Merge remote-tracking branch 'origin/dev' into fix/doctor-custom-providers-rebased
This commit is contained in:
@@ -47,7 +47,7 @@ jobs:
|
|||||||
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
|
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: bun test
|
run: bun run script/run-ci-tests.ts
|
||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ jobs:
|
|||||||
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
|
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: bun test
|
run: bun run script/run-ci-tests.ts
|
||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# oh-my-opencode — O P E N C O D E Plugin
|
# oh-my-opencode — O P E N C O D E Plugin
|
||||||
|
|
||||||
**Generated:** 2026-04-05 | **Commit:** c9be5bb51 | **Branch:** dev
|
**Generated:** 2026-04-08 | **Commit:** 4f196f49 | **Branch:** dev
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"$id": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||||
|
"title": "Oh My OpenCode Configuration",
|
||||||
|
"description": "Configuration schema for oh-my-opencode plugin",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"$schema": {
|
"$schema": {
|
||||||
@@ -6068,8 +6071,5 @@
|
|||||||
"required": [
|
"required": [
|
||||||
"git_master"
|
"git_master"
|
||||||
],
|
],
|
||||||
"additionalProperties": false,
|
"additionalProperties": false
|
||||||
"$id": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
|
||||||
"title": "Oh My OpenCode Configuration",
|
|
||||||
"description": "Configuration schema for oh-my-opencode plugin"
|
|
||||||
}
|
}
|
||||||
@@ -10,8 +10,8 @@
|
|||||||
"@clack/prompts": "^0.11.0",
|
"@clack/prompts": "^0.11.0",
|
||||||
"@code-yeongyu/comment-checker": "^0.7.0",
|
"@code-yeongyu/comment-checker": "^0.7.0",
|
||||||
"@modelcontextprotocol/sdk": "^1.25.2",
|
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||||
"@opencode-ai/plugin": "^1.2.24",
|
"@opencode-ai/plugin": "^1.4.0",
|
||||||
"@opencode-ai/sdk": "^1.2.24",
|
"@opencode-ai/sdk": "^1.4.0",
|
||||||
"commander": "^14.0.2",
|
"commander": "^14.0.2",
|
||||||
"detect-libc": "^2.0.0",
|
"detect-libc": "^2.0.0",
|
||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
@@ -20,8 +20,7 @@
|
|||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"picomatch": "^4.0.2",
|
"picomatch": "^4.0.2",
|
||||||
"vscode-jsonrpc": "^8.2.0",
|
"vscode-jsonrpc": "^8.2.0",
|
||||||
"zod": "^3.24.0",
|
"zod": "^4.3.0",
|
||||||
"zod-to-json-schema": "^3.25.1",
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
@@ -30,17 +29,17 @@
|
|||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"oh-my-opencode-darwin-arm64": "3.15.3",
|
"oh-my-opencode-darwin-arm64": "3.16.0",
|
||||||
"oh-my-opencode-darwin-x64": "3.15.3",
|
"oh-my-opencode-darwin-x64": "3.16.0",
|
||||||
"oh-my-opencode-darwin-x64-baseline": "3.15.3",
|
"oh-my-opencode-darwin-x64-baseline": "3.16.0",
|
||||||
"oh-my-opencode-linux-arm64": "3.15.3",
|
"oh-my-opencode-linux-arm64": "3.16.0",
|
||||||
"oh-my-opencode-linux-arm64-musl": "3.15.3",
|
"oh-my-opencode-linux-arm64-musl": "3.16.0",
|
||||||
"oh-my-opencode-linux-x64": "3.15.3",
|
"oh-my-opencode-linux-x64": "3.16.0",
|
||||||
"oh-my-opencode-linux-x64-baseline": "3.15.3",
|
"oh-my-opencode-linux-x64-baseline": "3.16.0",
|
||||||
"oh-my-opencode-linux-x64-musl": "3.15.3",
|
"oh-my-opencode-linux-x64-musl": "3.16.0",
|
||||||
"oh-my-opencode-linux-x64-musl-baseline": "3.15.3",
|
"oh-my-opencode-linux-x64-musl-baseline": "3.16.0",
|
||||||
"oh-my-opencode-windows-x64": "3.15.3",
|
"oh-my-opencode-windows-x64": "3.16.0",
|
||||||
"oh-my-opencode-windows-x64-baseline": "3.15.3",
|
"oh-my-opencode-windows-x64-baseline": "3.16.0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -49,9 +48,6 @@
|
|||||||
"@ast-grep/napi",
|
"@ast-grep/napi",
|
||||||
"@code-yeongyu/comment-checker",
|
"@code-yeongyu/comment-checker",
|
||||||
],
|
],
|
||||||
"overrides": {
|
|
||||||
"@opencode-ai/sdk": "^1.2.24",
|
|
||||||
},
|
|
||||||
"packages": {
|
"packages": {
|
||||||
"@ast-grep/cli": ["@ast-grep/cli@0.41.1", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "@ast-grep/cli-darwin-arm64": "0.41.1", "@ast-grep/cli-darwin-x64": "0.41.1", "@ast-grep/cli-linux-arm64-gnu": "0.41.1", "@ast-grep/cli-linux-x64-gnu": "0.41.1", "@ast-grep/cli-win32-arm64-msvc": "0.41.1", "@ast-grep/cli-win32-ia32-msvc": "0.41.1", "@ast-grep/cli-win32-x64-msvc": "0.41.1" }, "bin": { "sg": "sg", "ast-grep": "ast-grep" } }, "sha512-6oSuzF1Ra0d9jdcmflRIR1DHcicI7TYVxaaV/hajV51J49r6C+1BA2H9G+e47lH4sDEXUS9KWLNGNvXa/Gqs5A=="],
|
"@ast-grep/cli": ["@ast-grep/cli@0.41.1", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "@ast-grep/cli-darwin-arm64": "0.41.1", "@ast-grep/cli-darwin-x64": "0.41.1", "@ast-grep/cli-linux-arm64-gnu": "0.41.1", "@ast-grep/cli-linux-x64-gnu": "0.41.1", "@ast-grep/cli-win32-arm64-msvc": "0.41.1", "@ast-grep/cli-win32-ia32-msvc": "0.41.1", "@ast-grep/cli-win32-x64-msvc": "0.41.1" }, "bin": { "sg": "sg", "ast-grep": "ast-grep" } }, "sha512-6oSuzF1Ra0d9jdcmflRIR1DHcicI7TYVxaaV/hajV51J49r6C+1BA2H9G+e47lH4sDEXUS9KWLNGNvXa/Gqs5A=="],
|
||||||
|
|
||||||
@@ -99,9 +95,9 @@
|
|||||||
|
|
||||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="],
|
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="],
|
||||||
|
|
||||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.2.24", "", { "dependencies": { "@opencode-ai/sdk": "1.2.24", "zod": "4.1.8" } }, "sha512-B3hw415D+2w6AtdRdvKWkuQVT0LXDWTdnAZhZC6gbd+UHh5O5DMmnZTe/YM8yK8ZZO9Dvo5rnV78TdDDYunJiw=="],
|
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.4.0", "", { "dependencies": { "@opencode-ai/sdk": "1.4.0", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.1.97", "@opentui/solid": ">=0.1.97" }, "optionalPeers": ["@opentui/core", "@opentui/solid"] }, "sha512-VFIff6LHp/RVaJdrK3EQ1ijx0K1tV5i1DY5YJ+pRqwC6trunPHbvqSN0GHSTZX39RdnSc+XuzCTZQCy1W2qNOg=="],
|
||||||
|
|
||||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.24", "", {}, "sha512-MQamFkRl4B/3d6oIRLNpkYR2fcwet1V/ffKyOKJXWjtP/CT9PDJMtLpu6olVHjXKQi8zMNltwuMhv1QsNtRlZg=="],
|
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.4.0", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-mfa3MzhqNM+Az4bgPDDXL3NdG+aYOHClXmT6/4qLxf2ulyfPpMNHqb9Dfmo4D8UfmrDsPuJHmbune73/nUQnuw=="],
|
||||||
|
|
||||||
"@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="],
|
"@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="],
|
||||||
|
|
||||||
@@ -239,28 +235,6 @@
|
|||||||
|
|
||||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||||
|
|
||||||
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.15.3", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-FApyQE45gv3VFwS/7iLS1/84v4iTX6BIVNcYYU2faqPazcZkvenkMbtxuWRfohQyZ1lhADopnjUcqOdcKjLDGQ=="],
|
|
||||||
|
|
||||||
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.15.3", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-h4fr0/myoyvvytdizfLNQgRAWK+hw+1tW32rgL7ENLv1JQ8ChXHnHKEQ2saEqGfn1SuXvA0xUTsFMYR8q3mnbA=="],
|
|
||||||
|
|
||||||
"oh-my-opencode-darwin-x64-baseline": ["oh-my-opencode-darwin-x64-baseline@3.15.3", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Zhi5xGcEhirHcx95kZtABYlIdSt6a5L5+T+exR4Kcnu+KR1mJ6li9n3UBIiW8eVgDz2ls7W25ePD78xRlqnxlg=="],
|
|
||||||
|
|
||||||
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.15.3", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-+lDsQMPfXGCrwe9vqHdmp1tCJ8PV+5OkKueVorRwXNfiZNOW3848TKxtW3QdkKopiBKejEaDfyu/IGSgWQ/iyQ=="],
|
|
||||||
|
|
||||||
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.15.3", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-cokhNYK+dBVPRmZ2bYd3ZNp7dSGZdko77qUaeb0jjALFWkNzzmFgOV0spgOGZ3iS+yMS1XjAheTo5Qswh0capQ=="],
|
|
||||||
|
|
||||||
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.15.3", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-8+57NMUwdcc2DZGX6KlNb1EchTB6xmwiiHcRhFZpYiAB1GCUFNeWihq3D7r5GUtOs0zQYWUT/F1Rj2nzBxuy+A=="],
|
|
||||||
|
|
||||||
"oh-my-opencode-linux-x64-baseline": ["oh-my-opencode-linux-x64-baseline@3.15.3", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-1awTpjU8m1cLF+GiiT7BuK5+y+WvTZwAaBZzYrJBzldiqdqMGJVYaH/uLiKt6CdZ0T6jh0zR/v85VFZIaXRusQ=="],
|
|
||||||
|
|
||||||
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.15.3", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-WhmJ9ZwXxe3Nv0sVnFN3ibykie1JDiXthOmErhtKbcAL9V25IDYSbTcjxY2jUq0rNr4PeTvBva+WkMW4k9438w=="],
|
|
||||||
|
|
||||||
"oh-my-opencode-linux-x64-musl-baseline": ["oh-my-opencode-linux-x64-musl-baseline@3.15.3", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Gx2YitS/Ydg1XdwZMAH186ABvHGPlnuVA/1j7nGdARIwNM/xz6bZRq+kaeMmlj2N1U63unMOHe1ibE6nL1oZSw=="],
|
|
||||||
|
|
||||||
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.15.3", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-Q6xskcBlBqUT77OK+7oHID9McrHu6t5+P/YCaDU/zLvr1T8M0Z5WgakM5hRsqCI8e4P1NEX6wHtwQNbVfUgo1w=="],
|
|
||||||
|
|
||||||
"oh-my-opencode-windows-x64-baseline": ["oh-my-opencode-windows-x64-baseline@3.15.3", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-2BlXtH+DrSRPFGEOtfY1mlROOXFeWQbG/EpDw0JD27s7QQOkShaDff8Vc48PnmD1H8vW4d7/o/eP8jJPPjGQ0w=="],
|
|
||||||
|
|
||||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||||
|
|
||||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||||
@@ -331,12 +305,10 @@
|
|||||||
|
|
||||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||||
|
|
||||||
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||||
|
|
||||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
|
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
|
||||||
|
|
||||||
"@modelcontextprotocol/sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
|
||||||
|
|
||||||
"@opencode-ai/plugin/zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
|
"@opencode-ai/plugin/zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-19
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode",
|
"name": "oh-my-opencode",
|
||||||
"version": "3.15.3",
|
"version": "3.16.0",
|
||||||
"description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
|
"description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
@@ -59,8 +59,8 @@
|
|||||||
"@clack/prompts": "^0.11.0",
|
"@clack/prompts": "^0.11.0",
|
||||||
"@code-yeongyu/comment-checker": "^0.7.0",
|
"@code-yeongyu/comment-checker": "^0.7.0",
|
||||||
"@modelcontextprotocol/sdk": "^1.25.2",
|
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||||
"@opencode-ai/plugin": "^1.2.24",
|
"@opencode-ai/plugin": "^1.4.0",
|
||||||
"@opencode-ai/sdk": "^1.2.24",
|
"@opencode-ai/sdk": "^1.4.0",
|
||||||
"commander": "^14.0.2",
|
"commander": "^14.0.2",
|
||||||
"detect-libc": "^2.0.0",
|
"detect-libc": "^2.0.0",
|
||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
@@ -69,8 +69,7 @@
|
|||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"picomatch": "^4.0.2",
|
"picomatch": "^4.0.2",
|
||||||
"vscode-jsonrpc": "^8.2.0",
|
"vscode-jsonrpc": "^8.2.0",
|
||||||
"zod-to-json-schema": "^3.25.1",
|
"zod": "^4.3.0"
|
||||||
"zod": "^3.24.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
@@ -79,21 +78,19 @@
|
|||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"oh-my-opencode-darwin-arm64": "3.15.3",
|
"oh-my-opencode-darwin-arm64": "3.16.0",
|
||||||
"oh-my-opencode-darwin-x64": "3.15.3",
|
"oh-my-opencode-darwin-x64": "3.16.0",
|
||||||
"oh-my-opencode-darwin-x64-baseline": "3.15.3",
|
"oh-my-opencode-darwin-x64-baseline": "3.16.0",
|
||||||
"oh-my-opencode-linux-arm64": "3.15.3",
|
"oh-my-opencode-linux-arm64": "3.16.0",
|
||||||
"oh-my-opencode-linux-arm64-musl": "3.15.3",
|
"oh-my-opencode-linux-arm64-musl": "3.16.0",
|
||||||
"oh-my-opencode-linux-x64": "3.15.3",
|
"oh-my-opencode-linux-x64": "3.16.0",
|
||||||
"oh-my-opencode-linux-x64-baseline": "3.15.3",
|
"oh-my-opencode-linux-x64-baseline": "3.16.0",
|
||||||
"oh-my-opencode-linux-x64-musl": "3.15.3",
|
"oh-my-opencode-linux-x64-musl": "3.16.0",
|
||||||
"oh-my-opencode-linux-x64-musl-baseline": "3.15.3",
|
"oh-my-opencode-linux-x64-musl-baseline": "3.16.0",
|
||||||
"oh-my-opencode-windows-x64": "3.15.3",
|
"oh-my-opencode-windows-x64": "3.16.0",
|
||||||
"oh-my-opencode-windows-x64-baseline": "3.15.3"
|
"oh-my-opencode-windows-x64-baseline": "3.16.0"
|
||||||
},
|
|
||||||
"overrides": {
|
|
||||||
"@opencode-ai/sdk": "^1.2.24"
|
|
||||||
},
|
},
|
||||||
|
"overrides": {},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
"@ast-grep/cli",
|
"@ast-grep/cli",
|
||||||
"@ast-grep/napi",
|
"@ast-grep/napi",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-darwin-arm64",
|
"name": "oh-my-opencode-darwin-arm64",
|
||||||
"version": "3.15.3",
|
"version": "3.16.0",
|
||||||
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
|
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-darwin-x64-baseline",
|
"name": "oh-my-opencode-darwin-x64-baseline",
|
||||||
"version": "3.15.3",
|
"version": "3.16.0",
|
||||||
"description": "Platform-specific binary for oh-my-opencode (darwin-x64-baseline, no AVX2)",
|
"description": "Platform-specific binary for oh-my-opencode (darwin-x64-baseline, no AVX2)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-darwin-x64",
|
"name": "oh-my-opencode-darwin-x64",
|
||||||
"version": "3.15.3",
|
"version": "3.16.0",
|
||||||
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
|
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-linux-arm64-musl",
|
"name": "oh-my-opencode-linux-arm64-musl",
|
||||||
"version": "3.15.3",
|
"version": "3.16.0",
|
||||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
|
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-linux-arm64",
|
"name": "oh-my-opencode-linux-arm64",
|
||||||
"version": "3.15.3",
|
"version": "3.16.0",
|
||||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
|
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-linux-x64-baseline",
|
"name": "oh-my-opencode-linux-x64-baseline",
|
||||||
"version": "3.15.3",
|
"version": "3.16.0",
|
||||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64-baseline, no AVX2)",
|
"description": "Platform-specific binary for oh-my-opencode (linux-x64-baseline, no AVX2)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-linux-x64-musl-baseline",
|
"name": "oh-my-opencode-linux-x64-musl-baseline",
|
||||||
"version": "3.15.3",
|
"version": "3.16.0",
|
||||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl-baseline, no AVX2)",
|
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl-baseline, no AVX2)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-linux-x64-musl",
|
"name": "oh-my-opencode-linux-x64-musl",
|
||||||
"version": "3.15.3",
|
"version": "3.16.0",
|
||||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
|
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-linux-x64",
|
"name": "oh-my-opencode-linux-x64",
|
||||||
"version": "3.15.3",
|
"version": "3.16.0",
|
||||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
|
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-windows-x64-baseline",
|
"name": "oh-my-opencode-windows-x64-baseline",
|
||||||
"version": "3.15.3",
|
"version": "3.16.0",
|
||||||
"description": "Platform-specific binary for oh-my-opencode (windows-x64-baseline, no AVX2)",
|
"description": "Platform-specific binary for oh-my-opencode (windows-x64-baseline, no AVX2)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-windows-x64",
|
"name": "oh-my-opencode-windows-x64",
|
||||||
"version": "3.15.3",
|
"version": "3.16.0",
|
||||||
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
|
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
+63
-1
@@ -7,6 +7,60 @@ import { getPlatformPackageCandidates, getBinaryPath } from "./bin/platform.js";
|
|||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
|
const MIN_OPENCODE_VERSION = "1.4.0";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse version string into numeric parts
|
||||||
|
* @param {string} version
|
||||||
|
* @returns {number[]}
|
||||||
|
*/
|
||||||
|
function parseVersion(version) {
|
||||||
|
return version
|
||||||
|
.replace(/^v/, "")
|
||||||
|
.split("-")[0]
|
||||||
|
.split(".")
|
||||||
|
.map((part) => Number.parseInt(part, 10) || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare two version strings
|
||||||
|
* @param {string} current
|
||||||
|
* @param {string} minimum
|
||||||
|
* @returns {boolean} true if current >= minimum
|
||||||
|
*/
|
||||||
|
function compareVersions(current, minimum) {
|
||||||
|
const currentParts = parseVersion(current);
|
||||||
|
const minimumParts = parseVersion(minimum);
|
||||||
|
const length = Math.max(currentParts.length, minimumParts.length);
|
||||||
|
|
||||||
|
for (let index = 0; index < length; index++) {
|
||||||
|
const currentPart = currentParts[index] ?? 0;
|
||||||
|
const minimumPart = minimumParts[index] ?? 0;
|
||||||
|
if (currentPart > minimumPart) return true;
|
||||||
|
if (currentPart < minimumPart) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if opencode version meets minimum requirement
|
||||||
|
* @returns {{ok: boolean, version: string | null}}
|
||||||
|
*/
|
||||||
|
function checkOpenCodeVersion() {
|
||||||
|
try {
|
||||||
|
const result = require("child_process").execSync("opencode --version", {
|
||||||
|
encoding: "utf-8",
|
||||||
|
stdio: ["pipe", "pipe", "ignore"],
|
||||||
|
});
|
||||||
|
const version = result.trim();
|
||||||
|
const ok = compareVersions(version, MIN_OPENCODE_VERSION);
|
||||||
|
return { ok, version };
|
||||||
|
} catch {
|
||||||
|
return { ok: true, version: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect libc family on Linux
|
* Detect libc family on Linux
|
||||||
*/
|
*/
|
||||||
@@ -36,7 +90,15 @@ function main() {
|
|||||||
const { platform, arch } = process;
|
const { platform, arch } = process;
|
||||||
const libcFamily = getLibcFamily();
|
const libcFamily = getLibcFamily();
|
||||||
const packageBaseName = getPackageBaseName();
|
const packageBaseName = getPackageBaseName();
|
||||||
|
|
||||||
|
// Check opencode version requirement
|
||||||
|
const versionCheck = checkOpenCodeVersion();
|
||||||
|
if (versionCheck.version && !versionCheck.ok) {
|
||||||
|
console.warn(`⚠ oh-my-opencode requires OpenCode >= ${MIN_OPENCODE_VERSION}`);
|
||||||
|
console.warn(` Detected: ${versionCheck.version}`);
|
||||||
|
console.warn(` Please update OpenCode to avoid compatibility issues.`);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const packageCandidates = getPlatformPackageCandidates({
|
const packageCandidates = getPlatformPackageCandidates({
|
||||||
platform,
|
platform,
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { zodToJsonSchema } from "zod-to-json-schema"
|
import { z } from "zod"
|
||||||
import { OhMyOpenCodeConfigSchema } from "../src/config/schema"
|
import { OhMyOpenCodeConfigSchema } from "../src/config/schema"
|
||||||
|
|
||||||
export function createOhMyOpenCodeJsonSchema(): Record<string, unknown> {
|
export function createOhMyOpenCodeJsonSchema(): Record<string, unknown> {
|
||||||
const jsonSchema = zodToJsonSchema(OhMyOpenCodeConfigSchema) as Record<string, unknown>
|
const jsonSchema = z.toJSONSchema(OhMyOpenCodeConfigSchema, {
|
||||||
|
target: "draft-7",
|
||||||
|
unrepresentable: "any",
|
||||||
|
}) as Record<string, unknown>
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...jsonSchema,
|
|
||||||
$schema: "http://json-schema.org/draft-07/schema#",
|
$schema: "http://json-schema.org/draft-07/schema#",
|
||||||
$id: "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
$id: "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||||
title: "Oh My OpenCode Configuration",
|
title: "Oh My OpenCode Configuration",
|
||||||
description: "Configuration schema for oh-my-opencode plugin",
|
description: "Configuration schema for oh-my-opencode plugin",
|
||||||
|
...jsonSchema,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ describe("test workflows", () => {
|
|||||||
const workflow = readFileSync(workflowPath, "utf8")
|
const workflow = readFileSync(workflowPath, "utf8")
|
||||||
|
|
||||||
expect(workflow).toContain("- name: Run tests")
|
expect(workflow).toContain("- name: Run tests")
|
||||||
expect(workflow).toContain("run: bun test")
|
expect(workflow).toMatch(/run: bun (test|run script\/run-ci-tests\.ts)/)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -29,13 +29,7 @@ async function usesModuleMock(rootDirectory: string, testFile: string): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toIsolatedTarget(testFile: string): string {
|
function toIsolatedTarget(testFile: string): string {
|
||||||
const pathSegments = testFile.split("/")
|
return testFile
|
||||||
|
|
||||||
if (pathSegments.length <= 3) {
|
|
||||||
return testFile
|
|
||||||
}
|
|
||||||
|
|
||||||
return pathSegments.slice(0, -1).join("/")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isCoveredByTarget(testFile: string, isolatedTarget: string): boolean {
|
function isCoveredByTarget(testFile: string, isolatedTarget: string): boolean {
|
||||||
|
|||||||
@@ -2599,6 +2599,54 @@
|
|||||||
"created_at": "2026-04-06T15:37:54Z",
|
"created_at": "2026-04-06T15:37:54Z",
|
||||||
"repoId": 1108837393,
|
"repoId": 1108837393,
|
||||||
"pullRequestNo": 3160
|
"pullRequestNo": 3160
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "teneburu",
|
||||||
|
"id": 43727604,
|
||||||
|
"comment_id": 4199167526,
|
||||||
|
"created_at": "2026-04-07T13:06:07Z",
|
||||||
|
"repoId": 1108837393,
|
||||||
|
"pullRequestNo": 3203
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "dhruvkej9",
|
||||||
|
"id": 96516827,
|
||||||
|
"comment_id": 4204071246,
|
||||||
|
"created_at": "2026-04-08T05:36:52Z",
|
||||||
|
"repoId": 1108837393,
|
||||||
|
"pullRequestNo": 3217
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "dhruvkej9",
|
||||||
|
"id": 96516827,
|
||||||
|
"comment_id": 4204084942,
|
||||||
|
"created_at": "2026-04-08T05:40:40Z",
|
||||||
|
"repoId": 1108837393,
|
||||||
|
"pullRequestNo": 3217
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "FrancoStino",
|
||||||
|
"id": 32127923,
|
||||||
|
"comment_id": 4205715582,
|
||||||
|
"created_at": "2026-04-08T10:52:39Z",
|
||||||
|
"repoId": 1108837393,
|
||||||
|
"pullRequestNo": 3234
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sen7971",
|
||||||
|
"id": 193416996,
|
||||||
|
"comment_id": 4207621925,
|
||||||
|
"created_at": "2026-04-08T15:57:15Z",
|
||||||
|
"repoId": 1108837393,
|
||||||
|
"pullRequestNo": 3248
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "NikkeTryHard",
|
||||||
|
"id": 111729769,
|
||||||
|
"comment_id": 4210843488,
|
||||||
|
"created_at": "2026-04-09T01:34:03Z",
|
||||||
|
"repoId": 1108837393,
|
||||||
|
"pullRequestNo": 3261
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -21,11 +21,12 @@ describe("runCliInstaller", () => {
|
|||||||
console.error = originalConsoleError
|
console.error = originalConsoleError
|
||||||
})
|
})
|
||||||
|
|
||||||
it("completes installation without auth plugin or provider config steps", async () => {
|
it("blocks installation when OpenCode is below the minimum version", async () => {
|
||||||
//#given
|
// given
|
||||||
const restoreSpies = [
|
const restoreSpies = [
|
||||||
spyOn(configManager, "detectCurrentConfig").mockReturnValue({
|
spyOn(configManager, "detectCurrentConfig").mockReturnValue({
|
||||||
isInstalled: false,
|
isInstalled: false,
|
||||||
|
installedVersion: null,
|
||||||
hasClaude: false,
|
hasClaude: false,
|
||||||
isMax20: false,
|
isMax20: false,
|
||||||
hasOpenAI: false,
|
hasOpenAI: false,
|
||||||
@@ -34,9 +35,56 @@ describe("runCliInstaller", () => {
|
|||||||
hasOpencodeZen: false,
|
hasOpencodeZen: false,
|
||||||
hasZaiCodingPlan: false,
|
hasZaiCodingPlan: false,
|
||||||
hasKimiForCoding: false,
|
hasKimiForCoding: false,
|
||||||
|
hasOpencodeGo: false,
|
||||||
}),
|
}),
|
||||||
spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true),
|
spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true),
|
||||||
spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.0.200"),
|
spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.3.9"),
|
||||||
|
]
|
||||||
|
const addPluginSpy = spyOn(configManager, "addPluginToOpenCodeConfig")
|
||||||
|
|
||||||
|
const args: InstallArgs = {
|
||||||
|
tui: false,
|
||||||
|
claude: "no",
|
||||||
|
openai: "no",
|
||||||
|
gemini: "no",
|
||||||
|
copilot: "no",
|
||||||
|
opencodeZen: "no",
|
||||||
|
zaiCodingPlan: "no",
|
||||||
|
kimiForCoding: "no",
|
||||||
|
opencodeGo: "no",
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await runCliInstaller(args, "3.16.0")
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toBe(1)
|
||||||
|
expect(addPluginSpy).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
for (const spy of restoreSpies) {
|
||||||
|
spy.mockRestore()
|
||||||
|
}
|
||||||
|
addPluginSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("completes installation without auth plugin or provider config steps", async () => {
|
||||||
|
// given
|
||||||
|
const restoreSpies = [
|
||||||
|
spyOn(configManager, "detectCurrentConfig").mockReturnValue({
|
||||||
|
isInstalled: false,
|
||||||
|
installedVersion: null,
|
||||||
|
hasClaude: false,
|
||||||
|
isMax20: false,
|
||||||
|
hasOpenAI: false,
|
||||||
|
hasGemini: false,
|
||||||
|
hasCopilot: false,
|
||||||
|
hasOpencodeZen: false,
|
||||||
|
hasZaiCodingPlan: false,
|
||||||
|
hasKimiForCoding: false,
|
||||||
|
hasOpencodeGo: false,
|
||||||
|
}),
|
||||||
|
spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true),
|
||||||
|
spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.4.0"),
|
||||||
spyOn(configManager, "addPluginToOpenCodeConfig").mockResolvedValue({
|
spyOn(configManager, "addPluginToOpenCodeConfig").mockResolvedValue({
|
||||||
success: true,
|
success: true,
|
||||||
configPath: "/tmp/opencode.jsonc",
|
configPath: "/tmp/opencode.jsonc",
|
||||||
@@ -56,12 +104,13 @@ describe("runCliInstaller", () => {
|
|||||||
opencodeZen: "no",
|
opencodeZen: "no",
|
||||||
zaiCodingPlan: "no",
|
zaiCodingPlan: "no",
|
||||||
kimiForCoding: "no",
|
kimiForCoding: "no",
|
||||||
|
opencodeGo: "no",
|
||||||
}
|
}
|
||||||
|
|
||||||
//#when
|
// when
|
||||||
const result = await runCliInstaller(args, "3.4.0")
|
const result = await runCliInstaller(args, "3.4.0")
|
||||||
|
|
||||||
//#then
|
// then
|
||||||
expect(result).toBe(0)
|
expect(result).toBe(0)
|
||||||
|
|
||||||
for (const spy of restoreSpies) {
|
for (const spy of restoreSpies) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
printWarning,
|
printWarning,
|
||||||
validateNonTuiArgs,
|
validateNonTuiArgs,
|
||||||
} from "./install-validators"
|
} from "./install-validators"
|
||||||
|
import { getUnsupportedOpenCodeVersionMessage } from "./minimum-opencode-version"
|
||||||
|
|
||||||
export async function runCliInstaller(args: InstallArgs, version: string): Promise<number> {
|
export async function runCliInstaller(args: InstallArgs, version: string): Promise<number> {
|
||||||
const validation = validateNonTuiArgs(args)
|
const validation = validateNonTuiArgs(args)
|
||||||
@@ -57,6 +58,12 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi
|
|||||||
printInfo("Visit https://opencode.ai/docs for installation instructions")
|
printInfo("Visit https://opencode.ai/docs for installation instructions")
|
||||||
} else {
|
} else {
|
||||||
printSuccess(`OpenCode ${openCodeVersion ?? ""} detected`)
|
printSuccess(`OpenCode ${openCodeVersion ?? ""} detected`)
|
||||||
|
|
||||||
|
const unsupportedVersionMessage = getUnsupportedOpenCodeVersionMessage(openCodeVersion)
|
||||||
|
if (unsupportedVersionMessage) {
|
||||||
|
printWarning(unsupportedVersionMessage)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isUpdate) {
|
if (isUpdate) {
|
||||||
|
|||||||
@@ -18,3 +18,12 @@ export { detectCurrentConfig } from "./config-manager/detect-current-config"
|
|||||||
|
|
||||||
export type { BunInstallResult } from "./config-manager/bun-install"
|
export type { BunInstallResult } from "./config-manager/bun-install"
|
||||||
export { runBunInstall, runBunInstallWithDetails } from "./config-manager/bun-install"
|
export { runBunInstall, runBunInstallWithDetails } from "./config-manager/bun-install"
|
||||||
|
|
||||||
|
export type { VersionCompatibility } from "./config-manager/version-compatibility"
|
||||||
|
export {
|
||||||
|
checkVersionCompatibility,
|
||||||
|
extractVersionFromPluginEntry,
|
||||||
|
} from "./config-manager/version-compatibility"
|
||||||
|
|
||||||
|
export type { BackupResult } from "./config-manager/backup-config"
|
||||||
|
export { backupConfigFile } from "./config-manager/backup-config"
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { readFileSync, writeFileSync } from "node:fs"
|
import { readFileSync, writeFileSync } from "node:fs"
|
||||||
import type { ConfigMergeResult } from "../types"
|
import type { ConfigMergeResult } from "../types"
|
||||||
import { PLUGIN_NAME, LEGACY_PLUGIN_NAME } from "../../shared"
|
import { PLUGIN_NAME, LEGACY_PLUGIN_NAME } from "../../shared"
|
||||||
|
import { backupConfigFile } from "./backup-config"
|
||||||
import { getConfigDir } from "./config-context"
|
import { getConfigDir } from "./config-context"
|
||||||
import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists"
|
import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists"
|
||||||
import { formatErrorWithSuggestion } from "./format-error-with-suggestion"
|
import { formatErrorWithSuggestion } from "./format-error-with-suggestion"
|
||||||
import { detectConfigFormat } from "./opencode-config-format"
|
import { detectConfigFormat } from "./opencode-config-format"
|
||||||
import { parseOpenCodeConfigFileWithError, type OpenCodeConfig } from "./parse-opencode-config-file"
|
import { parseOpenCodeConfigFileWithError, type OpenCodeConfig } from "./parse-opencode-config-file"
|
||||||
import { getPluginNameWithVersion } from "./plugin-name-with-version"
|
import { getPluginNameWithVersion } from "./plugin-name-with-version"
|
||||||
|
import { checkVersionCompatibility, extractVersionFromPluginEntry } from "./version-compatibility"
|
||||||
|
|
||||||
export async function addPluginToOpenCodeConfig(currentVersion: string): Promise<ConfigMergeResult> {
|
export async function addPluginToOpenCodeConfig(currentVersion: string): Promise<ConfigMergeResult> {
|
||||||
try {
|
try {
|
||||||
@@ -52,14 +54,33 @@ export async function addPluginToOpenCodeConfig(currentVersion: string): Promise
|
|||||||
&& !(plugin === LEGACY_PLUGIN_NAME || plugin.startsWith(`${LEGACY_PLUGIN_NAME}@`))
|
&& !(plugin === LEGACY_PLUGIN_NAME || plugin.startsWith(`${LEGACY_PLUGIN_NAME}@`))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const existingEntry = canonicalEntries[0] ?? legacyEntries[0]
|
||||||
|
if (existingEntry) {
|
||||||
|
const installedVersion = extractVersionFromPluginEntry(existingEntry)
|
||||||
|
const compatibility = checkVersionCompatibility(installedVersion, currentVersion)
|
||||||
|
|
||||||
|
if (!compatibility.canUpgrade) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
configPath: path,
|
||||||
|
error: compatibility.reason ?? "Version compatibility check failed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const backupResult = backupConfigFile(path)
|
||||||
|
if (!backupResult.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
configPath: path,
|
||||||
|
error: `Failed to create backup: ${backupResult.error}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedPlugins = [...otherPlugins]
|
const normalizedPlugins = [...otherPlugins]
|
||||||
|
|
||||||
if (canonicalEntries.length > 0) {
|
if (canonicalEntries.length > 0 || legacyEntries.length > 0) {
|
||||||
normalizedPlugins.push(canonicalEntries[0])
|
normalizedPlugins.push(pluginEntry)
|
||||||
} else if (legacyEntries.length > 0) {
|
|
||||||
const versionMatch = legacyEntries[0].match(/@(.+)$/)
|
|
||||||
const preservedVersion = versionMatch ? versionMatch[1] : null
|
|
||||||
normalizedPlugins.push(preservedVersion ? `${PLUGIN_NAME}@${preservedVersion}` : pluginEntry)
|
|
||||||
} else {
|
} else {
|
||||||
normalizedPlugins.push(pluginEntry)
|
normalizedPlugins.push(pluginEntry)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { copyFileSync, existsSync, mkdirSync } from "node:fs"
|
||||||
|
import { dirname } from "node:path"
|
||||||
|
|
||||||
|
export interface BackupResult {
|
||||||
|
success: boolean
|
||||||
|
backupPath?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function backupConfigFile(configPath: string): BackupResult {
|
||||||
|
if (!existsSync(configPath)) {
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-")
|
||||||
|
const backupPath = `${configPath}.backup-${timestamp}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dir = dirname(backupPath)
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
mkdirSync(dir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
copyFileSync(configPath, backupPath)
|
||||||
|
return { success: true, backupPath }
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: err instanceof Error ? err.message : "Failed to create backup",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import type { DetectedConfig } from "../types"
|
|||||||
import { getOmoConfigPath } from "./config-context"
|
import { getOmoConfigPath } from "./config-context"
|
||||||
import { detectConfigFormat } from "./opencode-config-format"
|
import { detectConfigFormat } from "./opencode-config-format"
|
||||||
import { parseOpenCodeConfigFileWithError } from "./parse-opencode-config-file"
|
import { parseOpenCodeConfigFileWithError } from "./parse-opencode-config-file"
|
||||||
|
import { extractVersionFromPluginEntry } from "./version-compatibility"
|
||||||
|
|
||||||
function detectProvidersFromOmoConfig(): {
|
function detectProvidersFromOmoConfig(): {
|
||||||
hasOpenAI: boolean
|
hasOpenAI: boolean
|
||||||
@@ -60,9 +61,14 @@ function isOurPlugin(plugin: string): boolean {
|
|||||||
plugin === LEGACY_PLUGIN_NAME || plugin.startsWith(`${LEGACY_PLUGIN_NAME}@`)
|
plugin === LEGACY_PLUGIN_NAME || plugin.startsWith(`${LEGACY_PLUGIN_NAME}@`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findOurPluginEntry(plugins: string[]): string | null {
|
||||||
|
return plugins.find(isOurPlugin) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
export function detectCurrentConfig(): DetectedConfig {
|
export function detectCurrentConfig(): DetectedConfig {
|
||||||
const result: DetectedConfig = {
|
const result: DetectedConfig = {
|
||||||
isInstalled: false,
|
isInstalled: false,
|
||||||
|
installedVersion: null,
|
||||||
hasClaude: true,
|
hasClaude: true,
|
||||||
isMax20: true,
|
isMax20: true,
|
||||||
hasOpenAI: true,
|
hasOpenAI: true,
|
||||||
@@ -86,7 +92,12 @@ export function detectCurrentConfig(): DetectedConfig {
|
|||||||
|
|
||||||
const openCodeConfig = parseResult.config
|
const openCodeConfig = parseResult.config
|
||||||
const plugins = openCodeConfig.plugin ?? []
|
const plugins = openCodeConfig.plugin ?? []
|
||||||
result.isInstalled = plugins.some(isOurPlugin)
|
const ourPluginEntry = findOurPluginEntry(plugins)
|
||||||
|
result.isInstalled = !!ourPluginEntry
|
||||||
|
|
||||||
|
if (ourPluginEntry) {
|
||||||
|
result.installedVersion = extractVersionFromPluginEntry(ourPluginEntry)
|
||||||
|
}
|
||||||
|
|
||||||
if (!result.isInstalled) {
|
if (!result.isInstalled) {
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"
|
||||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
@@ -6,6 +6,7 @@ import { join } from "node:path"
|
|||||||
import { resetConfigContext } from "./config-context"
|
import { resetConfigContext } from "./config-context"
|
||||||
import { detectCurrentConfig } from "./detect-current-config"
|
import { detectCurrentConfig } from "./detect-current-config"
|
||||||
import { addPluginToOpenCodeConfig } from "./add-plugin-to-opencode-config"
|
import { addPluginToOpenCodeConfig } from "./add-plugin-to-opencode-config"
|
||||||
|
import * as pluginNameWithVersion from "./plugin-name-with-version"
|
||||||
|
|
||||||
describe("detectCurrentConfig - single package detection", () => {
|
describe("detectCurrentConfig - single package detection", () => {
|
||||||
let testConfigDir = ""
|
let testConfigDir = ""
|
||||||
@@ -109,17 +110,19 @@ describe("addPluginToOpenCodeConfig - single package writes", () => {
|
|||||||
expect(savedConfig.plugin).toEqual(["oh-my-openagent"])
|
expect(savedConfig.plugin).toEqual(["oh-my-openagent"])
|
||||||
})
|
})
|
||||||
|
|
||||||
it("upgrades a version-pinned legacy entry to canonical", async () => {
|
it("updates a version-pinned legacy entry to the requested version", async () => {
|
||||||
// given
|
// given
|
||||||
writeFileSync(testConfigPath, JSON.stringify({ plugin: ["oh-my-opencode@3.10.0"] }, null, 2) + "\n", "utf-8")
|
const getPluginNameWithVersionSpy = spyOn(pluginNameWithVersion, "getPluginNameWithVersion").mockResolvedValue("oh-my-openagent@3.16.0")
|
||||||
|
writeFileSync(testConfigPath, JSON.stringify({ plugin: ["oh-my-opencode@3.15.0"] }, null, 2) + "\n", "utf-8")
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const result = await addPluginToOpenCodeConfig("3.11.0")
|
const result = await addPluginToOpenCodeConfig("3.16.0")
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(result.success).toBe(true)
|
expect(result.success).toBe(true)
|
||||||
const savedConfig = JSON.parse(readFileSync(testConfigPath, "utf-8"))
|
const savedConfig = JSON.parse(readFileSync(testConfigPath, "utf-8"))
|
||||||
expect(savedConfig.plugin).toEqual(["oh-my-openagent@3.10.0"])
|
expect(savedConfig.plugin).toEqual(["oh-my-openagent@3.16.0"])
|
||||||
|
getPluginNameWithVersionSpy.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("removes stale legacy entry when canonical and legacy entries both exist", async () => {
|
it("removes stale legacy entry when canonical and legacy entries both exist", async () => {
|
||||||
@@ -135,17 +138,36 @@ describe("addPluginToOpenCodeConfig - single package writes", () => {
|
|||||||
expect(savedConfig.plugin).toEqual(["oh-my-openagent"])
|
expect(savedConfig.plugin).toEqual(["oh-my-openagent"])
|
||||||
})
|
})
|
||||||
|
|
||||||
it("preserves a canonical entry when it already exists", async () => {
|
it("preserves a canonical entry when the same version is re-installed", async () => {
|
||||||
// given
|
// given
|
||||||
|
const getPluginNameWithVersionSpy = spyOn(pluginNameWithVersion, "getPluginNameWithVersion").mockResolvedValue("oh-my-openagent@3.10.0")
|
||||||
writeFileSync(testConfigPath, JSON.stringify({ plugin: ["oh-my-openagent@3.10.0"] }, null, 2) + "\n", "utf-8")
|
writeFileSync(testConfigPath, JSON.stringify({ plugin: ["oh-my-openagent@3.10.0"] }, null, 2) + "\n", "utf-8")
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const result = await addPluginToOpenCodeConfig("3.11.0")
|
const result = await addPluginToOpenCodeConfig("3.10.0")
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(result.success).toBe(true)
|
expect(result.success).toBe(true)
|
||||||
const savedConfig = JSON.parse(readFileSync(testConfigPath, "utf-8"))
|
const savedConfig = JSON.parse(readFileSync(testConfigPath, "utf-8"))
|
||||||
expect(savedConfig.plugin).toEqual(["oh-my-openagent@3.10.0"])
|
expect(savedConfig.plugin).toEqual(["oh-my-openagent@3.10.0"])
|
||||||
|
getPluginNameWithVersionSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("blocks a downgrade for a version-pinned canonical entry", async () => {
|
||||||
|
// given
|
||||||
|
const getPluginNameWithVersionSpy = spyOn(pluginNameWithVersion, "getPluginNameWithVersion").mockResolvedValue("oh-my-openagent@3.15.0")
|
||||||
|
writeFileSync(testConfigPath, JSON.stringify({ plugin: ["oh-my-openagent@3.16.0"] }, null, 2) + "\n", "utf-8")
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await addPluginToOpenCodeConfig("3.15.0")
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toContain("Downgrade")
|
||||||
|
|
||||||
|
const savedConfig = JSON.parse(readFileSync(testConfigPath, "utf-8"))
|
||||||
|
expect(savedConfig.plugin).toEqual(["oh-my-openagent@3.16.0"])
|
||||||
|
getPluginNameWithVersionSpy.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("rewrites quoted jsonc plugin field in place", async () => {
|
it("rewrites quoted jsonc plugin field in place", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, expect, it } from "bun:test"
|
||||||
|
import {
|
||||||
|
checkVersionCompatibility,
|
||||||
|
extractVersionFromPluginEntry,
|
||||||
|
} from "./version-compatibility"
|
||||||
|
|
||||||
|
describe("checkVersionCompatibility", () => {
|
||||||
|
it("allows fresh install when no current version", () => {
|
||||||
|
const result = checkVersionCompatibility(null, "3.15.0")
|
||||||
|
expect(result.canUpgrade).toBe(true)
|
||||||
|
expect(result.isDowngrade).toBe(false)
|
||||||
|
expect(result.requiresMigration).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("detects same version as already installed", () => {
|
||||||
|
const result = checkVersionCompatibility("3.15.0", "3.15.0")
|
||||||
|
expect(result.canUpgrade).toBe(true)
|
||||||
|
expect(result.reason).toContain("already installed")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("blocks downgrade from higher to lower version", () => {
|
||||||
|
const result = checkVersionCompatibility("3.15.0", "3.14.0")
|
||||||
|
expect(result.canUpgrade).toBe(false)
|
||||||
|
expect(result.isDowngrade).toBe(true)
|
||||||
|
expect(result.reason).toContain("Downgrade")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("allows patch version upgrade", () => {
|
||||||
|
const result = checkVersionCompatibility("3.15.0", "3.15.1")
|
||||||
|
expect(result.canUpgrade).toBe(true)
|
||||||
|
expect(result.isMajorBump).toBe(false)
|
||||||
|
expect(result.requiresMigration).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("allows minor version upgrade", () => {
|
||||||
|
const result = checkVersionCompatibility("3.15.0", "3.16.0")
|
||||||
|
expect(result.canUpgrade).toBe(true)
|
||||||
|
expect(result.isMajorBump).toBe(false)
|
||||||
|
expect(result.requiresMigration).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("detects major version bump requiring migration", () => {
|
||||||
|
const result = checkVersionCompatibility("3.15.0", "4.0.0")
|
||||||
|
expect(result.canUpgrade).toBe(true)
|
||||||
|
expect(result.isMajorBump).toBe(true)
|
||||||
|
expect(result.requiresMigration).toBe(true)
|
||||||
|
expect(result.reason).toContain("Major version upgrade")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles v prefix in versions", () => {
|
||||||
|
const result = checkVersionCompatibility("v3.15.0", "v3.16.0")
|
||||||
|
expect(result.canUpgrade).toBe(true)
|
||||||
|
expect(result.isDowngrade).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles mixed v prefix", () => {
|
||||||
|
const result = checkVersionCompatibility("3.15.0", "v3.16.0")
|
||||||
|
expect(result.canUpgrade).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("extractVersionFromPluginEntry", () => {
|
||||||
|
it("extracts version from canonical plugin entry", () => {
|
||||||
|
const version = extractVersionFromPluginEntry("oh-my-openagent@3.15.0")
|
||||||
|
expect(version).toBe("3.15.0")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("extracts version from legacy plugin entry", () => {
|
||||||
|
const version = extractVersionFromPluginEntry("oh-my-opencode@3.14.0")
|
||||||
|
expect(version).toBe("3.14.0")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns null for bare plugin entry", () => {
|
||||||
|
const version = extractVersionFromPluginEntry("oh-my-openagent")
|
||||||
|
expect(version).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles prerelease versions", () => {
|
||||||
|
const version = extractVersionFromPluginEntry("oh-my-openagent@3.16.0-beta.1")
|
||||||
|
expect(version).toBe("3.16.0-beta.1")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
export interface VersionCompatibility {
|
||||||
|
canUpgrade: boolean
|
||||||
|
reason?: string
|
||||||
|
isDowngrade: boolean
|
||||||
|
isMajorBump: boolean
|
||||||
|
requiresMigration: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVersion(version: string): number[] {
|
||||||
|
const clean = version.replace(/^v/, "").split("-")[0]
|
||||||
|
return clean.split(".").map(Number)
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareVersions(a: string, b: string): number {
|
||||||
|
const partsA = parseVersion(a)
|
||||||
|
const partsB = parseVersion(b)
|
||||||
|
const maxLen = Math.max(partsA.length, partsB.length)
|
||||||
|
|
||||||
|
for (let i = 0; i < maxLen; i++) {
|
||||||
|
const numA = partsA[i] ?? 0
|
||||||
|
const numB = partsB[i] ?? 0
|
||||||
|
if (numA !== numB) {
|
||||||
|
return numA - numB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkVersionCompatibility(
|
||||||
|
currentVersion: string | null,
|
||||||
|
newVersion: string
|
||||||
|
): VersionCompatibility {
|
||||||
|
if (!currentVersion) {
|
||||||
|
return {
|
||||||
|
canUpgrade: true,
|
||||||
|
isDowngrade: false,
|
||||||
|
isMajorBump: false,
|
||||||
|
requiresMigration: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanCurrent = currentVersion.replace(/^v/, "")
|
||||||
|
const cleanNew = newVersion.replace(/^v/, "")
|
||||||
|
|
||||||
|
try {
|
||||||
|
const comparison = compareVersions(cleanNew, cleanCurrent)
|
||||||
|
|
||||||
|
if (comparison < 0) {
|
||||||
|
return {
|
||||||
|
canUpgrade: false,
|
||||||
|
reason: `Downgrade from ${currentVersion} to ${newVersion} is not allowed`,
|
||||||
|
isDowngrade: true,
|
||||||
|
isMajorBump: false,
|
||||||
|
requiresMigration: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (comparison === 0) {
|
||||||
|
return {
|
||||||
|
canUpgrade: true,
|
||||||
|
reason: `Version ${newVersion} is already installed`,
|
||||||
|
isDowngrade: false,
|
||||||
|
isMajorBump: false,
|
||||||
|
requiresMigration: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentMajor = cleanCurrent.split(".")[0]
|
||||||
|
const newMajor = cleanNew.split(".")[0]
|
||||||
|
const isMajorBump = currentMajor !== newMajor
|
||||||
|
|
||||||
|
if (isMajorBump) {
|
||||||
|
return {
|
||||||
|
canUpgrade: true,
|
||||||
|
reason: `Major version upgrade from ${currentVersion} to ${newVersion} - configuration migration may be required`,
|
||||||
|
isDowngrade: false,
|
||||||
|
isMajorBump: true,
|
||||||
|
requiresMigration: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
canUpgrade: true,
|
||||||
|
isDowngrade: false,
|
||||||
|
isMajorBump: false,
|
||||||
|
requiresMigration: false,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
canUpgrade: true,
|
||||||
|
reason: `Unable to compare versions ${currentVersion} and ${newVersion} - proceeding with caution`,
|
||||||
|
isDowngrade: false,
|
||||||
|
isMajorBump: false,
|
||||||
|
requiresMigration: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractVersionFromPluginEntry(entry: string): string | null {
|
||||||
|
const match = entry.match(/@(.+)$/)
|
||||||
|
return match ? match[1] : null
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ const installConfig: InstallConfig = {
|
|||||||
hasOpencodeZen: false,
|
hasOpencodeZen: false,
|
||||||
hasZaiCodingPlan: false,
|
hasZaiCodingPlan: false,
|
||||||
hasKimiForCoding: false,
|
hasKimiForCoding: false,
|
||||||
|
hasOpencodeGo: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRecord(value: unknown): Record<string, unknown> {
|
function getRecord(value: unknown): Record<string, unknown> {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"
|
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"
|
||||||
import { parseJsonc } from "../../shared"
|
import { parseJsonc } from "../../shared"
|
||||||
import type { ConfigMergeResult, InstallConfig } from "../types"
|
import type { ConfigMergeResult, InstallConfig } from "../types"
|
||||||
|
import { backupConfigFile } from "./backup-config"
|
||||||
import { getConfigDir, getOmoConfigPath } from "./config-context"
|
import { getConfigDir, getOmoConfigPath } from "./config-context"
|
||||||
import { deepMergeRecord } from "./deep-merge-record"
|
import { deepMergeRecord } from "./deep-merge-record"
|
||||||
import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists"
|
import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists"
|
||||||
@@ -28,6 +29,15 @@ export function writeOmoConfig(installConfig: InstallConfig): ConfigMergeResult
|
|||||||
const newConfig = generateOmoConfig(installConfig)
|
const newConfig = generateOmoConfig(installConfig)
|
||||||
|
|
||||||
if (existsSync(omoConfigPath)) {
|
if (existsSync(omoConfigPath)) {
|
||||||
|
const backupResult = backupConfigFile(omoConfigPath)
|
||||||
|
if (!backupResult.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
configPath: omoConfigPath,
|
||||||
|
error: `Failed to create backup: ${backupResult.error}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stat = statSync(omoConfigPath)
|
const stat = statSync(omoConfigPath)
|
||||||
const content = readFileSync(omoConfigPath, "utf-8")
|
const content = readFileSync(omoConfigPath, "utf-8")
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export const EXIT_CODES = {
|
|||||||
FAILURE: 1,
|
FAILURE: 1,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export const MIN_OPENCODE_VERSION = "1.0.150"
|
export const MIN_OPENCODE_VERSION = "1.4.0"
|
||||||
|
|
||||||
export const PACKAGE_NAME = PLUGIN_NAME
|
export const PACKAGE_NAME = PLUGIN_NAME
|
||||||
|
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ describe("install CLI - binary check behavior", () => {
|
|||||||
test("non-TUI mode: should still succeed and complete all steps when binary exists", async () => {
|
test("non-TUI mode: should still succeed and complete all steps when binary exists", async () => {
|
||||||
// given OpenCode binary IS installed
|
// given OpenCode binary IS installed
|
||||||
isOpenCodeInstalledSpy = spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true)
|
isOpenCodeInstalledSpy = spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true)
|
||||||
getOpenCodeVersionSpy = spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.0.200")
|
getOpenCodeVersionSpy = spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.4.0")
|
||||||
|
|
||||||
// given mock npm fetch
|
// given mock npm fetch
|
||||||
globalThis.fetch = mock(() =>
|
globalThis.fetch = mock(() =>
|
||||||
@@ -157,6 +157,6 @@ describe("install CLI - binary check behavior", () => {
|
|||||||
// then should have printed success (OK symbol)
|
// then should have printed success (OK symbol)
|
||||||
const allCalls = mockConsoleLog.mock.calls.flat().join("\n")
|
const allCalls = mockConsoleLog.mock.calls.flat().join("\n")
|
||||||
expect(allCalls).toContain("[OK]")
|
expect(allCalls).toContain("[OK]")
|
||||||
expect(allCalls).toContain("OpenCode 1.0.200")
|
expect(allCalls).toContain("OpenCode 1.4.0")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { MIN_OPENCODE_VERSION } from "./doctor/constants"
|
||||||
|
import { compareVersions } from "../shared/opencode-version"
|
||||||
|
|
||||||
|
export function getUnsupportedOpenCodeVersionMessage(openCodeVersion: string | null): string | null {
|
||||||
|
if (!openCodeVersion) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compareVersions(openCodeVersion, MIN_OPENCODE_VERSION) >= 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Detected OpenCode ${openCodeVersion}, but ${MIN_OPENCODE_VERSION}+ is required. Update OpenCode, then rerun the installer.`
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"
|
||||||
|
import * as p from "@clack/prompts"
|
||||||
|
import * as configManager from "./config-manager"
|
||||||
|
import * as tuiInstallPrompts from "./tui-install-prompts"
|
||||||
|
import { runTuiInstaller } from "./tui-installer"
|
||||||
|
|
||||||
|
function createMockSpinner(): ReturnType<typeof p.spinner> {
|
||||||
|
return {
|
||||||
|
start: () => undefined,
|
||||||
|
stop: () => undefined,
|
||||||
|
message: () => undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("runTuiInstaller", () => {
|
||||||
|
const originalIsStdinTty = process.stdin.isTTY
|
||||||
|
const originalIsStdoutTty = process.stdout.isTTY
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true })
|
||||||
|
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: originalIsStdinTty })
|
||||||
|
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: originalIsStdoutTty })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("blocks installation when OpenCode is below the minimum version", async () => {
|
||||||
|
// given
|
||||||
|
const restoreSpies = [
|
||||||
|
spyOn(p, "spinner").mockReturnValue(createMockSpinner()),
|
||||||
|
spyOn(p, "intro").mockImplementation(() => undefined),
|
||||||
|
spyOn(p.log, "warn").mockImplementation(() => undefined),
|
||||||
|
spyOn(configManager, "detectCurrentConfig").mockReturnValue({
|
||||||
|
isInstalled: false,
|
||||||
|
installedVersion: null,
|
||||||
|
hasClaude: false,
|
||||||
|
isMax20: false,
|
||||||
|
hasOpenAI: false,
|
||||||
|
hasGemini: false,
|
||||||
|
hasCopilot: false,
|
||||||
|
hasOpencodeZen: false,
|
||||||
|
hasZaiCodingPlan: false,
|
||||||
|
hasKimiForCoding: false,
|
||||||
|
hasOpencodeGo: false,
|
||||||
|
}),
|
||||||
|
spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true),
|
||||||
|
spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.3.9"),
|
||||||
|
]
|
||||||
|
const promptSpy = spyOn(tuiInstallPrompts, "promptInstallConfig")
|
||||||
|
const addPluginSpy = spyOn(configManager, "addPluginToOpenCodeConfig")
|
||||||
|
const outroSpy = spyOn(p, "outro").mockImplementation(() => undefined)
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await runTuiInstaller({ tui: true }, "3.16.0")
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toBe(1)
|
||||||
|
expect(promptSpy).not.toHaveBeenCalled()
|
||||||
|
expect(addPluginSpy).not.toHaveBeenCalled()
|
||||||
|
expect(outroSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
for (const spy of restoreSpies) {
|
||||||
|
spy.mockRestore()
|
||||||
|
}
|
||||||
|
promptSpy.mockRestore()
|
||||||
|
addPluginSpy.mockRestore()
|
||||||
|
outroSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("proceeds when OpenCode meets the minimum version", async () => {
|
||||||
|
// given
|
||||||
|
const restoreSpies = [
|
||||||
|
spyOn(p, "spinner").mockReturnValue(createMockSpinner()),
|
||||||
|
spyOn(p, "intro").mockImplementation(() => undefined),
|
||||||
|
spyOn(p.log, "info").mockImplementation(() => undefined),
|
||||||
|
spyOn(p.log, "warn").mockImplementation(() => undefined),
|
||||||
|
spyOn(p.log, "success").mockImplementation(() => undefined),
|
||||||
|
spyOn(p.log, "message").mockImplementation(() => undefined),
|
||||||
|
spyOn(p, "note").mockImplementation(() => undefined),
|
||||||
|
spyOn(p, "outro").mockImplementation(() => undefined),
|
||||||
|
spyOn(configManager, "detectCurrentConfig").mockReturnValue({
|
||||||
|
isInstalled: false,
|
||||||
|
installedVersion: null,
|
||||||
|
hasClaude: false,
|
||||||
|
isMax20: false,
|
||||||
|
hasOpenAI: false,
|
||||||
|
hasGemini: false,
|
||||||
|
hasCopilot: false,
|
||||||
|
hasOpencodeZen: false,
|
||||||
|
hasZaiCodingPlan: false,
|
||||||
|
hasKimiForCoding: false,
|
||||||
|
hasOpencodeGo: false,
|
||||||
|
}),
|
||||||
|
spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true),
|
||||||
|
spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.4.0"),
|
||||||
|
spyOn(tuiInstallPrompts, "promptInstallConfig").mockResolvedValue({
|
||||||
|
hasClaude: false,
|
||||||
|
isMax20: false,
|
||||||
|
hasOpenAI: false,
|
||||||
|
hasGemini: false,
|
||||||
|
hasCopilot: false,
|
||||||
|
hasOpencodeZen: false,
|
||||||
|
hasZaiCodingPlan: false,
|
||||||
|
hasKimiForCoding: false,
|
||||||
|
hasOpencodeGo: false,
|
||||||
|
}),
|
||||||
|
spyOn(configManager, "addPluginToOpenCodeConfig").mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
configPath: "/tmp/opencode.jsonc",
|
||||||
|
}),
|
||||||
|
spyOn(configManager, "writeOmoConfig").mockReturnValue({
|
||||||
|
success: true,
|
||||||
|
configPath: "/tmp/oh-my-opencode.jsonc",
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await runTuiInstaller({ tui: true }, "3.16.0")
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toBe(0)
|
||||||
|
|
||||||
|
for (const spy of restoreSpies) {
|
||||||
|
spy.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
writeOmoConfig,
|
writeOmoConfig,
|
||||||
} from "./config-manager"
|
} from "./config-manager"
|
||||||
import { detectedToInitialValues, formatConfigSummary, SYMBOLS } from "./install-validators"
|
import { detectedToInitialValues, formatConfigSummary, SYMBOLS } from "./install-validators"
|
||||||
|
import { getUnsupportedOpenCodeVersionMessage } from "./minimum-opencode-version"
|
||||||
import { promptInstallConfig } from "./tui-install-prompts"
|
import { promptInstallConfig } from "./tui-install-prompts"
|
||||||
|
|
||||||
export async function runTuiInstaller(args: InstallArgs, version: string): Promise<number> {
|
export async function runTuiInstaller(args: InstallArgs, version: string): Promise<number> {
|
||||||
@@ -39,6 +40,13 @@ export async function runTuiInstaller(args: InstallArgs, version: string): Promi
|
|||||||
p.note("Visit https://opencode.ai/docs for installation instructions", "Installation Guide")
|
p.note("Visit https://opencode.ai/docs for installation instructions", "Installation Guide")
|
||||||
} else {
|
} else {
|
||||||
spinner.stop(`OpenCode ${openCodeVersion ?? "installed"} ${color.green("[OK]")}`)
|
spinner.stop(`OpenCode ${openCodeVersion ?? "installed"} ${color.green("[OK]")}`)
|
||||||
|
|
||||||
|
const unsupportedVersionMessage = getUnsupportedOpenCodeVersionMessage(openCodeVersion)
|
||||||
|
if (unsupportedVersionMessage) {
|
||||||
|
p.log.warn(unsupportedVersionMessage)
|
||||||
|
p.outro(color.red("Installation blocked."))
|
||||||
|
return 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await promptInstallConfig(detected)
|
const config = await promptInstallConfig(detected)
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export interface ConfigMergeResult {
|
|||||||
|
|
||||||
export interface DetectedConfig {
|
export interface DetectedConfig {
|
||||||
isInstalled: boolean
|
isInstalled: boolean
|
||||||
|
installedVersion: string | null
|
||||||
hasClaude: boolean
|
hasClaude: boolean
|
||||||
isMax20: boolean
|
isMax20: boolean
|
||||||
hasOpenAI: boolean
|
hasOpenAI: boolean
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
||||||
import { mkdtempSync, writeFileSync, rmSync } from "node:fs"
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
import {
|
import {
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
clearCompactionAgentConfigCheckpoint,
|
clearCompactionAgentConfigCheckpoint,
|
||||||
setCompactionAgentConfigCheckpoint,
|
setCompactionAgentConfigCheckpoint,
|
||||||
} from "../../shared/compaction-agent-config-checkpoint"
|
} from "../../shared/compaction-agent-config-checkpoint"
|
||||||
|
import { PART_STORAGE } from "../../shared"
|
||||||
|
|
||||||
describe("isCompactionAgent", () => {
|
describe("isCompactionAgent", () => {
|
||||||
describe("#given agent name variations", () => {
|
describe("#given agent name variations", () => {
|
||||||
@@ -73,6 +74,7 @@ describe("findNearestMessageExcludingCompaction", () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
rmSync(tempDir, { force: true, recursive: true })
|
rmSync(tempDir, { force: true, recursive: true })
|
||||||
|
rmSync(join(PART_STORAGE, "msg_test_background_compaction_marker"), { force: true, recursive: true })
|
||||||
clearCompactionAgentConfigCheckpoint("ses_checkpoint")
|
clearCompactionAgentConfigCheckpoint("ses_checkpoint")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -116,6 +118,30 @@ describe("findNearestMessageExcludingCompaction", () => {
|
|||||||
expect(result?.agent).toBe("sisyphus")
|
expect(result?.agent).toBe("sisyphus")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("skips JSON messages whose part storage contains a compaction marker", () => {
|
||||||
|
// given
|
||||||
|
const compactionMessageID = "msg_test_background_compaction_marker"
|
||||||
|
const partDir = join(PART_STORAGE, compactionMessageID)
|
||||||
|
writeFileSync(join(tempDir, "002.json"), JSON.stringify({
|
||||||
|
id: compactionMessageID,
|
||||||
|
agent: "atlas",
|
||||||
|
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||||
|
}))
|
||||||
|
writeFileSync(join(tempDir, "001.json"), JSON.stringify({
|
||||||
|
id: "msg_001",
|
||||||
|
agent: "sisyphus",
|
||||||
|
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||||
|
}))
|
||||||
|
mkdirSync(partDir, { recursive: true })
|
||||||
|
writeFileSync(join(partDir, "prt_0001.json"), JSON.stringify({ type: "compaction" }))
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = findNearestMessageExcludingCompaction(tempDir)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result?.agent).toBe("sisyphus")
|
||||||
|
})
|
||||||
|
|
||||||
test("falls back to partial agent/model match", () => {
|
test("falls back to partial agent/model match", () => {
|
||||||
// given
|
// given
|
||||||
const messageWithAgentOnly = {
|
const messageWithAgentOnly = {
|
||||||
@@ -256,4 +282,28 @@ describe("resolvePromptContextFromSessionMessages", () => {
|
|||||||
tools: { bash: true },
|
tools: { bash: true },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("skips SDK messages that only exist to mark compaction", () => {
|
||||||
|
// given
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
id: "msg_compaction",
|
||||||
|
info: { agent: "atlas", model: { providerID: "openai", modelID: "gpt-5" } },
|
||||||
|
parts: [{ type: "compaction" }],
|
||||||
|
},
|
||||||
|
{ info: { agent: "sisyphus" } },
|
||||||
|
{ info: { model: { providerID: "anthropic", modelID: "claude-opus-4-1" } } },
|
||||||
|
{ info: { tools: { bash: true } } },
|
||||||
|
]
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = resolvePromptContextFromSessionMessages(messages)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toEqual({
|
||||||
|
agent: "sisyphus",
|
||||||
|
model: { providerID: "anthropic", modelID: "claude-opus-4-1" },
|
||||||
|
tools: { bash: true },
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,8 +2,16 @@ import { readdirSync, readFileSync } from "node:fs"
|
|||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import type { StoredMessage } from "../hook-message-injector"
|
import type { StoredMessage } from "../hook-message-injector"
|
||||||
import { getCompactionAgentConfigCheckpoint } from "../../shared/compaction-agent-config-checkpoint"
|
import { getCompactionAgentConfigCheckpoint } from "../../shared/compaction-agent-config-checkpoint"
|
||||||
|
import {
|
||||||
|
hasCompactionPartInStorage,
|
||||||
|
isCompactionAgent,
|
||||||
|
isCompactionMessage,
|
||||||
|
} from "../../shared/compaction-marker"
|
||||||
|
|
||||||
|
export { isCompactionAgent } from "../../shared/compaction-marker"
|
||||||
|
|
||||||
type SessionMessage = {
|
type SessionMessage = {
|
||||||
|
id?: string
|
||||||
info?: {
|
info?: {
|
||||||
agent?: string
|
agent?: string
|
||||||
model?: {
|
model?: {
|
||||||
@@ -15,10 +23,7 @@ type SessionMessage = {
|
|||||||
modelID?: string
|
modelID?: string
|
||||||
tools?: StoredMessage["tools"]
|
tools?: StoredMessage["tools"]
|
||||||
}
|
}
|
||||||
}
|
parts?: Array<{ type?: string }>
|
||||||
|
|
||||||
export function isCompactionAgent(agent: string | undefined): boolean {
|
|
||||||
return agent?.trim().toLowerCase() === "compaction"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasFullAgentAndModel(message: StoredMessage): boolean {
|
function hasFullAgentAndModel(message: StoredMessage): boolean {
|
||||||
@@ -35,6 +40,10 @@ function hasPartialAgentOrModel(message: StoredMessage): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function convertSessionMessageToStoredMessage(message: SessionMessage): StoredMessage | null {
|
function convertSessionMessageToStoredMessage(message: SessionMessage): StoredMessage | null {
|
||||||
|
if (isCompactionMessage(message)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const info = message.info
|
const info = message.info
|
||||||
if (!info) {
|
if (!info) {
|
||||||
return null
|
return null
|
||||||
@@ -138,7 +147,11 @@ export function findNearestMessageExcludingCompaction(
|
|||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
try {
|
try {
|
||||||
const content = readFileSync(join(messageDir, file), "utf-8")
|
const content = readFileSync(join(messageDir, file), "utf-8")
|
||||||
messages.push(JSON.parse(content) as StoredMessage)
|
const parsed = JSON.parse(content) as StoredMessage & { id?: string }
|
||||||
|
if (hasCompactionPartInStorage(parsed.id) || isCompactionAgent(parsed.agent)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
messages.push(parsed)
|
||||||
} catch {
|
} catch {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -218,6 +218,10 @@ function getRootDescendantCounts(manager: BackgroundManager): Map<string, number
|
|||||||
return (manager as unknown as { rootDescendantCounts: Map<string, number> }).rootDescendantCounts
|
return (manager as unknown as { rootDescendantCounts: Map<string, number> }).rootDescendantCounts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPreStartDescendantReservations(manager: BackgroundManager): Set<string> {
|
||||||
|
return (manager as unknown as { preStartDescendantReservations: Set<string> }).preStartDescendantReservations
|
||||||
|
}
|
||||||
|
|
||||||
function getQueuesByKey(
|
function getQueuesByKey(
|
||||||
manager: BackgroundManager
|
manager: BackgroundManager
|
||||||
): Map<string, Array<{ task: BackgroundTask; input: import("./types").LaunchInput }>> {
|
): Map<string, Array<{ task: BackgroundTask; input: import("./types").LaunchInput }>> {
|
||||||
@@ -1144,7 +1148,18 @@ describe("BackgroundManager.notifyParentSession - notifications toggle", () => {
|
|||||||
prompt: promptMock,
|
prompt: promptMock,
|
||||||
promptAsync: promptMock,
|
promptAsync: promptMock,
|
||||||
abort: async () => ({}),
|
abort: async () => ({}),
|
||||||
messages: async () => ({ data: [] }),
|
messages: async () => ({
|
||||||
|
data: [{
|
||||||
|
info: {
|
||||||
|
agent: "explore",
|
||||||
|
model: {
|
||||||
|
providerID: "anthropic",
|
||||||
|
modelID: "claude-opus-4-6",
|
||||||
|
variant: "high",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
const manager = new BackgroundManager(
|
const manager = new BackgroundManager(
|
||||||
@@ -1177,6 +1192,101 @@ describe("BackgroundManager.notifyParentSession - notifications toggle", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("BackgroundManager.notifyParentSession - variant propagation", () => {
|
||||||
|
test("should prefer parent session variant over child task variant in parent notification promptAsync body", async () => {
|
||||||
|
//#given
|
||||||
|
const promptCalls: Array<{ body: Record<string, unknown> }> = []
|
||||||
|
const client = {
|
||||||
|
session: {
|
||||||
|
prompt: async () => ({}),
|
||||||
|
promptAsync: async (args: { path: { id: string }; body: Record<string, unknown> }) => {
|
||||||
|
promptCalls.push({ body: args.body })
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
abort: async () => ({}),
|
||||||
|
messages: async () => ({
|
||||||
|
data: [{
|
||||||
|
info: {
|
||||||
|
agent: "explore",
|
||||||
|
model: {
|
||||||
|
providerID: "anthropic",
|
||||||
|
modelID: "claude-opus-4-6",
|
||||||
|
variant: "max",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
|
||||||
|
const task: BackgroundTask = {
|
||||||
|
id: "task-parent-variant-wins",
|
||||||
|
sessionID: "session-child",
|
||||||
|
parentSessionID: "session-parent",
|
||||||
|
parentMessageID: "msg-parent",
|
||||||
|
description: "task with mismatched variant",
|
||||||
|
prompt: "test",
|
||||||
|
agent: "explore",
|
||||||
|
status: "completed",
|
||||||
|
startedAt: new Date(),
|
||||||
|
completedAt: new Date(),
|
||||||
|
model: { providerID: "anthropic", modelID: "claude-opus-4-6", variant: "high" },
|
||||||
|
}
|
||||||
|
getPendingByParent(manager).set("session-parent", new Set([task.id]))
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await (manager as unknown as { notifyParentSession: (task: BackgroundTask) => Promise<void> })
|
||||||
|
.notifyParentSession(task)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(promptCalls).toHaveLength(1)
|
||||||
|
expect(promptCalls[0].body.variant).toBe("max")
|
||||||
|
|
||||||
|
manager.shutdown()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should not include variant in promptAsync body when task has no variant", async () => {
|
||||||
|
//#given
|
||||||
|
const promptCalls: Array<{ body: Record<string, unknown> }> = []
|
||||||
|
const client = {
|
||||||
|
session: {
|
||||||
|
prompt: async () => ({}),
|
||||||
|
promptAsync: async (args: { path: { id: string }; body: Record<string, unknown> }) => {
|
||||||
|
promptCalls.push({ body: args.body })
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
abort: async () => ({}),
|
||||||
|
messages: async () => ({ data: [] }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
|
||||||
|
const task: BackgroundTask = {
|
||||||
|
id: "task-no-variant",
|
||||||
|
sessionID: "session-child",
|
||||||
|
parentSessionID: "session-parent",
|
||||||
|
parentMessageID: "msg-parent",
|
||||||
|
description: "task without variant",
|
||||||
|
prompt: "test",
|
||||||
|
agent: "explore",
|
||||||
|
status: "completed",
|
||||||
|
startedAt: new Date(),
|
||||||
|
completedAt: new Date(),
|
||||||
|
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||||
|
}
|
||||||
|
getPendingByParent(manager).set("session-parent", new Set([task.id]))
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await (manager as unknown as { notifyParentSession: (task: BackgroundTask) => Promise<void> })
|
||||||
|
.notifyParentSession(task)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(promptCalls).toHaveLength(1)
|
||||||
|
expect(promptCalls[0].body.variant).toBeUndefined()
|
||||||
|
|
||||||
|
manager.shutdown()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("BackgroundManager.injectPendingNotificationsIntoChatMessage", () => {
|
describe("BackgroundManager.injectPendingNotificationsIntoChatMessage", () => {
|
||||||
test("should prepend queued notifications to first text part and clear queue", () => {
|
test("should prepend queued notifications to first text part and clear queue", () => {
|
||||||
// given
|
// given
|
||||||
@@ -1437,6 +1547,7 @@ describe("BackgroundManager.tryCompleteTask", () => {
|
|||||||
|
|
||||||
const task = createMockTask({
|
const task = createMockTask({
|
||||||
id: "task-zombie-session",
|
id: "task-zombie-session",
|
||||||
|
sessionID: "session-zombie-placeholder",
|
||||||
parentSessionID: "parent-zombie",
|
parentSessionID: "parent-zombie",
|
||||||
status: "pending",
|
status: "pending",
|
||||||
agent: "explore",
|
agent: "explore",
|
||||||
@@ -1779,10 +1890,10 @@ describe("BackgroundManager.resume model persistence", () => {
|
|||||||
expect(getSessionPromptParams("session-advanced")).toEqual({
|
expect(getSessionPromptParams("session-advanced")).toEqual({
|
||||||
temperature: 0.25,
|
temperature: 0.25,
|
||||||
topP: 0.55,
|
topP: 0.55,
|
||||||
|
maxOutputTokens: 8192,
|
||||||
options: {
|
options: {
|
||||||
reasoningEffort: "high",
|
reasoningEffort: "high",
|
||||||
thinking: { type: "disabled" },
|
thinking: { type: "disabled" },
|
||||||
maxTokens: 8192,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -2379,6 +2490,46 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
|||||||
expect(retryTask.status).toBe("pending")
|
expect(retryTask.status).toBe("pending")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("should only roll back the failed task reservation once when siblings still exist", async () => {
|
||||||
|
// given
|
||||||
|
const concurrencyKey = "test-agent"
|
||||||
|
const task = createMockTask({
|
||||||
|
id: "task-single-reservation-rollback",
|
||||||
|
sessionID: "session-single-reservation-rollback",
|
||||||
|
parentSessionID: "session-root",
|
||||||
|
status: "pending",
|
||||||
|
agent: "test-agent",
|
||||||
|
rootSessionID: "session-root",
|
||||||
|
})
|
||||||
|
delete (task as Partial<BackgroundTask>).sessionID
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
description: task.description,
|
||||||
|
prompt: task.prompt,
|
||||||
|
agent: task.agent,
|
||||||
|
parentSessionID: task.parentSessionID,
|
||||||
|
parentMessageID: task.parentMessageID,
|
||||||
|
}
|
||||||
|
|
||||||
|
getTaskMap(manager).set(task.id, task)
|
||||||
|
getQueuesByKey(manager).set(concurrencyKey, [{ task, input }])
|
||||||
|
getRootDescendantCounts(manager).set("session-root", 2)
|
||||||
|
getPreStartDescendantReservations(manager).add(task.id)
|
||||||
|
stubNotifyParentSession(manager)
|
||||||
|
|
||||||
|
;(manager as unknown as {
|
||||||
|
startTask: (item: { task: BackgroundTask; input: typeof input }) => Promise<void>
|
||||||
|
}).startTask = async () => {
|
||||||
|
throw new Error("session create failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
await processKeyForTest(manager, concurrencyKey)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(getRootDescendantCounts(manager).get("session-root")).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
test("should keep the next queued task when the first task is cancelled during session creation", async () => {
|
test("should keep the next queued task when the first task is cancelled during session creation", async () => {
|
||||||
// given
|
// given
|
||||||
const firstSessionID = "ses-first-cancelled-during-create"
|
const firstSessionID = "ses-first-cancelled-during-create"
|
||||||
|
|||||||
@@ -422,10 +422,6 @@ export class BackgroundManager {
|
|||||||
this.concurrencyManager.release(key)
|
this.concurrencyManager.release(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.task.rootSessionID) {
|
|
||||||
this.unregisterRootDescendant(item.task.rootSessionID)
|
|
||||||
}
|
|
||||||
|
|
||||||
removeTaskToastTracking(item.task.id)
|
removeTaskToastTracking(item.task.id)
|
||||||
|
|
||||||
// Abort the orphaned session if one was created before the error
|
// Abort the orphaned session if one was created before the error
|
||||||
@@ -1783,6 +1779,7 @@ export class BackgroundManager {
|
|||||||
let agent: string | undefined = task.parentAgent
|
let agent: string | undefined = task.parentAgent
|
||||||
let model: { providerID: string; modelID: string } | undefined
|
let model: { providerID: string; modelID: string } | undefined
|
||||||
let tools: Record<string, boolean> | undefined = task.parentTools
|
let tools: Record<string, boolean> | undefined = task.parentTools
|
||||||
|
let promptContext: ReturnType<typeof resolvePromptContextFromSessionMessages> = null
|
||||||
|
|
||||||
if (this.enableParentSessionNotifications) {
|
if (this.enableParentSessionNotifications) {
|
||||||
try {
|
try {
|
||||||
@@ -1796,7 +1793,7 @@ export class BackgroundManager {
|
|||||||
tools?: Record<string, boolean | "allow" | "deny" | "ask">
|
tools?: Record<string, boolean | "allow" | "deny" | "ask">
|
||||||
}
|
}
|
||||||
}>)
|
}>)
|
||||||
const promptContext = resolvePromptContextFromSessionMessages(
|
promptContext = resolvePromptContextFromSessionMessages(
|
||||||
messages,
|
messages,
|
||||||
task.parentSessionID,
|
task.parentSessionID,
|
||||||
)
|
)
|
||||||
@@ -1840,6 +1837,8 @@ export class BackgroundManager {
|
|||||||
const isTaskFailure = task.status === "error" || task.status === "cancelled" || task.status === "interrupt"
|
const isTaskFailure = task.status === "error" || task.status === "cancelled" || task.status === "interrupt"
|
||||||
const shouldReply = allComplete || isTaskFailure
|
const shouldReply = allComplete || isTaskFailure
|
||||||
|
|
||||||
|
const variant = promptContext?.model?.variant
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.client.session.promptAsync({
|
await this.client.session.promptAsync({
|
||||||
path: { id: task.parentSessionID },
|
path: { id: task.parentSessionID },
|
||||||
@@ -1847,6 +1846,7 @@ export class BackgroundManager {
|
|||||||
noReply: !shouldReply,
|
noReply: !shouldReply,
|
||||||
...(agent !== undefined ? { agent } : {}),
|
...(agent !== undefined ? { agent } : {}),
|
||||||
...(model !== undefined ? { model } : {}),
|
...(model !== undefined ? { model } : {}),
|
||||||
|
...(variant !== undefined ? { variant } : {}),
|
||||||
...(resolvedTools ? { tools: resolvedTools } : {}),
|
...(resolvedTools ? { tools: resolvedTools } : {}),
|
||||||
parts: [createInternalAgentTextPart(notification)],
|
parts: [createInternalAgentTextPart(notification)],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -400,10 +400,10 @@ describe("background-agent spawner fallback model promotion", () => {
|
|||||||
expect(getSessionPromptParams("session-123")).toEqual({
|
expect(getSessionPromptParams("session-123")).toEqual({
|
||||||
temperature: 0.4,
|
temperature: 0.4,
|
||||||
topP: 0.7,
|
topP: 0.7,
|
||||||
|
maxOutputTokens: 4096,
|
||||||
options: {
|
options: {
|
||||||
reasoningEffort: "high",
|
reasoningEffort: "high",
|
||||||
thinking: { type: "disabled" },
|
thinking: { type: "disabled" },
|
||||||
maxTokens: 4096,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -466,4 +466,58 @@ describe("background-agent spawner fallback model promotion", () => {
|
|||||||
})
|
})
|
||||||
expect(promptCalls[0]?.body?.variant).toBe("medium")
|
expect(promptCalls[0]?.body?.variant).toBe("medium")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("strips leading zwsp from prompt body agent before promptAsync", async () => {
|
||||||
|
//#given
|
||||||
|
const promptCalls: Array<{ body?: { agent?: string } }> = []
|
||||||
|
|
||||||
|
const client = {
|
||||||
|
session: {
|
||||||
|
get: async () => ({ data: { directory: "/parent/dir" } }),
|
||||||
|
create: async () => ({ data: { id: "ses_child_clean_agent" } }),
|
||||||
|
promptAsync: async (args?: { body?: { agent?: string } }) => {
|
||||||
|
promptCalls.push(args ?? {})
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = createTask({
|
||||||
|
description: "Test task",
|
||||||
|
prompt: "Do work",
|
||||||
|
agent: "\u200Bsisyphus-junior",
|
||||||
|
parentSessionID: "ses_parent",
|
||||||
|
parentMessageID: "msg_parent",
|
||||||
|
})
|
||||||
|
|
||||||
|
const item = {
|
||||||
|
task,
|
||||||
|
input: {
|
||||||
|
description: task.description,
|
||||||
|
prompt: task.prompt,
|
||||||
|
agent: task.agent,
|
||||||
|
parentSessionID: task.parentSessionID,
|
||||||
|
parentMessageID: task.parentMessageID,
|
||||||
|
parentModel: task.parentModel,
|
||||||
|
parentAgent: task.parentAgent,
|
||||||
|
model: task.model,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
client,
|
||||||
|
directory: "/fallback",
|
||||||
|
concurrencyManager: { release: () => {} },
|
||||||
|
tmuxEnabled: false,
|
||||||
|
onTaskError: () => {},
|
||||||
|
}
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await startTask(item as any, ctx as any)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(promptCalls).toHaveLength(1)
|
||||||
|
expect(promptCalls[0]?.body?.agent).toBe("sisyphus-junior")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { applySessionPromptParams } from "../../shared/session-prompt-params-hel
|
|||||||
import { subagentSessions } from "../claude-code-session-state"
|
import { subagentSessions } from "../claude-code-session-state"
|
||||||
import { getTaskToastManager } from "../task-toast-manager"
|
import { getTaskToastManager } from "../task-toast-manager"
|
||||||
import { isInsideTmux } from "../../shared/tmux"
|
import { isInsideTmux } from "../../shared/tmux"
|
||||||
|
import { stripAgentListSortPrefix } from "../../shared/agent-display-names"
|
||||||
import type { ConcurrencyManager } from "./concurrency"
|
import type { ConcurrencyManager } from "./concurrency"
|
||||||
|
|
||||||
export const FALLBACK_AGENT = "general"
|
export const FALLBACK_AGENT = "general"
|
||||||
@@ -168,11 +169,12 @@ export async function startTask(
|
|||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
const launchVariant = input.model?.variant
|
const launchVariant = input.model?.variant
|
||||||
|
const normalizedAgent = stripAgentListSortPrefix(input.agent)
|
||||||
|
|
||||||
applySessionPromptParams(sessionID, input.model)
|
applySessionPromptParams(sessionID, input.model)
|
||||||
|
|
||||||
const promptBody = {
|
const promptBody = {
|
||||||
agent: input.agent,
|
agent: normalizedAgent,
|
||||||
...(launchModel ? { model: launchModel } : {}),
|
...(launchModel ? { model: launchModel } : {}),
|
||||||
...(launchVariant ? { variant: launchVariant } : {}),
|
...(launchVariant ? { variant: launchVariant } : {}),
|
||||||
system: input.skillContent,
|
system: input.skillContent,
|
||||||
@@ -180,7 +182,7 @@ export async function startTask(
|
|||||||
task: false,
|
task: false,
|
||||||
call_omo_agent: true,
|
call_omo_agent: true,
|
||||||
question: false,
|
question: false,
|
||||||
...getAgentToolRestrictions(input.agent),
|
...getAgentToolRestrictions(normalizedAgent),
|
||||||
},
|
},
|
||||||
parts: [createInternalAgentTextPart(input.prompt)],
|
parts: [createInternalAgentTextPart(input.prompt)],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -650,6 +650,65 @@ describe("boulder-state", () => {
|
|||||||
expect(progress.completed).toBe(1)
|
expect(progress.completed).toBe(1)
|
||||||
expect(progress.isComplete).toBe(false)
|
expect(progress.isComplete).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("should count only top-level checkboxes for simple plans with nested tasks", () => {
|
||||||
|
// given
|
||||||
|
const planPath = join(TEST_DIR, "simple-nested-plan.md")
|
||||||
|
writeFileSync(planPath, `# Plan
|
||||||
|
|
||||||
|
- [ ] Top-level task 1
|
||||||
|
- [x] Nested task ignored
|
||||||
|
- [x] Top-level task 2
|
||||||
|
* [ ] Another nested task ignored
|
||||||
|
`)
|
||||||
|
|
||||||
|
// when
|
||||||
|
const progress = getPlanProgress(planPath)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(progress.total).toBe(2)
|
||||||
|
expect(progress.completed).toBe(1)
|
||||||
|
expect(progress.isComplete).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should treat final-wave-only plans as structured mode", () => {
|
||||||
|
// given
|
||||||
|
const planPath = join(TEST_DIR, "final-wave-only-plan.md")
|
||||||
|
writeFileSync(planPath, `# Plan
|
||||||
|
|
||||||
|
## Final Verification Wave
|
||||||
|
- [ ] F1. Top-level final review
|
||||||
|
- [x] Nested verification detail ignored
|
||||||
|
`)
|
||||||
|
|
||||||
|
// when
|
||||||
|
const progress = getPlanProgress(planPath)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(progress.total).toBe(1)
|
||||||
|
expect(progress.completed).toBe(0)
|
||||||
|
expect(progress.isComplete).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should ignore mixed indentation levels in simple plans", () => {
|
||||||
|
// given
|
||||||
|
const planPath = join(TEST_DIR, "simple-mixed-indentation-plan.md")
|
||||||
|
writeFileSync(planPath, `# Plan
|
||||||
|
|
||||||
|
* [x] Top-level star task
|
||||||
|
- [ ] Indented task ignored
|
||||||
|
- [x] Tab-indented task ignored
|
||||||
|
- [ ] Top-level dash task
|
||||||
|
`)
|
||||||
|
|
||||||
|
// when
|
||||||
|
const progress = getPlanProgress(planPath)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(progress.total).toBe(2)
|
||||||
|
expect(progress.completed).toBe(1)
|
||||||
|
expect(progress.isComplete).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("getPlanName", () => {
|
describe("getPlanName", () => {
|
||||||
|
|||||||
@@ -226,7 +226,9 @@ export function getPlanProgress(planPath: string): PlanProgress {
|
|||||||
const lines = content.split(/\r?\n/)
|
const lines = content.split(/\r?\n/)
|
||||||
|
|
||||||
// Check if the plan has structured sections (## TODOs / ## Final Verification Wave)
|
// Check if the plan has structured sections (## TODOs / ## Final Verification Wave)
|
||||||
const hasStructuredSections = lines.some((line) => TODO_HEADING_PATTERN.test(line))
|
const hasStructuredSections = lines.some(
|
||||||
|
(line) => TODO_HEADING_PATTERN.test(line) || FINAL_VERIFICATION_HEADING_PATTERN.test(line),
|
||||||
|
)
|
||||||
|
|
||||||
if (hasStructuredSections) {
|
if (hasStructuredSections) {
|
||||||
// Structured plan: only count top-level checkboxes with numbered labels
|
// Structured plan: only count top-level checkboxes with numbered labels
|
||||||
@@ -291,8 +293,8 @@ function getStructuredPlanProgress(lines: string[]): PlanProgress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getSimplePlanProgress(content: string): PlanProgress {
|
function getSimplePlanProgress(content: string): PlanProgress {
|
||||||
const uncheckedMatches = content.match(/^\s*[-*]\s*\[\s*\]/gm) || []
|
const uncheckedMatches = content.match(/^[-*]\s*\[\s*\]/gm) || []
|
||||||
const checkedMatches = content.match(/^\s*[-*]\s*\[[xX]\]/gm) || []
|
const checkedMatches = content.match(/^[-*]\s*\[[xX]\]/gm) || []
|
||||||
|
|
||||||
const total = uncheckedMatches.length + checkedMatches.length
|
const total = uncheckedMatches.length + checkedMatches.length
|
||||||
const completed = checkedMatches.length
|
const completed = checkedMatches.length
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { ULW_LOOP_TEMPLATE } from "./ralph-loop"
|
||||||
|
|
||||||
|
describe("ULW_LOOP_TEMPLATE", () => {
|
||||||
|
test("returns the documented iteration caps for ultrawork and normal modes", () => {
|
||||||
|
// given
|
||||||
|
const expectedIterationCaps = "The iteration limit is 500 for ultrawork mode, 100 for normal mode"
|
||||||
|
|
||||||
|
// when
|
||||||
|
const template = ULW_LOOP_TEMPLATE
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(template).toContain(expectedIterationCaps)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -36,7 +36,7 @@ export const ULW_LOOP_TEMPLATE = `You are starting an ULTRAWORK Loop - a self-re
|
|||||||
2. When you believe the work is complete, output: \`<promise>{{COMPLETION_PROMISE}}</promise>\`
|
2. When you believe the work is complete, output: \`<promise>{{COMPLETION_PROMISE}}</promise>\`
|
||||||
3. That does NOT finish the loop yet. The system will require Oracle verification
|
3. That does NOT finish the loop yet. The system will require Oracle verification
|
||||||
4. The loop only ends after the system confirms Oracle verified the result
|
4. The loop only ends after the system confirms Oracle verified the result
|
||||||
5. There is no iteration limit
|
5. The iteration limit is 500 for ultrawork mode, 100 for normal mode
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { join } from "node:path"
|
|||||||
|
|
||||||
const originalClaudePluginsHome = process.env.CLAUDE_PLUGINS_HOME
|
const originalClaudePluginsHome = process.env.CLAUDE_PLUGINS_HOME
|
||||||
const temporaryDirectories: string[] = []
|
const temporaryDirectories: string[] = []
|
||||||
|
const originalCwd = process.cwd()
|
||||||
|
|
||||||
function createTemporaryDirectory(prefix: string): string {
|
function createTemporaryDirectory(prefix: string): string {
|
||||||
const directory = mkdtempSync(join(tmpdir(), prefix))
|
const directory = mkdtempSync(join(tmpdir(), prefix))
|
||||||
@@ -17,6 +18,14 @@ function createTemporaryDirectory(prefix: string): string {
|
|||||||
return directory
|
return directory
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function writeDatabase(pluginsHome: string, database: unknown): void {
|
||||||
|
writeFileSync(join(pluginsHome, "installed_plugins.json"), JSON.stringify(database), "utf-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInstallPath(prefix: string): string {
|
||||||
|
return createTemporaryDirectory(prefix)
|
||||||
|
}
|
||||||
|
|
||||||
describe("discoverInstalledPlugins", () => {
|
describe("discoverInstalledPlugins", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mock.module("../../shared/logger", () => ({
|
mock.module("../../shared/logger", () => ({
|
||||||
@@ -36,6 +45,10 @@ describe("discoverInstalledPlugins", () => {
|
|||||||
process.env.CLAUDE_PLUGINS_HOME = originalClaudePluginsHome
|
process.env.CLAUDE_PLUGINS_HOME = originalClaudePluginsHome
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (process.cwd() !== originalCwd) {
|
||||||
|
process.chdir(originalCwd)
|
||||||
|
}
|
||||||
|
|
||||||
for (const directory of temporaryDirectories.splice(0)) {
|
for (const directory of temporaryDirectories.splice(0)) {
|
||||||
rmSync(directory, { recursive: true, force: true })
|
rmSync(directory, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
@@ -156,4 +169,488 @@ describe("discoverInstalledPlugins", () => {
|
|||||||
expect(discovered.plugins).toHaveLength(1)
|
expect(discovered.plugins).toHaveLength(1)
|
||||||
expect(discovered.plugins[0]?.name).toBe("oh-my-openagent")
|
expect(discovered.plugins[0]?.name).toBe("oh-my-openagent")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("#given project-scoped entries in v1 format", () => {
|
||||||
|
it("#when cwd matches projectPath #then the plugin loads", async () => {
|
||||||
|
//#given
|
||||||
|
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||||
|
const projectDirectory = createTemporaryDirectory("omo-v1-project-match-")
|
||||||
|
const installPath = createInstallPath("omo-v1-install-")
|
||||||
|
writeDatabase(pluginsHome, {
|
||||||
|
version: 1,
|
||||||
|
plugins: {
|
||||||
|
"project-plugin@market": {
|
||||||
|
scope: "project",
|
||||||
|
projectPath: projectDirectory,
|
||||||
|
installPath,
|
||||||
|
version: "1.0.0",
|
||||||
|
installedAt: "2026-03-25T00:00:00Z",
|
||||||
|
lastUpdated: "2026-03-25T00:00:00Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
process.chdir(projectDirectory)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v1-match`)
|
||||||
|
const discovered = discoverInstalledPlugins({
|
||||||
|
pluginsHomeOverride: pluginsHome,
|
||||||
|
loadPluginManifestOverride: () => null,
|
||||||
|
})
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(discovered.errors).toHaveLength(0)
|
||||||
|
expect(discovered.plugins).toHaveLength(1)
|
||||||
|
expect(discovered.plugins[0]?.name).toBe("project-plugin")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("#when cwd is a subdirectory of projectPath #then the plugin loads", async () => {
|
||||||
|
//#given
|
||||||
|
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||||
|
const projectDirectory = createTemporaryDirectory("omo-v1-project-sub-")
|
||||||
|
const subdirectory = join(projectDirectory, "packages", "app")
|
||||||
|
mkdirSync(subdirectory, { recursive: true })
|
||||||
|
const installPath = createInstallPath("omo-v1-install-")
|
||||||
|
writeDatabase(pluginsHome, {
|
||||||
|
version: 1,
|
||||||
|
plugins: {
|
||||||
|
"sub-plugin@market": {
|
||||||
|
scope: "project",
|
||||||
|
projectPath: projectDirectory,
|
||||||
|
installPath,
|
||||||
|
version: "1.0.0",
|
||||||
|
installedAt: "2026-03-25T00:00:00Z",
|
||||||
|
lastUpdated: "2026-03-25T00:00:00Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
process.chdir(subdirectory)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v1-sub`)
|
||||||
|
const discovered = discoverInstalledPlugins({
|
||||||
|
pluginsHomeOverride: pluginsHome,
|
||||||
|
loadPluginManifestOverride: () => null,
|
||||||
|
})
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(discovered.errors).toHaveLength(0)
|
||||||
|
expect(discovered.plugins).toHaveLength(1)
|
||||||
|
expect(discovered.plugins[0]?.name).toBe("sub-plugin")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("#when cwd does not match projectPath #then the plugin is skipped", async () => {
|
||||||
|
//#given
|
||||||
|
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||||
|
const projectDirectory = createTemporaryDirectory("omo-v1-project-miss-")
|
||||||
|
const otherDirectory = createTemporaryDirectory("omo-v1-other-")
|
||||||
|
const installPath = createInstallPath("omo-v1-install-")
|
||||||
|
writeDatabase(pluginsHome, {
|
||||||
|
version: 1,
|
||||||
|
plugins: {
|
||||||
|
"outside-plugin@market": {
|
||||||
|
scope: "project",
|
||||||
|
projectPath: projectDirectory,
|
||||||
|
installPath,
|
||||||
|
version: "1.0.0",
|
||||||
|
installedAt: "2026-03-25T00:00:00Z",
|
||||||
|
lastUpdated: "2026-03-25T00:00:00Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
process.chdir(otherDirectory)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v1-miss`)
|
||||||
|
const discovered = discoverInstalledPlugins({
|
||||||
|
pluginsHomeOverride: pluginsHome,
|
||||||
|
loadPluginManifestOverride: () => null,
|
||||||
|
})
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(discovered.errors).toHaveLength(0)
|
||||||
|
expect(discovered.plugins).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("#when projectPath is missing #then the plugin is skipped", async () => {
|
||||||
|
//#given
|
||||||
|
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||||
|
const installPath = createInstallPath("omo-v1-install-")
|
||||||
|
writeDatabase(pluginsHome, {
|
||||||
|
version: 1,
|
||||||
|
plugins: {
|
||||||
|
"no-path-plugin@market": {
|
||||||
|
scope: "project",
|
||||||
|
installPath,
|
||||||
|
version: "1.0.0",
|
||||||
|
installedAt: "2026-03-25T00:00:00Z",
|
||||||
|
lastUpdated: "2026-03-25T00:00:00Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v1-noproj`)
|
||||||
|
const discovered = discoverInstalledPlugins({
|
||||||
|
pluginsHomeOverride: pluginsHome,
|
||||||
|
loadPluginManifestOverride: () => null,
|
||||||
|
})
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(discovered.errors).toHaveLength(0)
|
||||||
|
expect(discovered.plugins).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("#when scope is user #then it always loads regardless of cwd", async () => {
|
||||||
|
//#given
|
||||||
|
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||||
|
const unrelatedDirectory = createTemporaryDirectory("omo-v1-unrelated-")
|
||||||
|
const installPath = createInstallPath("omo-v1-install-")
|
||||||
|
writeDatabase(pluginsHome, {
|
||||||
|
version: 1,
|
||||||
|
plugins: {
|
||||||
|
"user-plugin@market": {
|
||||||
|
scope: "user",
|
||||||
|
installPath,
|
||||||
|
version: "1.0.0",
|
||||||
|
installedAt: "2026-03-25T00:00:00Z",
|
||||||
|
lastUpdated: "2026-03-25T00:00:00Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
process.chdir(unrelatedDirectory)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v1-user`)
|
||||||
|
const discovered = discoverInstalledPlugins({
|
||||||
|
pluginsHomeOverride: pluginsHome,
|
||||||
|
loadPluginManifestOverride: () => null,
|
||||||
|
})
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(discovered.errors).toHaveLength(0)
|
||||||
|
expect(discovered.plugins).toHaveLength(1)
|
||||||
|
expect(discovered.plugins[0]?.name).toBe("user-plugin")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("#given project and local scoped entries in v2 format", () => {
|
||||||
|
it("#when cwd matches project-scoped projectPath #then it loads while non-matching entries are dropped", async () => {
|
||||||
|
//#given
|
||||||
|
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||||
|
const projectDirectory = createTemporaryDirectory("omo-v2-project-")
|
||||||
|
const otherDirectory = createTemporaryDirectory("omo-v2-other-")
|
||||||
|
const matchingInstall = createInstallPath("omo-v2-match-install-")
|
||||||
|
const missingInstall = createInstallPath("omo-v2-miss-install-")
|
||||||
|
const userInstall = createInstallPath("omo-v2-user-install-")
|
||||||
|
writeDatabase(pluginsHome, {
|
||||||
|
version: 2,
|
||||||
|
plugins: {
|
||||||
|
"matching-project@market": [
|
||||||
|
{
|
||||||
|
scope: "project",
|
||||||
|
projectPath: projectDirectory,
|
||||||
|
installPath: matchingInstall,
|
||||||
|
version: "1.0.0",
|
||||||
|
installedAt: "2026-03-25T00:00:00Z",
|
||||||
|
lastUpdated: "2026-03-25T00:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"other-project@market": [
|
||||||
|
{
|
||||||
|
scope: "project",
|
||||||
|
projectPath: otherDirectory,
|
||||||
|
installPath: missingInstall,
|
||||||
|
version: "1.0.0",
|
||||||
|
installedAt: "2026-03-25T00:00:00Z",
|
||||||
|
lastUpdated: "2026-03-25T00:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"global-user@market": [
|
||||||
|
{
|
||||||
|
scope: "user",
|
||||||
|
installPath: userInstall,
|
||||||
|
version: "2.0.0",
|
||||||
|
installedAt: "2026-03-25T00:00:00Z",
|
||||||
|
lastUpdated: "2026-03-25T00:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
process.chdir(projectDirectory)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v2-mix`)
|
||||||
|
const discovered = discoverInstalledPlugins({
|
||||||
|
pluginsHomeOverride: pluginsHome,
|
||||||
|
loadPluginManifestOverride: () => null,
|
||||||
|
})
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(discovered.errors).toHaveLength(0)
|
||||||
|
const names = discovered.plugins.map((plugin) => plugin.name).sort()
|
||||||
|
expect(names).toEqual(["global-user", "matching-project"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("#when scope is local and cwd matches projectPath #then it loads", async () => {
|
||||||
|
//#given
|
||||||
|
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||||
|
const projectDirectory = createTemporaryDirectory("omo-v2-local-match-")
|
||||||
|
const installPath = createInstallPath("omo-v2-local-install-")
|
||||||
|
writeDatabase(pluginsHome, {
|
||||||
|
version: 2,
|
||||||
|
plugins: {
|
||||||
|
"local-plugin@market": [
|
||||||
|
{
|
||||||
|
scope: "local",
|
||||||
|
projectPath: projectDirectory,
|
||||||
|
installPath,
|
||||||
|
version: "1.0.0",
|
||||||
|
installedAt: "2026-03-25T00:00:00Z",
|
||||||
|
lastUpdated: "2026-03-25T00:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
process.chdir(projectDirectory)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v2-local-match`)
|
||||||
|
const discovered = discoverInstalledPlugins({
|
||||||
|
pluginsHomeOverride: pluginsHome,
|
||||||
|
loadPluginManifestOverride: () => null,
|
||||||
|
})
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(discovered.errors).toHaveLength(0)
|
||||||
|
expect(discovered.plugins).toHaveLength(1)
|
||||||
|
expect(discovered.plugins[0]?.name).toBe("local-plugin")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("#when scope is local and cwd does not match projectPath #then it is skipped", async () => {
|
||||||
|
//#given
|
||||||
|
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||||
|
const projectDirectory = createTemporaryDirectory("omo-v2-local-miss-")
|
||||||
|
const otherDirectory = createTemporaryDirectory("omo-v2-local-other-")
|
||||||
|
const installPath = createInstallPath("omo-v2-local-install-")
|
||||||
|
writeDatabase(pluginsHome, {
|
||||||
|
version: 2,
|
||||||
|
plugins: {
|
||||||
|
"local-plugin@market": [
|
||||||
|
{
|
||||||
|
scope: "local",
|
||||||
|
projectPath: projectDirectory,
|
||||||
|
installPath,
|
||||||
|
version: "1.0.0",
|
||||||
|
installedAt: "2026-03-25T00:00:00Z",
|
||||||
|
lastUpdated: "2026-03-25T00:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
process.chdir(otherDirectory)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v2-local-miss`)
|
||||||
|
const discovered = discoverInstalledPlugins({
|
||||||
|
pluginsHomeOverride: pluginsHome,
|
||||||
|
loadPluginManifestOverride: () => null,
|
||||||
|
})
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(discovered.errors).toHaveLength(0)
|
||||||
|
expect(discovered.plugins).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("#when multiple installations are present #then only the first is considered and scope filtering still applies", async () => {
|
||||||
|
//#given
|
||||||
|
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||||
|
const projectDirectory = createTemporaryDirectory("omo-v2-multi-")
|
||||||
|
const otherDirectory = createTemporaryDirectory("omo-v2-multi-other-")
|
||||||
|
const primaryInstall = createInstallPath("omo-v2-multi-primary-")
|
||||||
|
const secondaryInstall = createInstallPath("omo-v2-multi-secondary-")
|
||||||
|
writeDatabase(pluginsHome, {
|
||||||
|
version: 2,
|
||||||
|
plugins: {
|
||||||
|
"multi-plugin@market": [
|
||||||
|
{
|
||||||
|
scope: "project",
|
||||||
|
projectPath: otherDirectory,
|
||||||
|
installPath: primaryInstall,
|
||||||
|
version: "1.0.0",
|
||||||
|
installedAt: "2026-03-25T00:00:00Z",
|
||||||
|
lastUpdated: "2026-03-25T00:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scope: "project",
|
||||||
|
projectPath: projectDirectory,
|
||||||
|
installPath: secondaryInstall,
|
||||||
|
version: "2.0.0",
|
||||||
|
installedAt: "2026-03-25T00:00:00Z",
|
||||||
|
lastUpdated: "2026-03-25T00:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
process.chdir(projectDirectory)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v2-multi`)
|
||||||
|
const discovered = discoverInstalledPlugins({
|
||||||
|
pluginsHomeOverride: pluginsHome,
|
||||||
|
loadPluginManifestOverride: () => null,
|
||||||
|
})
|
||||||
|
|
||||||
|
//#then — existing behavior keeps only the first entry; with scope filter it is
|
||||||
|
// (correctly) skipped because the first entry points at a different project.
|
||||||
|
expect(discovered.errors).toHaveLength(0)
|
||||||
|
expect(discovered.plugins).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("#given project and local scoped entries in v3 flat-array format", () => {
|
||||||
|
it("#when cwd matches projectPath #then projectPath flows through and the plugin loads", async () => {
|
||||||
|
//#given
|
||||||
|
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||||
|
const projectDirectory = createTemporaryDirectory("omo-v3-match-")
|
||||||
|
const installPath = createInstallPath("omo-v3-install-")
|
||||||
|
writeDatabase(pluginsHome, [
|
||||||
|
{
|
||||||
|
name: "v3-project-plugin",
|
||||||
|
marketplace: "market",
|
||||||
|
scope: "project",
|
||||||
|
projectPath: projectDirectory,
|
||||||
|
installPath,
|
||||||
|
version: "1.0.0",
|
||||||
|
lastUpdated: "2026-03-25T00:00:00Z",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
process.chdir(projectDirectory)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v3-match`)
|
||||||
|
const discovered = discoverInstalledPlugins({
|
||||||
|
pluginsHomeOverride: pluginsHome,
|
||||||
|
loadPluginManifestOverride: () => null,
|
||||||
|
})
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(discovered.errors).toHaveLength(0)
|
||||||
|
expect(discovered.plugins).toHaveLength(1)
|
||||||
|
expect(discovered.plugins[0]?.name).toBe("v3-project-plugin")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("#when cwd does not match projectPath #then the plugin is skipped", async () => {
|
||||||
|
//#given
|
||||||
|
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||||
|
const projectDirectory = createTemporaryDirectory("omo-v3-miss-")
|
||||||
|
const otherDirectory = createTemporaryDirectory("omo-v3-miss-other-")
|
||||||
|
const installPath = createInstallPath("omo-v3-install-")
|
||||||
|
writeDatabase(pluginsHome, [
|
||||||
|
{
|
||||||
|
name: "v3-skipped-plugin",
|
||||||
|
marketplace: "market",
|
||||||
|
scope: "project",
|
||||||
|
projectPath: projectDirectory,
|
||||||
|
installPath,
|
||||||
|
version: "1.0.0",
|
||||||
|
lastUpdated: "2026-03-25T00:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "v3-user-plugin",
|
||||||
|
marketplace: "market",
|
||||||
|
scope: "user",
|
||||||
|
installPath: createInstallPath("omo-v3-user-install-"),
|
||||||
|
version: "2.0.0",
|
||||||
|
lastUpdated: "2026-03-25T00:00:00Z",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
process.chdir(otherDirectory)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v3-miss`)
|
||||||
|
const discovered = discoverInstalledPlugins({
|
||||||
|
pluginsHomeOverride: pluginsHome,
|
||||||
|
loadPluginManifestOverride: () => null,
|
||||||
|
})
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(discovered.errors).toHaveLength(0)
|
||||||
|
expect(discovered.plugins).toHaveLength(1)
|
||||||
|
expect(discovered.plugins[0]?.name).toBe("v3-user-plugin")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("#given enabledPluginsOverride combined with scope filtering", () => {
|
||||||
|
it("#when a project-scoped plugin is disabled via override #then it is still skipped even if cwd would match", async () => {
|
||||||
|
//#given
|
||||||
|
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||||
|
const projectDirectory = createTemporaryDirectory("omo-enabled-proj-")
|
||||||
|
const installPath = createInstallPath("omo-enabled-install-")
|
||||||
|
writeDatabase(pluginsHome, {
|
||||||
|
version: 2,
|
||||||
|
plugins: {
|
||||||
|
"gated-plugin@market": [
|
||||||
|
{
|
||||||
|
scope: "project",
|
||||||
|
projectPath: projectDirectory,
|
||||||
|
installPath,
|
||||||
|
version: "1.0.0",
|
||||||
|
installedAt: "2026-03-25T00:00:00Z",
|
||||||
|
lastUpdated: "2026-03-25T00:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
process.chdir(projectDirectory)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-enabled-off`)
|
||||||
|
const discovered = discoverInstalledPlugins({
|
||||||
|
pluginsHomeOverride: pluginsHome,
|
||||||
|
loadPluginManifestOverride: () => null,
|
||||||
|
enabledPluginsOverride: { "gated-plugin@market": false },
|
||||||
|
})
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(discovered.errors).toHaveLength(0)
|
||||||
|
expect(discovered.plugins).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("#when a project-scoped plugin is enabled and cwd matches #then it loads", async () => {
|
||||||
|
//#given
|
||||||
|
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||||
|
const projectDirectory = createTemporaryDirectory("omo-enabled-match-")
|
||||||
|
const installPath = createInstallPath("omo-enabled-match-install-")
|
||||||
|
writeDatabase(pluginsHome, {
|
||||||
|
version: 2,
|
||||||
|
plugins: {
|
||||||
|
"enabled-plugin@market": [
|
||||||
|
{
|
||||||
|
scope: "project",
|
||||||
|
projectPath: projectDirectory,
|
||||||
|
installPath,
|
||||||
|
version: "1.0.0",
|
||||||
|
installedAt: "2026-03-25T00:00:00Z",
|
||||||
|
lastUpdated: "2026-03-25T00:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
process.chdir(projectDirectory)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-enabled-on`)
|
||||||
|
const discovered = discoverInstalledPlugins({
|
||||||
|
pluginsHomeOverride: pluginsHome,
|
||||||
|
loadPluginManifestOverride: () => null,
|
||||||
|
enabledPluginsOverride: { "enabled-plugin@market": true },
|
||||||
|
})
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(discovered.errors).toHaveLength(0)
|
||||||
|
expect(discovered.plugins).toHaveLength(1)
|
||||||
|
expect(discovered.plugins[0]?.name).toBe("enabled-plugin")
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { homedir } from "os"
|
|||||||
import { basename, join } from "path"
|
import { basename, join } from "path"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
|
import { shouldLoadPluginForCwd } from "./scope-filter"
|
||||||
import type {
|
import type {
|
||||||
InstalledPluginsDatabase,
|
InstalledPluginsDatabase,
|
||||||
InstalledPluginEntryV3,
|
InstalledPluginEntryV3,
|
||||||
@@ -132,6 +133,7 @@ function v3EntryToInstallation(entry: InstalledPluginEntryV3): PluginInstallatio
|
|||||||
installedAt: entry.lastUpdated,
|
installedAt: entry.lastUpdated,
|
||||||
lastUpdated: entry.lastUpdated,
|
lastUpdated: entry.lastUpdated,
|
||||||
gitCommitSha: entry.gitCommitSha,
|
gitCommitSha: entry.gitCommitSha,
|
||||||
|
projectPath: entry.projectPath,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +179,7 @@ export function discoverInstalledPlugins(options?: PluginLoaderOptions): PluginL
|
|||||||
const settingsEnabledPlugins = settings?.enabledPlugins
|
const settingsEnabledPlugins = settings?.enabledPlugins
|
||||||
const overrideEnabledPlugins = options?.enabledPluginsOverride
|
const overrideEnabledPlugins = options?.enabledPluginsOverride
|
||||||
const pluginManifestLoader = options?.loadPluginManifestOverride ?? loadPluginManifest
|
const pluginManifestLoader = options?.loadPluginManifestOverride ?? loadPluginManifest
|
||||||
|
const cwd = process.cwd()
|
||||||
|
|
||||||
for (const [pluginKey, installation] of extractPluginEntries(db)) {
|
for (const [pluginKey, installation] of extractPluginEntries(db)) {
|
||||||
if (!installation) continue
|
if (!installation) continue
|
||||||
@@ -186,6 +189,14 @@ export function discoverInstalledPlugins(options?: PluginLoaderOptions): PluginL
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!shouldLoadPluginForCwd(installation, cwd)) {
|
||||||
|
log(`Skipping ${installation.scope}-scoped plugin outside current cwd: ${pluginKey}`, {
|
||||||
|
projectPath: installation.projectPath,
|
||||||
|
cwd,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const { installPath, scope, version } = installation
|
const { installPath, scope, version } = installation
|
||||||
|
|
||||||
if (!existsSync(installPath)) {
|
if (!existsSync(installPath)) {
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import { shouldLoadPluginForCwd } from "./scope-filter"
|
||||||
|
|
||||||
|
const temporaryDirectories: string[] = []
|
||||||
|
|
||||||
|
function createTemporaryDirectory(prefix: string): string {
|
||||||
|
const directory = mkdtempSync(join(tmpdir(), prefix))
|
||||||
|
temporaryDirectories.push(directory)
|
||||||
|
return directory
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("shouldLoadPluginForCwd", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
mock.restore()
|
||||||
|
|
||||||
|
for (const directory of temporaryDirectories.splice(0)) {
|
||||||
|
rmSync(directory, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("#given a user-scoped plugin", () => {
|
||||||
|
it("#when called with any cwd #then it loads", () => {
|
||||||
|
//#given
|
||||||
|
const installation = { scope: "user" as const }
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = shouldLoadPluginForCwd(installation, "/tmp/anywhere")
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("#given a managed-scoped plugin", () => {
|
||||||
|
it("#when called with any cwd #then it loads", () => {
|
||||||
|
//#given
|
||||||
|
const installation = { scope: "managed" as const }
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = shouldLoadPluginForCwd(installation, "/tmp/anywhere")
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("#given a project-scoped plugin without projectPath", () => {
|
||||||
|
it("#when called with any cwd #then it is skipped", () => {
|
||||||
|
//#given
|
||||||
|
const installation = { scope: "project" as const }
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = shouldLoadPluginForCwd(installation, "/tmp/anywhere")
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("#given a local-scoped plugin without projectPath", () => {
|
||||||
|
it("#when called with any cwd #then it is skipped", () => {
|
||||||
|
//#given
|
||||||
|
const installation = { scope: "local" as const }
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = shouldLoadPluginForCwd(installation, "/tmp/anywhere")
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("#given a project-scoped plugin with matching projectPath", () => {
|
||||||
|
it("#when cwd exactly matches projectPath #then it loads", () => {
|
||||||
|
//#given
|
||||||
|
const projectDirectory = createTemporaryDirectory("omo-scope-")
|
||||||
|
const installation = {
|
||||||
|
scope: "project" as const,
|
||||||
|
projectPath: projectDirectory,
|
||||||
|
}
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = shouldLoadPluginForCwd(installation, projectDirectory)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("#when cwd is a subdirectory of projectPath #then it loads", () => {
|
||||||
|
//#given
|
||||||
|
const projectDirectory = createTemporaryDirectory("omo-scope-")
|
||||||
|
const installation = {
|
||||||
|
scope: "project" as const,
|
||||||
|
projectPath: projectDirectory,
|
||||||
|
}
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = shouldLoadPluginForCwd(installation, join(projectDirectory, "packages", "app"))
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("#given a project-scoped plugin with non-matching projectPath", () => {
|
||||||
|
it("#when cwd is unrelated #then it is skipped", () => {
|
||||||
|
//#given
|
||||||
|
const projectDirectory = createTemporaryDirectory("omo-scope-")
|
||||||
|
const otherDirectory = createTemporaryDirectory("omo-other-")
|
||||||
|
const installation = {
|
||||||
|
scope: "project" as const,
|
||||||
|
projectPath: projectDirectory,
|
||||||
|
}
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = shouldLoadPluginForCwd(installation, otherDirectory)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("#when cwd is the parent of projectPath #then it is skipped", () => {
|
||||||
|
//#given
|
||||||
|
const projectDirectory = createTemporaryDirectory("omo-scope-")
|
||||||
|
const installation = {
|
||||||
|
scope: "project" as const,
|
||||||
|
projectPath: join(projectDirectory, "nested"),
|
||||||
|
}
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = shouldLoadPluginForCwd(installation, projectDirectory)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("#given a local-scoped plugin with matching projectPath", () => {
|
||||||
|
it("#when cwd matches projectPath #then it loads", () => {
|
||||||
|
//#given
|
||||||
|
const projectDirectory = createTemporaryDirectory("omo-scope-")
|
||||||
|
const installation = {
|
||||||
|
scope: "local" as const,
|
||||||
|
projectPath: projectDirectory,
|
||||||
|
}
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = shouldLoadPluginForCwd(installation, projectDirectory)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("#given a local-scoped plugin with non-matching projectPath", () => {
|
||||||
|
it("#when cwd is unrelated #then it is skipped", () => {
|
||||||
|
//#given
|
||||||
|
const projectDirectory = createTemporaryDirectory("omo-scope-")
|
||||||
|
const otherDirectory = createTemporaryDirectory("omo-other-")
|
||||||
|
const installation = {
|
||||||
|
scope: "local" as const,
|
||||||
|
projectPath: projectDirectory,
|
||||||
|
}
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = shouldLoadPluginForCwd(installation, otherDirectory)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("#given a project-scoped plugin with a tilde-prefixed projectPath", () => {
|
||||||
|
let fakeHome: string
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fakeHome = createTemporaryDirectory("omo-home-")
|
||||||
|
mock.module("node:os", () => ({
|
||||||
|
homedir: () => fakeHome,
|
||||||
|
tmpdir,
|
||||||
|
}))
|
||||||
|
mock.module("os", () => ({
|
||||||
|
homedir: () => fakeHome,
|
||||||
|
tmpdir,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it("#when the expanded home matches cwd #then it loads", async () => {
|
||||||
|
//#given
|
||||||
|
const { shouldLoadPluginForCwd: freshShouldLoad } = await import(
|
||||||
|
`./scope-filter?t=${Date.now()}-tilde-match`
|
||||||
|
)
|
||||||
|
const installation = {
|
||||||
|
scope: "project" as const,
|
||||||
|
projectPath: "~/workspace/proj-a",
|
||||||
|
}
|
||||||
|
const cwd = join(fakeHome, "workspace", "proj-a")
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = freshShouldLoad(installation, cwd)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("#when the expanded home does not match cwd #then it is skipped", async () => {
|
||||||
|
//#given
|
||||||
|
const { shouldLoadPluginForCwd: freshShouldLoad } = await import(
|
||||||
|
`./scope-filter?t=${Date.now()}-tilde-mismatch`
|
||||||
|
)
|
||||||
|
const installation = {
|
||||||
|
scope: "project" as const,
|
||||||
|
projectPath: "~/workspace/proj-a",
|
||||||
|
}
|
||||||
|
const cwd = join(fakeHome, "workspace", "proj-b")
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = freshShouldLoad(installation, cwd)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("#when projectPath is exactly ~ and cwd equals fake home #then it loads", async () => {
|
||||||
|
//#given
|
||||||
|
const { shouldLoadPluginForCwd: freshShouldLoad } = await import(
|
||||||
|
`./scope-filter?t=${Date.now()}-tilde-root`
|
||||||
|
)
|
||||||
|
const installation = {
|
||||||
|
scope: "project" as const,
|
||||||
|
projectPath: "~",
|
||||||
|
}
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = freshShouldLoad(installation, fakeHome)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { homedir } from "os"
|
||||||
|
import { join } from "path"
|
||||||
|
import { containsPath } from "../../shared/contains-path"
|
||||||
|
import type { PluginInstallation } from "./types"
|
||||||
|
|
||||||
|
function expandTilde(inputPath: string): string {
|
||||||
|
if (inputPath === "~") {
|
||||||
|
return homedir()
|
||||||
|
}
|
||||||
|
if (inputPath.startsWith("~/") || inputPath.startsWith("~\\")) {
|
||||||
|
return join(homedir(), inputPath.slice(2))
|
||||||
|
}
|
||||||
|
return inputPath
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldLoadPluginForCwd(
|
||||||
|
installation: Pick<PluginInstallation, "scope" | "projectPath">,
|
||||||
|
cwd: string = process.cwd(),
|
||||||
|
): boolean {
|
||||||
|
if (installation.scope !== "project" && installation.scope !== "local") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!installation.projectPath) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return containsPath(expandTilde(installation.projectPath), cwd)
|
||||||
|
}
|
||||||
@@ -18,6 +18,12 @@ export interface PluginInstallation {
|
|||||||
lastUpdated: string
|
lastUpdated: string
|
||||||
gitCommitSha?: string
|
gitCommitSha?: string
|
||||||
isLocal?: boolean
|
isLocal?: boolean
|
||||||
|
/**
|
||||||
|
* Claude Code records this on project/local-scoped installations.
|
||||||
|
* Absolute path (or `~`-prefixed) of the project the plugin was installed for.
|
||||||
|
* Used to filter project/local plugins that do not belong to the current cwd.
|
||||||
|
*/
|
||||||
|
projectPath?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,6 +57,11 @@ export interface InstalledPluginEntryV3 {
|
|||||||
installPath: string
|
installPath: string
|
||||||
lastUpdated: string
|
lastUpdated: string
|
||||||
gitCommitSha?: string
|
gitCommitSha?: string
|
||||||
|
/**
|
||||||
|
* Claude Code records this on project/local-scoped installations.
|
||||||
|
* Absolute path (or `~`-prefixed) of the project the plugin was installed for.
|
||||||
|
*/
|
||||||
|
projectPath?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
getMainSessionID,
|
getMainSessionID,
|
||||||
registerAgentName,
|
registerAgentName,
|
||||||
isAgentRegistered,
|
isAgentRegistered,
|
||||||
|
resolveRegisteredAgentName,
|
||||||
_resetForTesting,
|
_resetForTesting,
|
||||||
} from "./state"
|
} from "./state"
|
||||||
|
|
||||||
@@ -140,6 +141,15 @@ describe("claude-code-session-state", () => {
|
|||||||
expect(isAgentRegistered("Atlas - Plan Executor")).toBe(true)
|
expect(isAgentRegistered("Atlas - Plan Executor")).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("should resolve config keys back to the registered raw agent name", () => {
|
||||||
|
// given
|
||||||
|
registerAgentName("\u200B\u200B\u200B\u200BAtlas - Plan Executor")
|
||||||
|
|
||||||
|
// when / then
|
||||||
|
expect(resolveRegisteredAgentName("atlas")).toBe("\u200B\u200B\u200B\u200BAtlas - Plan Executor")
|
||||||
|
expect(resolveRegisteredAgentName("Atlas - Plan Executor")).toBe("\u200B\u200B\u200B\u200BAtlas - Plan Executor")
|
||||||
|
})
|
||||||
|
|
||||||
describe("#given atlas display name with zero-width prefix", () => {
|
describe("#given atlas display name with zero-width prefix", () => {
|
||||||
describe("#when checking registration without the zero-width prefix", () => {
|
describe("#when checking registration without the zero-width prefix", () => {
|
||||||
test("#then it treats the display name as registered", () => {
|
test("#then it treats the display name as registered", () => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export function getMainSessionID(): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const registeredAgentNames = new Set<string>()
|
const registeredAgentNames = new Set<string>()
|
||||||
|
const registeredAgentAliases = new Map<string, string>()
|
||||||
|
|
||||||
const ZERO_WIDTH_CHARACTERS_REGEX = /[\u200B\u200C\u200D\uFEFF]/g
|
const ZERO_WIDTH_CHARACTERS_REGEX = /[\u200B\u200C\u200D\uFEFF]/g
|
||||||
|
|
||||||
@@ -28,10 +29,16 @@ function normalizeStoredAgentName(name: string): string {
|
|||||||
export function registerAgentName(name: string): void {
|
export function registerAgentName(name: string): void {
|
||||||
const normalizedName = normalizeRegisteredAgentName(name)
|
const normalizedName = normalizeRegisteredAgentName(name)
|
||||||
registeredAgentNames.add(normalizedName)
|
registeredAgentNames.add(normalizedName)
|
||||||
|
if (!registeredAgentAliases.has(normalizedName)) {
|
||||||
|
registeredAgentAliases.set(normalizedName, name)
|
||||||
|
}
|
||||||
|
|
||||||
const configKey = normalizeRegisteredAgentName(getAgentConfigKey(name))
|
const configKey = normalizeRegisteredAgentName(getAgentConfigKey(name))
|
||||||
if (configKey !== normalizedName) {
|
if (configKey !== normalizedName) {
|
||||||
registeredAgentNames.add(configKey)
|
registeredAgentNames.add(configKey)
|
||||||
|
if (!registeredAgentAliases.has(configKey)) {
|
||||||
|
registeredAgentAliases.set(configKey, name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +46,15 @@ export function isAgentRegistered(name: string): boolean {
|
|||||||
return registeredAgentNames.has(normalizeRegisteredAgentName(name))
|
return registeredAgentNames.has(normalizeRegisteredAgentName(name))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveRegisteredAgentName(name: string | undefined): string | undefined {
|
||||||
|
if (typeof name !== "string") {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedName = normalizeRegisteredAgentName(name)
|
||||||
|
return registeredAgentAliases.get(normalizedName) ?? normalizeStoredAgentName(name)
|
||||||
|
}
|
||||||
|
|
||||||
/** @internal For testing only */
|
/** @internal For testing only */
|
||||||
export function _resetForTesting(): void {
|
export function _resetForTesting(): void {
|
||||||
_mainSessionID = undefined
|
_mainSessionID = undefined
|
||||||
@@ -46,6 +62,7 @@ export function _resetForTesting(): void {
|
|||||||
syncSubagentSessions.clear()
|
syncSubagentSessions.clear()
|
||||||
sessionAgentMap.clear()
|
sessionAgentMap.clear()
|
||||||
registeredAgentNames.clear()
|
registeredAgentNames.clear()
|
||||||
|
registeredAgentAliases.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionAgentMap = new Map<string, string>()
|
const sessionAgentMap = new Map<string, string>()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
generatePartId,
|
generatePartId,
|
||||||
injectHookMessage,
|
injectHookMessage,
|
||||||
} from "./injector"
|
} from "./injector"
|
||||||
|
import { PART_STORAGE } from "../../shared"
|
||||||
import { isSqliteBackend, resetSqliteBackendCache } from "../../shared/opencode-storage-detection"
|
import { isSqliteBackend, resetSqliteBackendCache } from "../../shared/opencode-storage-detection"
|
||||||
|
|
||||||
//#region Mocks
|
//#region Mocks
|
||||||
@@ -53,6 +54,7 @@ function createMockClient(messages: Array<{
|
|||||||
tools?: Record<string, boolean>
|
tools?: Record<string, boolean>
|
||||||
time?: { created?: number }
|
time?: { created?: number }
|
||||||
}
|
}
|
||||||
|
parts?: Array<{ type?: string }>
|
||||||
}>): {
|
}>): {
|
||||||
session: {
|
session: {
|
||||||
messages: (opts: { path: { id: string } }) => Promise<{ data: typeof messages }>
|
messages: (opts: { path: { id: string } }) => Promise<{ data: typeof messages }>
|
||||||
@@ -176,6 +178,24 @@ describe("findNearestMessageWithFieldsFromSDK", () => {
|
|||||||
|
|
||||||
expect(result?.agent).toBe("newest-by-time")
|
expect(result?.agent).toBe("newest-by-time")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("skips compaction marker user messages when resolving nearest message", async () => {
|
||||||
|
const mockClient = createMockClient([
|
||||||
|
{
|
||||||
|
id: "msg_compaction",
|
||||||
|
info: { agent: "atlas", model: { providerID: "openai", modelID: "gpt-5" }, time: { created: 200 } },
|
||||||
|
parts: [{ type: "compaction" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "msg_real",
|
||||||
|
info: { agent: "sisyphus", model: { providerID: "anthropic", modelID: "claude-opus-4" }, time: { created: 100 } },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123")
|
||||||
|
|
||||||
|
expect(result?.agent).toBe("sisyphus")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("findNearestMessageWithFields JSON backend ordering", () => {
|
describe("findNearestMessageWithFields JSON backend ordering", () => {
|
||||||
@@ -197,6 +217,34 @@ describe("findNearestMessageWithFields JSON backend ordering", () => {
|
|||||||
|
|
||||||
expect(result?.agent).toBe("newest-by-time")
|
expect(result?.agent).toBe("newest-by-time")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("skips JSON messages whose parts contain a compaction marker", () => {
|
||||||
|
mockIsSqliteBackend.mockReturnValue(false)
|
||||||
|
const messageDir = createMessageDir()
|
||||||
|
const compactionMessageID = "msg_test_injector_compaction_marker"
|
||||||
|
const partDir = join(PART_STORAGE, compactionMessageID)
|
||||||
|
tempDirs.push(partDir)
|
||||||
|
|
||||||
|
writeFileSync(join(messageDir, "msg_0001.json"), JSON.stringify({
|
||||||
|
id: compactionMessageID,
|
||||||
|
agent: "atlas",
|
||||||
|
model: { providerID: "openai", modelID: "gpt-5" },
|
||||||
|
time: { created: 200 },
|
||||||
|
}))
|
||||||
|
mkdirSync(partDir, { recursive: true })
|
||||||
|
writeFileSync(join(partDir, "prt_0001.json"), JSON.stringify({ type: "compaction" }))
|
||||||
|
|
||||||
|
writeFileSync(join(messageDir, "msg_0002.json"), JSON.stringify({
|
||||||
|
id: "msg_0002",
|
||||||
|
agent: "sisyphus",
|
||||||
|
model: { providerID: "anthropic", modelID: "claude-opus-4" },
|
||||||
|
time: { created: 100 },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const result = findNearestMessageWithFields(messageDir)
|
||||||
|
|
||||||
|
expect(result?.agent).toBe("sisyphus")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("findFirstMessageWithAgentFromSDK", () => {
|
describe("findFirstMessageWithAgentFromSDK", () => {
|
||||||
@@ -222,6 +270,17 @@ describe("findFirstMessageWithAgentFromSDK", () => {
|
|||||||
expect(result).toBe("earliest-agent")
|
expect(result).toBe("earliest-agent")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("skips compaction marker user messages when resolving first agent", async () => {
|
||||||
|
const mockClient = createMockClient([
|
||||||
|
{ id: "msg_compaction", info: { agent: "atlas", time: { created: 10 } }, parts: [{ type: "compaction" }] },
|
||||||
|
{ id: "msg_real", info: { agent: "sisyphus", time: { created: 20 } } },
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123")
|
||||||
|
|
||||||
|
expect(result).toBe("sisyphus")
|
||||||
|
})
|
||||||
|
|
||||||
it("skips messages without agent field", async () => {
|
it("skips messages without agent field", async () => {
|
||||||
const mockClient = createMockClient([
|
const mockClient = createMockClient([
|
||||||
{ info: {} },
|
{ info: {} },
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { MessageMeta, OriginalMessageContext, TextPart, ToolPermission } fr
|
|||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
|
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
|
||||||
import { createInternalAgentTextPart, normalizeSDKResponse } from "../../shared"
|
import { createInternalAgentTextPart, normalizeSDKResponse } from "../../shared"
|
||||||
|
import { hasCompactionPartInStorage, isCompactionMessage } from "../../shared/compaction-marker"
|
||||||
|
|
||||||
export interface StoredMessage {
|
export interface StoredMessage {
|
||||||
agent?: string
|
agent?: string
|
||||||
@@ -32,6 +33,7 @@ interface SDKMessage {
|
|||||||
created?: number
|
created?: number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
parts?: Array<{ type?: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
const processPrefix = randomBytes(4).toString("hex")
|
const processPrefix = randomBytes(4).toString("hex")
|
||||||
@@ -39,6 +41,10 @@ let messageCounter = 0
|
|||||||
let partCounter = 0
|
let partCounter = 0
|
||||||
|
|
||||||
function convertSDKMessageToStoredMessage(msg: SDKMessage): StoredMessage | null {
|
function convertSDKMessageToStoredMessage(msg: SDKMessage): StoredMessage | null {
|
||||||
|
if (isCompactionMessage(msg)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const info = msg.info
|
const info = msg.info
|
||||||
if (!info) return null
|
if (!info) return null
|
||||||
|
|
||||||
@@ -164,22 +170,38 @@ export function findNearestMessageWithFields(messageDir: string): StoredMessage
|
|||||||
return {
|
return {
|
||||||
fileName,
|
fileName,
|
||||||
msg,
|
msg,
|
||||||
|
hasCompactionMarker: hasCompactionPartInStorage(
|
||||||
|
typeof (msg as { id?: unknown }).id === "string" ? (msg as { id?: string }).id : undefined,
|
||||||
|
),
|
||||||
createdAt: typeof msg.time?.created === "number" ? msg.time.created : Number.NEGATIVE_INFINITY,
|
createdAt: typeof msg.time?.created === "number" ? msg.time.created : Number.NEGATIVE_INFINITY,
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter((entry): entry is { fileName: string; msg: StoredMessage & { time?: { created?: number } }; createdAt: number } => entry !== null)
|
.filter((entry): entry is {
|
||||||
|
fileName: string
|
||||||
|
msg: StoredMessage & { time?: { created?: number } }
|
||||||
|
hasCompactionMarker: boolean
|
||||||
|
createdAt: number
|
||||||
|
} => entry !== null)
|
||||||
.sort((left, right) => right.createdAt - left.createdAt || right.fileName.localeCompare(left.fileName))
|
.sort((left, right) => right.createdAt - left.createdAt || right.fileName.localeCompare(left.fileName))
|
||||||
|
|
||||||
for (const entry of messages) {
|
for (const entry of messages) {
|
||||||
|
if (entry.hasCompactionMarker || isCompactionMessage({ agent: entry.msg.agent })) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if (entry.msg.agent && entry.msg.model?.providerID && entry.msg.model?.modelID) {
|
if (entry.msg.agent && entry.msg.model?.providerID && entry.msg.model?.modelID) {
|
||||||
return entry.msg
|
return entry.msg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of messages) {
|
for (const entry of messages) {
|
||||||
|
if (entry.hasCompactionMarker || isCompactionMessage({ agent: entry.msg.agent })) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if (entry.msg.agent || (entry.msg.model?.providerID && entry.msg.model?.modelID)) {
|
if (entry.msg.agent || (entry.msg.model?.providerID && entry.msg.model?.modelID)) {
|
||||||
return entry.msg
|
return entry.msg
|
||||||
}
|
}
|
||||||
@@ -216,16 +238,28 @@ export function findFirstMessageWithAgent(messageDir: string): string | null {
|
|||||||
return {
|
return {
|
||||||
fileName,
|
fileName,
|
||||||
msg,
|
msg,
|
||||||
|
hasCompactionMarker: hasCompactionPartInStorage(
|
||||||
|
typeof (msg as { id?: unknown }).id === "string" ? (msg as { id?: string }).id : undefined,
|
||||||
|
),
|
||||||
createdAt: typeof msg.time?.created === "number" ? msg.time.created : Number.POSITIVE_INFINITY,
|
createdAt: typeof msg.time?.created === "number" ? msg.time.created : Number.POSITIVE_INFINITY,
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter((entry): entry is { fileName: string; msg: StoredMessage & { time?: { created?: number } }; createdAt: number } => entry !== null)
|
.filter((entry): entry is {
|
||||||
|
fileName: string
|
||||||
|
msg: StoredMessage & { time?: { created?: number } }
|
||||||
|
hasCompactionMarker: boolean
|
||||||
|
createdAt: number
|
||||||
|
} => entry !== null)
|
||||||
.sort((left, right) => left.createdAt - right.createdAt || left.fileName.localeCompare(right.fileName))
|
.sort((left, right) => left.createdAt - right.createdAt || left.fileName.localeCompare(right.fileName))
|
||||||
|
|
||||||
for (const entry of messages) {
|
for (const entry of messages) {
|
||||||
|
if (entry.hasCompactionMarker || isCompactionMessage({ agent: entry.msg.agent })) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if (entry.msg.agent) {
|
if (entry.msg.agent) {
|
||||||
return entry.msg.agent
|
return entry.msg.agent
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { OAuthTokenData } from "./storage"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-server OAuth refresh mutex to prevent concurrent refresh race conditions.
|
||||||
|
*
|
||||||
|
* When multiple operations need to refresh a token for the same server,
|
||||||
|
* this ensures only one refresh request is made and all waiters receive
|
||||||
|
* the same result.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ongoingRefreshes = new Map<string, Promise<OAuthTokenData>>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a token refresh with per-server mutual exclusion.
|
||||||
|
*
|
||||||
|
* If a refresh is already in progress for the given server, this will
|
||||||
|
* return the same promise to all concurrent callers. Once the refresh
|
||||||
|
* completes (success or failure), the lock is released.
|
||||||
|
*
|
||||||
|
* @param serverUrl - The OAuth server URL (used as mutex key)
|
||||||
|
* @param refreshFn - The actual refresh operation to execute
|
||||||
|
* @returns Promise that resolves to the new token data
|
||||||
|
*/
|
||||||
|
export async function withRefreshMutex(
|
||||||
|
serverUrl: string,
|
||||||
|
refreshFn: () => Promise<OAuthTokenData>,
|
||||||
|
): Promise<OAuthTokenData> {
|
||||||
|
const existing = ongoingRefreshes.get(serverUrl)
|
||||||
|
if (existing) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshPromise = refreshFn().finally(() => {
|
||||||
|
ongoingRefreshes.delete(serverUrl)
|
||||||
|
})
|
||||||
|
|
||||||
|
ongoingRefreshes.set(serverUrl, refreshPromise)
|
||||||
|
return refreshPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a refresh is currently in progress for a server.
|
||||||
|
*
|
||||||
|
* @param serverUrl - The OAuth server URL
|
||||||
|
* @returns true if a refresh operation is active
|
||||||
|
*/
|
||||||
|
export function isRefreshInProgress(serverUrl: string): boolean {
|
||||||
|
return ongoingRefreshes.has(serverUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of servers currently undergoing token refresh.
|
||||||
|
*
|
||||||
|
* @returns Number of active refresh operations
|
||||||
|
*/
|
||||||
|
export function getActiveRefreshCount(): number {
|
||||||
|
return ongoingRefreshes.size
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
|
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"
|
||||||
import { dirname, join } from "node:path"
|
import { dirname, join } from "node:path"
|
||||||
import { getOpenCodeConfigDir } from "../../shared"
|
import { getOpenCodeConfigDir } from "../../shared"
|
||||||
|
|
||||||
@@ -82,8 +82,10 @@ function writeStore(store: TokenStore): boolean {
|
|||||||
mkdirSync(dir, { recursive: true })
|
mkdirSync(dir, { recursive: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
writeFileSync(filePath, JSON.stringify(store, null, 2), { encoding: "utf-8", mode: 0o600 })
|
const tempPath = `${filePath}.tmp.${Date.now()}`
|
||||||
chmodSync(filePath, 0o600)
|
writeFileSync(tempPath, JSON.stringify(store, null, 2), { encoding: "utf-8", mode: 0o600 })
|
||||||
|
chmodSync(tempPath, 0o600)
|
||||||
|
renameSync(tempPath, filePath)
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { afterAll, afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
|
import { afterAll, afterEach, beforeEach, describe, expect, it, mock, test } from "bun:test"
|
||||||
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
||||||
import type { SkillMcpClientInfo, SkillMcpManagerState } from "./types"
|
import type { SkillMcpClientInfo, SkillMcpManagerState } from "./types"
|
||||||
|
|
||||||
@@ -89,11 +89,15 @@ function createState(): SkillMcpManagerState {
|
|||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
function createClientInfo(serverName: string): SkillMcpClientInfo {
|
function createClientInfo(
|
||||||
|
serverName: string,
|
||||||
|
scope?: SkillMcpClientInfo["scope"],
|
||||||
|
): SkillMcpClientInfo {
|
||||||
return {
|
return {
|
||||||
serverName,
|
serverName,
|
||||||
skillName: "env-skill",
|
skillName: "env-skill",
|
||||||
sessionID: "session-env",
|
sessionID: "session-env",
|
||||||
|
...(scope !== undefined ? { scope } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +129,68 @@ afterEach(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("getOrCreateClient env var expansion", () => {
|
describe("getOrCreateClient env var expansion", () => {
|
||||||
|
describe("#given a scope-sensitive stdio skill MCP config", () => {
|
||||||
|
test.each([
|
||||||
|
["opencode-project", "Authorization:Bearer "],
|
||||||
|
["local", "Authorization:Bearer "],
|
||||||
|
["user", "Authorization:Bearer xoxp-scope-token"],
|
||||||
|
["builtin", "Authorization:Bearer xoxp-scope-token"],
|
||||||
|
] satisfies Array<[NonNullable<SkillMcpClientInfo["scope"]>, string]>) (
|
||||||
|
"#when creating the client for %s scope #then args expand to %s",
|
||||||
|
async (scope, expectedAuthorizationHeader) => {
|
||||||
|
// given
|
||||||
|
process.env.SLACK_USER_TOKEN = "xoxp-scope-token"
|
||||||
|
const state = createState()
|
||||||
|
const info = createClientInfo(`scope-${scope}`, scope)
|
||||||
|
const clientKey = createClientKey(info)
|
||||||
|
const config: ClaudeCodeMcpServer = {
|
||||||
|
command: "npx",
|
||||||
|
args: [
|
||||||
|
"-y",
|
||||||
|
"mcp-remote",
|
||||||
|
"https://mcp.slack.com/mcp",
|
||||||
|
"--header",
|
||||||
|
"Authorization:Bearer ${SLACK_USER_TOKEN}",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
await getOrCreateClient({ state, clientKey, info, config })
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(createdStdioTransports).toHaveLength(1)
|
||||||
|
expect(createdStdioTransports[0]?.options.args?.[4]).toBe(expectedAuthorizationHeader)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it("#when creating the client without scope #then env vars remain trusted for backward compatibility", async () => {
|
||||||
|
// given
|
||||||
|
process.env.SLACK_USER_TOKEN = "xoxp-undefined-scope-token"
|
||||||
|
const state = createState()
|
||||||
|
const info = createClientInfo("scope-undefined")
|
||||||
|
const clientKey = createClientKey(info)
|
||||||
|
const config: ClaudeCodeMcpServer = {
|
||||||
|
command: "npx",
|
||||||
|
args: [
|
||||||
|
"-y",
|
||||||
|
"mcp-remote",
|
||||||
|
"https://mcp.slack.com/mcp",
|
||||||
|
"--header",
|
||||||
|
"Authorization:Bearer ${SLACK_USER_TOKEN}",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
await getOrCreateClient({ state, clientKey, info, config })
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(createdStdioTransports).toHaveLength(1)
|
||||||
|
expect(createdStdioTransports[0]?.options.args?.[4]).toBe(
|
||||||
|
"Authorization:Bearer xoxp-undefined-scope-token",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("#given a stdio skill MCP config with sensitive env vars in args", () => {
|
describe("#given a stdio skill MCP config with sensitive env vars in args", () => {
|
||||||
it("#when creating the client #then sensitive env vars in args are expanded", async () => {
|
it("#when creating the client #then sensitive env vars in args are expanded", async () => {
|
||||||
// given
|
// given
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ function createClientInfo(sessionID: string): SkillMcpClientInfo {
|
|||||||
serverName: "race-server",
|
serverName: "race-server",
|
||||||
skillName: "race-skill",
|
skillName: "race-skill",
|
||||||
sessionID,
|
sessionID,
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ function removeClientIfCurrent(state: SkillMcpManagerState, clientKey: string, c
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PROJECT_SCOPES = new Set(["project", "opencode-project", "local"])
|
||||||
|
|
||||||
export async function getOrCreateClient(params: {
|
export async function getOrCreateClient(params: {
|
||||||
state: SkillMcpManagerState
|
state: SkillMcpManagerState
|
||||||
clientKey: string
|
clientKey: string
|
||||||
@@ -38,7 +40,8 @@ export async function getOrCreateClient(params: {
|
|||||||
return pending
|
return pending
|
||||||
}
|
}
|
||||||
|
|
||||||
const expandedConfig = expandEnvVarsInObject(config, { trusted: true })
|
const isTrusted = !PROJECT_SCOPES.has(info.scope ?? "")
|
||||||
|
const expandedConfig = expandEnvVarsInObject(config, { trusted: isTrusted })
|
||||||
let currentConnectionPromise!: Promise<Client>
|
let currentConnectionPromise!: Promise<Client>
|
||||||
state.inFlightConnections.set(info.sessionID, (state.inFlightConnections.get(info.sessionID) ?? 0) + 1)
|
state.inFlightConnections.set(info.sessionID, (state.inFlightConnections.get(info.sessionID) ?? 0) + 1)
|
||||||
currentConnectionPromise = (async () => {
|
currentConnectionPromise = (async () => {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// Redacts sensitive tokens from error messages to prevent credential exposure
|
||||||
|
// Follows same patterns as env-cleaner.ts for consistency
|
||||||
|
|
||||||
|
const SENSITIVE_PATTERNS: RegExp[] = [
|
||||||
|
// API keys and tokens in common formats
|
||||||
|
/[a-zA-Z0-9_-]*(?:api[_-]?key|apikey)["\s]*[:=]["\s]*([a-zA-Z0-9_-]{16,})/gi,
|
||||||
|
/[a-zA-Z0-9_-]*(?:auth[_-]?token|authtoken)["\s]*[:=]["\s]*([a-zA-Z0-9_-]{16,})/gi,
|
||||||
|
/[a-zA-Z0-9_-]*(?:access[_-]?token|accesstoken)["\s]*[:=]["\s]*([a-zA-Z0-9_-]{16,})/gi,
|
||||||
|
/[a-zA-Z0-9_-]*(?:secret)["\s]*[:=]["\s]*([a-zA-Z0-9_-]{16,})/gi,
|
||||||
|
/[a-zA-Z0-9_-]*(?:password)["\s]*[:=]["\s]*([a-zA-Z0-9_-]{8,})/gi,
|
||||||
|
|
||||||
|
// Bearer tokens
|
||||||
|
/bearer\s+([a-zA-Z0-9_-]{20,})/gi,
|
||||||
|
|
||||||
|
// Common token prefixes
|
||||||
|
/sk-[a-zA-Z0-9]{20,}/g, // OpenAI-style secret keys
|
||||||
|
/gh[pousr]_[a-zA-Z0-9]{20,}/gi, // GitHub tokens
|
||||||
|
/glpat-[a-zA-Z0-9_-]{20,}/gi, // GitLab tokens
|
||||||
|
/[A-Za-z0-9_]{20,}-[A-Za-z0-9_]{10,}-[A-Za-z0-9_]{10,}/g, // Common JWT-like patterns
|
||||||
|
]
|
||||||
|
|
||||||
|
const REDACTION_MARKER = "[REDACTED]"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redacts sensitive tokens from a string.
|
||||||
|
* Used for error messages that may contain command-line arguments or environment info.
|
||||||
|
*/
|
||||||
|
export function redactSensitiveData(input: string): string {
|
||||||
|
let result = input
|
||||||
|
|
||||||
|
for (const pattern of SENSITIVE_PATTERNS) {
|
||||||
|
result = result.replace(pattern, REDACTION_MARKER)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redacts sensitive data from an Error object, returning a new Error.
|
||||||
|
* Preserves the stack trace but redacts the message.
|
||||||
|
*/
|
||||||
|
export function redactErrorSensitiveData(error: Error): Error {
|
||||||
|
const redactedMessage = redactSensitiveData(error.message)
|
||||||
|
const redactedError = new Error(redactedMessage)
|
||||||
|
redactedError.stack = error.stack ? redactSensitiveData(error.stack) : undefined
|
||||||
|
return redactedError
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"
|
||||||
|
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
||||||
|
import type { OAuthTokenData } from "../mcp-oauth/storage"
|
||||||
|
import type { SkillMcpClientInfo, SkillMcpServerContext } from "./types"
|
||||||
|
|
||||||
|
const mockGetOrCreateClient = mock(async () => {
|
||||||
|
throw new Error("not used")
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockGetOrCreateClientWithRetryImpl = mock(async () => ({
|
||||||
|
callTool: mock(async () => ({ content: [{ type: "text", text: "unused" }] })),
|
||||||
|
close: mock(async () => {}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
type ManagerModule = typeof import("./manager")
|
||||||
|
|
||||||
|
async function importFreshManagerModule(): Promise<ManagerModule> {
|
||||||
|
mock.module("./connection", () => ({
|
||||||
|
getOrCreateClient: mockGetOrCreateClient,
|
||||||
|
getOrCreateClientWithRetryImpl: mockGetOrCreateClientWithRetryImpl,
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module("../mcp-oauth/provider", () => ({
|
||||||
|
McpOAuthProvider: class MockMcpOAuthProvider {},
|
||||||
|
}))
|
||||||
|
|
||||||
|
return await import(new URL(`./manager.ts?oauth-retry-test=${Date.now()}-${Math.random()}`, import.meta.url).href)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInfo(): SkillMcpClientInfo {
|
||||||
|
return {
|
||||||
|
serverName: "oauth-server",
|
||||||
|
skillName: "oauth-skill",
|
||||||
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createContext(): SkillMcpServerContext {
|
||||||
|
return {
|
||||||
|
skillName: "oauth-skill",
|
||||||
|
config: {
|
||||||
|
url: "https://mcp.example.com/mcp",
|
||||||
|
oauth: { clientId: "test-client" },
|
||||||
|
} satisfies ClaudeCodeMcpServer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
mock.restore()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("SkillMcpManager post-request OAuth retry", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGetOrCreateClient.mockClear()
|
||||||
|
mockGetOrCreateClientWithRetryImpl.mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("retries the operation after a 401 refresh succeeds", async () => {
|
||||||
|
// given
|
||||||
|
const { SkillMcpManager } = await importFreshManagerModule()
|
||||||
|
const refresh = mock(async () => ({ accessToken: "refreshed-token" } satisfies OAuthTokenData))
|
||||||
|
const manager = new SkillMcpManager({
|
||||||
|
createOAuthProvider: () => ({
|
||||||
|
tokens: () => ({ accessToken: "stale-token", refreshToken: "refresh-token" }),
|
||||||
|
login: mock(async () => ({ accessToken: "login-token" } satisfies OAuthTokenData)),
|
||||||
|
refresh,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const callTool = mock(async () => {
|
||||||
|
if (callTool.mock.calls.length === 1) {
|
||||||
|
throw new Error("401 Unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
return { content: [{ type: "text", text: "success" }] }
|
||||||
|
})
|
||||||
|
mockGetOrCreateClientWithRetryImpl.mockResolvedValue({ callTool, close: mock(async () => {}) })
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await manager.callTool(createInfo(), createContext(), "test-tool", {})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toEqual([{ type: "text", text: "success" }])
|
||||||
|
expect(refresh).toHaveBeenCalledTimes(1)
|
||||||
|
expect(callTool).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("retries the operation after a 403 refresh succeeds without step-up scope", async () => {
|
||||||
|
// given
|
||||||
|
const { SkillMcpManager } = await importFreshManagerModule()
|
||||||
|
const refresh = mock(async () => ({ accessToken: "refreshed-token" } satisfies OAuthTokenData))
|
||||||
|
const manager = new SkillMcpManager({
|
||||||
|
createOAuthProvider: () => ({
|
||||||
|
tokens: () => ({ accessToken: "stale-token", refreshToken: "refresh-token" }),
|
||||||
|
login: mock(async () => ({ accessToken: "login-token" } satisfies OAuthTokenData)),
|
||||||
|
refresh,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const callTool = mock(async () => {
|
||||||
|
if (callTool.mock.calls.length === 1) {
|
||||||
|
throw new Error("403 Forbidden")
|
||||||
|
}
|
||||||
|
|
||||||
|
return { content: [{ type: "text", text: "success" }] }
|
||||||
|
})
|
||||||
|
mockGetOrCreateClientWithRetryImpl.mockResolvedValue({ callTool, close: mock(async () => {}) })
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await manager.callTool(createInfo(), createContext(), "test-tool", {})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toEqual([{ type: "text", text: "success" }])
|
||||||
|
expect(refresh).toHaveBeenCalledTimes(1)
|
||||||
|
expect(callTool).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("propagates the auth error without retry when refresh fails", async () => {
|
||||||
|
// given
|
||||||
|
const { SkillMcpManager } = await importFreshManagerModule()
|
||||||
|
const refresh = mock(async () => {
|
||||||
|
throw new Error("refresh failed")
|
||||||
|
})
|
||||||
|
const manager = new SkillMcpManager({
|
||||||
|
createOAuthProvider: () => ({
|
||||||
|
tokens: () => ({ accessToken: "stale-token", refreshToken: "refresh-token" }),
|
||||||
|
login: mock(async () => ({ accessToken: "login-token" } satisfies OAuthTokenData)),
|
||||||
|
refresh,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const callTool = mock(async () => {
|
||||||
|
throw new Error("401 Unauthorized")
|
||||||
|
})
|
||||||
|
mockGetOrCreateClientWithRetryImpl.mockResolvedValue({ callTool, close: mock(async () => {}) })
|
||||||
|
|
||||||
|
// when / then
|
||||||
|
await expect(manager.callTool(createInfo(), createContext(), "test-tool", {})).rejects.toThrow("401 Unauthorized")
|
||||||
|
expect(refresh).toHaveBeenCalledTimes(1)
|
||||||
|
expect(callTool).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("only attempts one refresh when the retried operation returns 401 again", async () => {
|
||||||
|
// given
|
||||||
|
const { SkillMcpManager } = await importFreshManagerModule()
|
||||||
|
const refresh = mock(async () => ({ accessToken: "refreshed-token" } satisfies OAuthTokenData))
|
||||||
|
const manager = new SkillMcpManager({
|
||||||
|
createOAuthProvider: () => ({
|
||||||
|
tokens: () => ({ accessToken: "stale-token", refreshToken: "refresh-token" }),
|
||||||
|
login: mock(async () => ({ accessToken: "login-token" } satisfies OAuthTokenData)),
|
||||||
|
refresh,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const callTool = mock(async () => {
|
||||||
|
throw new Error("401 Unauthorized")
|
||||||
|
})
|
||||||
|
mockGetOrCreateClientWithRetryImpl.mockResolvedValue({ callTool, close: mock(async () => {}) })
|
||||||
|
|
||||||
|
// when / then
|
||||||
|
await expect(manager.callTool(createInfo(), createContext(), "test-tool", {})).rejects.toThrow("401 Unauthorized")
|
||||||
|
expect(refresh).toHaveBeenCalledTimes(1)
|
||||||
|
expect(callTool).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -65,6 +65,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "test-server",
|
serverName: "test-server",
|
||||||
skillName: "test-skill",
|
skillName: "test-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {}
|
const config: ClaudeCodeMcpServer = {}
|
||||||
|
|
||||||
@@ -80,6 +81,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "my-mcp",
|
serverName: "my-mcp",
|
||||||
skillName: "data-skill",
|
skillName: "data-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {}
|
const config: ClaudeCodeMcpServer = {}
|
||||||
|
|
||||||
@@ -95,6 +97,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "custom-server",
|
serverName: "custom-server",
|
||||||
skillName: "custom-skill",
|
skillName: "custom-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {}
|
const config: ClaudeCodeMcpServer = {}
|
||||||
|
|
||||||
@@ -112,6 +115,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "http-server",
|
serverName: "http-server",
|
||||||
skillName: "test-skill",
|
skillName: "test-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
type: "http",
|
type: "http",
|
||||||
@@ -130,6 +134,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "sse-server",
|
serverName: "sse-server",
|
||||||
skillName: "test-skill",
|
skillName: "test-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
type: "sse",
|
type: "sse",
|
||||||
@@ -148,6 +153,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "inferred-http",
|
serverName: "inferred-http",
|
||||||
skillName: "test-skill",
|
skillName: "test-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
url: "https://example.com/mcp",
|
url: "https://example.com/mcp",
|
||||||
@@ -165,6 +171,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "stdio-server",
|
serverName: "stdio-server",
|
||||||
skillName: "test-skill",
|
skillName: "test-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
type: "stdio",
|
type: "stdio",
|
||||||
@@ -184,6 +191,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "inferred-stdio",
|
serverName: "inferred-stdio",
|
||||||
skillName: "test-skill",
|
skillName: "test-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
command: "node",
|
command: "node",
|
||||||
@@ -202,6 +210,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "mixed-config",
|
serverName: "mixed-config",
|
||||||
skillName: "test-skill",
|
skillName: "test-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
type: "stdio",
|
type: "stdio",
|
||||||
@@ -224,6 +233,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "bad-url-server",
|
serverName: "bad-url-server",
|
||||||
skillName: "test-skill",
|
skillName: "test-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
type: "http",
|
type: "http",
|
||||||
@@ -242,6 +252,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "http-error-server",
|
serverName: "http-error-server",
|
||||||
skillName: "test-skill",
|
skillName: "test-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
url: "https://nonexistent.example.com/mcp",
|
url: "https://nonexistent.example.com/mcp",
|
||||||
@@ -259,6 +270,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "hint-server",
|
serverName: "hint-server",
|
||||||
skillName: "test-skill",
|
skillName: "test-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
url: "https://nonexistent.example.com/mcp",
|
url: "https://nonexistent.example.com/mcp",
|
||||||
@@ -276,6 +288,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "mock-test-server",
|
serverName: "mock-test-server",
|
||||||
skillName: "test-skill",
|
skillName: "test-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
url: "https://example.com/mcp",
|
url: "https://example.com/mcp",
|
||||||
@@ -302,6 +315,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "missing-command",
|
serverName: "missing-command",
|
||||||
skillName: "test-skill",
|
skillName: "test-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
type: "stdio",
|
type: "stdio",
|
||||||
@@ -320,6 +334,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "test-server",
|
serverName: "test-server",
|
||||||
skillName: "test-skill",
|
skillName: "test-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
command: "nonexistent-command-xyz",
|
command: "nonexistent-command-xyz",
|
||||||
@@ -338,6 +353,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "test-server",
|
serverName: "test-server",
|
||||||
skillName: "test-skill",
|
skillName: "test-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
command: "nonexistent-command",
|
command: "nonexistent-command",
|
||||||
@@ -358,11 +374,13 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "server1",
|
serverName: "server1",
|
||||||
skillName: "skill1",
|
skillName: "skill1",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const session2Info: SkillMcpClientInfo = {
|
const session2Info: SkillMcpClientInfo = {
|
||||||
serverName: "server1",
|
serverName: "server1",
|
||||||
skillName: "skill1",
|
skillName: "skill1",
|
||||||
sessionID: "session-2",
|
sessionID: "session-2",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -396,6 +414,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "signal-server",
|
serverName: "signal-server",
|
||||||
skillName: "signal-skill",
|
skillName: "signal-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
url: "https://example.com/mcp",
|
url: "https://example.com/mcp",
|
||||||
@@ -423,11 +442,12 @@ describe("SkillMcpManager", () => {
|
|||||||
describe("isConnected", () => {
|
describe("isConnected", () => {
|
||||||
it("returns false for unconnected server", () => {
|
it("returns false for unconnected server", () => {
|
||||||
// given
|
// given
|
||||||
const info: SkillMcpClientInfo = {
|
const info: SkillMcpClientInfo = {
|
||||||
serverName: "unknown",
|
serverName: "$1",
|
||||||
skillName: "test",
|
skillName: "$2",
|
||||||
sessionID: "session-1",
|
sessionID: "$3",
|
||||||
}
|
scope: "builtin",
|
||||||
|
}
|
||||||
|
|
||||||
// when / #then
|
// when / #then
|
||||||
expect(manager.isConnected(info)).toBe(false)
|
expect(manager.isConnected(info)).toBe(false)
|
||||||
@@ -448,6 +468,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "test-server",
|
serverName: "test-server",
|
||||||
skillName: "test-skill",
|
skillName: "test-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const configWithoutEnv: ClaudeCodeMcpServer = {
|
const configWithoutEnv: ClaudeCodeMcpServer = {
|
||||||
command: "node",
|
command: "node",
|
||||||
@@ -471,6 +492,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "test-server",
|
serverName: "test-server",
|
||||||
skillName: "test-skill",
|
skillName: "test-skill",
|
||||||
sessionID: "session-2",
|
sessionID: "session-2",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const configWithEnv: ClaudeCodeMcpServer = {
|
const configWithEnv: ClaudeCodeMcpServer = {
|
||||||
command: "node",
|
command: "node",
|
||||||
@@ -498,6 +520,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "auth-server",
|
serverName: "auth-server",
|
||||||
skillName: "test-skill",
|
skillName: "test-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
url: "https://example.com/mcp",
|
url: "https://example.com/mcp",
|
||||||
@@ -526,6 +549,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "no-auth-server",
|
serverName: "no-auth-server",
|
||||||
skillName: "test-skill",
|
skillName: "test-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
url: "https://example.com/mcp",
|
url: "https://example.com/mcp",
|
||||||
@@ -546,6 +570,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "retry-server",
|
serverName: "retry-server",
|
||||||
skillName: "retry-skill",
|
skillName: "retry-skill",
|
||||||
sessionID: "session-retry-1",
|
sessionID: "session-retry-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const context: SkillMcpServerContext = {
|
const context: SkillMcpServerContext = {
|
||||||
config: {
|
config: {
|
||||||
@@ -584,6 +609,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "fail-server",
|
serverName: "fail-server",
|
||||||
skillName: "fail-skill",
|
skillName: "fail-skill",
|
||||||
sessionID: "session-fail-1",
|
sessionID: "session-fail-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const context: SkillMcpServerContext = {
|
const context: SkillMcpServerContext = {
|
||||||
config: {
|
config: {
|
||||||
@@ -615,6 +641,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "error-server",
|
serverName: "error-server",
|
||||||
skillName: "error-skill",
|
skillName: "error-skill",
|
||||||
sessionID: "session-error-1",
|
sessionID: "session-error-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const context: SkillMcpServerContext = {
|
const context: SkillMcpServerContext = {
|
||||||
config: {
|
config: {
|
||||||
@@ -653,6 +680,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "oauth-server",
|
serverName: "oauth-server",
|
||||||
skillName: "oauth-skill",
|
skillName: "oauth-skill",
|
||||||
sessionID: "session-oauth-1",
|
sessionID: "session-oauth-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
url: "https://mcp.example.com/mcp",
|
url: "https://mcp.example.com/mcp",
|
||||||
@@ -679,6 +707,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "oauth-no-token",
|
serverName: "oauth-no-token",
|
||||||
skillName: "oauth-skill",
|
skillName: "oauth-skill",
|
||||||
sessionID: "session-oauth-2",
|
sessionID: "session-oauth-2",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
url: "https://mcp.example.com/mcp",
|
url: "https://mcp.example.com/mcp",
|
||||||
@@ -705,6 +734,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "oauth-with-headers",
|
serverName: "oauth-with-headers",
|
||||||
skillName: "oauth-skill",
|
skillName: "oauth-skill",
|
||||||
sessionID: "session-oauth-3",
|
sessionID: "session-oauth-3",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
url: "https://mcp.example.com/mcp",
|
url: "https://mcp.example.com/mcp",
|
||||||
@@ -734,6 +764,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "oauth-refresh",
|
serverName: "oauth-refresh",
|
||||||
skillName: "oauth-skill",
|
skillName: "oauth-skill",
|
||||||
sessionID: "session-oauth-refresh",
|
sessionID: "session-oauth-refresh",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
url: "https://mcp.example.com/mcp",
|
url: "https://mcp.example.com/mcp",
|
||||||
@@ -766,6 +797,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "oauth-refresh-fallback",
|
serverName: "oauth-refresh-fallback",
|
||||||
skillName: "oauth-skill",
|
skillName: "oauth-skill",
|
||||||
sessionID: "session-oauth-refresh-fallback",
|
sessionID: "session-oauth-refresh-fallback",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
url: "https://mcp.example.com/mcp",
|
url: "https://mcp.example.com/mcp",
|
||||||
@@ -799,6 +831,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "no-oauth-server",
|
serverName: "no-oauth-server",
|
||||||
skillName: "test-skill",
|
skillName: "test-skill",
|
||||||
sessionID: "session-no-oauth",
|
sessionID: "session-no-oauth",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
url: "https://mcp.example.com/mcp",
|
url: "https://mcp.example.com/mcp",
|
||||||
@@ -824,6 +857,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "stepup-server",
|
serverName: "stepup-server",
|
||||||
skillName: "stepup-skill",
|
skillName: "stepup-skill",
|
||||||
sessionID: "session-stepup-1",
|
sessionID: "session-stepup-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
url: "https://mcp.example.com/mcp",
|
url: "https://mcp.example.com/mcp",
|
||||||
@@ -869,6 +903,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "no-stepup-server",
|
serverName: "no-stepup-server",
|
||||||
skillName: "no-stepup-skill",
|
skillName: "no-stepup-skill",
|
||||||
sessionID: "session-no-stepup",
|
sessionID: "session-no-stepup",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const context: SkillMcpServerContext = {
|
const context: SkillMcpServerContext = {
|
||||||
config: {
|
config: {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
|||||||
import { McpOAuthProvider } from "../mcp-oauth/provider"
|
import { McpOAuthProvider } from "../mcp-oauth/provider"
|
||||||
import { disconnectAll, disconnectSession, forceReconnect } from "./cleanup"
|
import { disconnectAll, disconnectSession, forceReconnect } from "./cleanup"
|
||||||
import { getOrCreateClient, getOrCreateClientWithRetryImpl } from "./connection"
|
import { getOrCreateClient, getOrCreateClientWithRetryImpl } from "./connection"
|
||||||
import { handleStepUpIfNeeded } from "./oauth-handler"
|
import { handlePostRequestAuthError, handleStepUpIfNeeded } from "./oauth-handler"
|
||||||
import type {
|
import type {
|
||||||
OAuthProviderFactory,
|
OAuthProviderFactory,
|
||||||
SkillMcpClientInfo,
|
SkillMcpClientInfo,
|
||||||
@@ -110,6 +110,7 @@ export class SkillMcpManager {
|
|||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const maxRetries = 3
|
const maxRetries = 3
|
||||||
let lastError: Error | null = null
|
let lastError: Error | null = null
|
||||||
|
const refreshAttempted = new Set<string>()
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
try {
|
try {
|
||||||
@@ -130,6 +131,17 @@ export class SkillMcpManager {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const postRequestRefreshHandled = await handlePostRequestAuthError({
|
||||||
|
error: lastError,
|
||||||
|
config,
|
||||||
|
authProviders: this.state.authProviders,
|
||||||
|
createOAuthProvider: this.state.createOAuthProvider,
|
||||||
|
refreshAttempted,
|
||||||
|
})
|
||||||
|
if (postRequestRefreshHandled) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if (!errorMessage.includes("not connected")) {
|
if (!errorMessage.includes("not connected")) {
|
||||||
throw lastError
|
throw lastError
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { describe, expect, it, mock } from "bun:test"
|
||||||
|
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
||||||
|
import type { OAuthTokenData } from "../mcp-oauth/storage"
|
||||||
|
import type { OAuthProviderFactory, OAuthProviderLike } from "./types"
|
||||||
|
|
||||||
|
type OAuthHandlerModule = typeof import("./oauth-handler")
|
||||||
|
|
||||||
|
async function importFreshOAuthHandlerModule(): Promise<OAuthHandlerModule> {
|
||||||
|
mock.module("../mcp-oauth/provider", () => ({
|
||||||
|
McpOAuthProvider: class MockMcpOAuthProvider {},
|
||||||
|
}))
|
||||||
|
|
||||||
|
return await import(new URL(`./oauth-handler.ts?oauth-handler-test=${Date.now()}-${Math.random()}`, import.meta.url).href)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Deferred<TValue> = {
|
||||||
|
promise: Promise<TValue>
|
||||||
|
resolve: (value: TValue) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDeferred<TValue>(): Deferred<TValue> {
|
||||||
|
let resolvePromise: ((value: TValue) => void) | null = null
|
||||||
|
const promise = new Promise<TValue>((resolve) => {
|
||||||
|
resolvePromise = resolve
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!resolvePromise) {
|
||||||
|
throw new Error("Failed to create deferred promise")
|
||||||
|
}
|
||||||
|
|
||||||
|
return { promise, resolve: resolvePromise }
|
||||||
|
}
|
||||||
|
|
||||||
|
function createConfig(serverUrl: string): ClaudeCodeMcpServer {
|
||||||
|
return {
|
||||||
|
url: serverUrl,
|
||||||
|
oauth: {
|
||||||
|
clientId: "test-client",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("oauth-handler refresh mutex wiring", () => {
|
||||||
|
it("deduplicates concurrent pre-request refresh attempts for the same server", async () => {
|
||||||
|
// given
|
||||||
|
const { buildHttpRequestInit } = await importFreshOAuthHandlerModule()
|
||||||
|
const deferred = createDeferred<OAuthTokenData>()
|
||||||
|
const refresh = mock(() => deferred.promise)
|
||||||
|
const provider: OAuthProviderLike = {
|
||||||
|
tokens: () => ({
|
||||||
|
accessToken: "expired-token",
|
||||||
|
refreshToken: "refresh-token",
|
||||||
|
expiresAt: Math.floor(Date.now() / 1000) - 60,
|
||||||
|
}),
|
||||||
|
login: mock(async () => ({ accessToken: "login-token" } satisfies OAuthTokenData)),
|
||||||
|
refresh,
|
||||||
|
}
|
||||||
|
const authProviders = new Map<string, OAuthProviderLike>()
|
||||||
|
const createOAuthProvider: OAuthProviderFactory = () => provider
|
||||||
|
|
||||||
|
// when
|
||||||
|
const firstRequest = buildHttpRequestInit(createConfig("https://same.example.com/mcp"), authProviders, createOAuthProvider)
|
||||||
|
const secondRequest = buildHttpRequestInit(createConfig("https://same.example.com/mcp"), authProviders, createOAuthProvider)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(refresh).toHaveBeenCalledTimes(1)
|
||||||
|
deferred.resolve({ accessToken: "refreshed-token" })
|
||||||
|
await expect(firstRequest).resolves.toEqual({ headers: { Authorization: "Bearer refreshed-token" } })
|
||||||
|
await expect(secondRequest).resolves.toEqual({ headers: { Authorization: "Bearer refreshed-token" } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("allows different servers to refresh independently after request auth errors", async () => {
|
||||||
|
// given
|
||||||
|
const { handlePostRequestAuthError } = await importFreshOAuthHandlerModule()
|
||||||
|
const firstDeferred = createDeferred<OAuthTokenData>()
|
||||||
|
const secondDeferred = createDeferred<OAuthTokenData>()
|
||||||
|
const firstProvider: OAuthProviderLike = {
|
||||||
|
tokens: () => ({ accessToken: "expired-a", refreshToken: "refresh-a" }),
|
||||||
|
login: mock(async () => ({ accessToken: "login-a" } satisfies OAuthTokenData)),
|
||||||
|
refresh: mock(() => firstDeferred.promise),
|
||||||
|
}
|
||||||
|
const secondProvider: OAuthProviderLike = {
|
||||||
|
tokens: () => ({ accessToken: "expired-b", refreshToken: "refresh-b" }),
|
||||||
|
login: mock(async () => ({ accessToken: "login-b" } satisfies OAuthTokenData)),
|
||||||
|
refresh: mock(() => secondDeferred.promise),
|
||||||
|
}
|
||||||
|
const providers = new Map([
|
||||||
|
["https://server-a.example.com/mcp", firstProvider],
|
||||||
|
["https://server-b.example.com/mcp", secondProvider],
|
||||||
|
])
|
||||||
|
|
||||||
|
// when
|
||||||
|
const firstAttempt = handlePostRequestAuthError({
|
||||||
|
error: new Error("401 Unauthorized"),
|
||||||
|
config: createConfig("https://server-a.example.com/mcp"),
|
||||||
|
authProviders: providers,
|
||||||
|
})
|
||||||
|
const secondAttempt = handlePostRequestAuthError({
|
||||||
|
error: new Error("403 Forbidden"),
|
||||||
|
config: createConfig("https://server-b.example.com/mcp"),
|
||||||
|
authProviders: providers,
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(firstProvider.refresh).toHaveBeenCalledTimes(1)
|
||||||
|
expect(secondProvider.refresh).toHaveBeenCalledTimes(1)
|
||||||
|
firstDeferred.resolve({ accessToken: "refreshed-a" })
|
||||||
|
secondDeferred.resolve({ accessToken: "refreshed-b" })
|
||||||
|
await expect(firstAttempt).resolves.toBe(true)
|
||||||
|
await expect(secondAttempt).resolves.toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("allows a new refresh after the previous same-server refresh completes", async () => {
|
||||||
|
// given
|
||||||
|
const { handlePostRequestAuthError } = await importFreshOAuthHandlerModule()
|
||||||
|
const refresh = mock(async () => ({ accessToken: `refreshed-${refresh.mock.calls.length + 1}` } satisfies OAuthTokenData))
|
||||||
|
const provider: OAuthProviderLike = {
|
||||||
|
tokens: () => ({ accessToken: "expired-token", refreshToken: "refresh-token" }),
|
||||||
|
login: mock(async () => ({ accessToken: "login-token" } satisfies OAuthTokenData)),
|
||||||
|
refresh,
|
||||||
|
}
|
||||||
|
const authProviders = new Map<string, OAuthProviderLike>([["https://same.example.com/mcp", provider]])
|
||||||
|
|
||||||
|
// when
|
||||||
|
const firstResult = await handlePostRequestAuthError({
|
||||||
|
error: new Error("401 Unauthorized"),
|
||||||
|
config: createConfig("https://same.example.com/mcp"),
|
||||||
|
authProviders,
|
||||||
|
})
|
||||||
|
const secondResult = await handlePostRequestAuthError({
|
||||||
|
error: new Error("401 Unauthorized"),
|
||||||
|
config: createConfig("https://same.example.com/mcp"),
|
||||||
|
authProviders,
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(firstResult).toBe(true)
|
||||||
|
expect(secondResult).toBe(true)
|
||||||
|
expect(refresh).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
||||||
import { McpOAuthProvider } from "../mcp-oauth/provider"
|
import { McpOAuthProvider } from "../mcp-oauth/provider"
|
||||||
|
import { withRefreshMutex } from "../mcp-oauth/refresh-mutex"
|
||||||
import type { OAuthTokenData } from "../mcp-oauth/storage"
|
import type { OAuthTokenData } from "../mcp-oauth/storage"
|
||||||
import { isStepUpRequired, mergeScopes } from "../mcp-oauth/step-up"
|
import { isStepUpRequired, mergeScopes } from "../mcp-oauth/step-up"
|
||||||
import type { OAuthProviderFactory, OAuthProviderLike } from "./types"
|
import type { OAuthProviderFactory, OAuthProviderLike } from "./types"
|
||||||
@@ -52,14 +53,15 @@ export async function buildHttpRequestInit(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tokenData && isTokenExpired(tokenData)) {
|
if (tokenData && isTokenExpired(tokenData)) {
|
||||||
try {
|
|
||||||
tokenData = tokenData.refreshToken
|
|
||||||
? await provider.refresh(tokenData.refreshToken)
|
|
||||||
: await provider.login()
|
|
||||||
} catch {
|
|
||||||
try {
|
try {
|
||||||
tokenData = await provider.login()
|
const refreshToken = tokenData.refreshToken
|
||||||
|
tokenData = refreshToken
|
||||||
|
? await withRefreshMutex(config.url, () => provider.refresh(refreshToken))
|
||||||
|
: await provider.login()
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
tokenData = await provider.login()
|
||||||
} catch {
|
} catch {
|
||||||
tokenData = null
|
tokenData = null
|
||||||
}
|
}
|
||||||
@@ -116,3 +118,43 @@ export async function handleStepUpIfNeeded(params: {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function handlePostRequestAuthError(params: {
|
||||||
|
error: Error
|
||||||
|
config: ClaudeCodeMcpServer
|
||||||
|
authProviders: Map<string, OAuthProviderLike>
|
||||||
|
createOAuthProvider?: OAuthProviderFactory
|
||||||
|
refreshAttempted?: Set<string>
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const { error, config, authProviders, createOAuthProvider, refreshAttempted = new Set() } = params
|
||||||
|
|
||||||
|
if (!config.oauth || !config.url) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusMatch = /\b(401|403)\b/.exec(error.message)
|
||||||
|
if (!statusMatch) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = getOrCreateAuthProvider(authProviders, config.url, config.oauth, createOAuthProvider)
|
||||||
|
const tokenData = provider.tokens()
|
||||||
|
|
||||||
|
if (!tokenData?.refreshToken) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refreshAttempted.has(config.url)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshAttempted.add(config.url)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const refreshToken = tokenData.refreshToken
|
||||||
|
await withRefreshMutex(config.url, () => provider.refresh(refreshToken))
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
|||||||
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
||||||
import { createCleanMcpEnvironment } from "./env-cleaner"
|
import { createCleanMcpEnvironment } from "./env-cleaner"
|
||||||
import { registerProcessCleanup, startCleanupTimer } from "./cleanup"
|
import { registerProcessCleanup, startCleanupTimer } from "./cleanup"
|
||||||
|
import { redactSensitiveData } from "./error-redaction"
|
||||||
import type { ManagedClient, SkillMcpClientConnectionParams } from "./types"
|
import type { ManagedClient, SkillMcpClientConnectionParams } from "./types"
|
||||||
|
|
||||||
function getStdioCommand(config: ClaudeCodeMcpServer, serverName: string): string {
|
function getStdioCommand(config: ClaudeCodeMcpServer, serverName: string): string {
|
||||||
@@ -45,10 +46,13 @@ export async function createStdioClient(params: SkillMcpClientConnectionParams):
|
|||||||
}
|
}
|
||||||
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
|
const fullCommand = `${command} ${args.join(" ")}`
|
||||||
|
const safeCommand = redactSensitiveData(fullCommand)
|
||||||
|
const safeErrorMessage = redactSensitiveData(errorMessage)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to connect to MCP server "${info.serverName}".\n\n` +
|
`Failed to connect to MCP server "${info.serverName}".\n\n` +
|
||||||
`Command: ${command} ${args.join(" ")}\n` +
|
`Command: ${safeCommand}\n` +
|
||||||
`Reason: ${errorMessage}\n\n` +
|
`Reason: ${safeErrorMessage}\n\n` +
|
||||||
`Hints:\n` +
|
`Hints:\n` +
|
||||||
` - Ensure the command is installed and available in PATH\n` +
|
` - Ensure the command is installed and available in PATH\n` +
|
||||||
` - Check if the MCP server package exists\n` +
|
` - Check if the MCP server package exists\n` +
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdi
|
|||||||
import type { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
import type { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||||
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
||||||
import type { McpOAuthProvider } from "../mcp-oauth/provider"
|
import type { McpOAuthProvider } from "../mcp-oauth/provider"
|
||||||
|
import type { SkillScope } from "../opencode-skill-loader/types"
|
||||||
|
|
||||||
export type SkillMcpConfig = Record<string, ClaudeCodeMcpServer>
|
export type SkillMcpConfig = Record<string, ClaudeCodeMcpServer>
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@ export interface SkillMcpClientInfo {
|
|||||||
serverName: string
|
serverName: string
|
||||||
skillName: string
|
skillName: string
|
||||||
sessionID: string
|
sessionID: string
|
||||||
|
scope?: SkillScope | "local"
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkillMcpServerContext {
|
export interface SkillMcpServerContext {
|
||||||
|
|||||||
@@ -40,10 +40,22 @@ mock.module("./action-executor", () => ({
|
|||||||
mock.module("../../shared/tmux", () => ({
|
mock.module("../../shared/tmux", () => ({
|
||||||
isInsideTmux: mockIsInsideTmux,
|
isInsideTmux: mockIsInsideTmux,
|
||||||
getCurrentPaneId: mockGetCurrentPaneId,
|
getCurrentPaneId: mockGetCurrentPaneId,
|
||||||
|
isServerRunning: mock(async () => true),
|
||||||
|
resetServerCheck: mock(() => {}),
|
||||||
|
markServerRunningInProcess: mock(() => {}),
|
||||||
|
getPaneDimensions: mock(async () => ({ width: 220, height: 44 })),
|
||||||
|
spawnTmuxPane: mock(async () => ({ success: true, paneId: "%1" })),
|
||||||
|
closeTmuxPane: mock(async () => ({ success: true })),
|
||||||
|
replaceTmuxPane: mock(async () => ({ success: true, paneId: "%1" })),
|
||||||
|
spawnTmuxWindow: mock(async () => ({ success: true, windowId: "@1" })),
|
||||||
|
spawnTmuxSession: mock(async () => ({ success: true, sessionId: "mock" })),
|
||||||
|
applyLayout: mock(async () => ({ success: true })),
|
||||||
|
enforceMainPaneWidth: mock(async () => ({ success: true })),
|
||||||
POLL_INTERVAL_BACKGROUND_MS: 10,
|
POLL_INTERVAL_BACKGROUND_MS: 10,
|
||||||
SESSION_READY_POLL_INTERVAL_MS: 10,
|
SESSION_READY_POLL_INTERVAL_MS: 10,
|
||||||
SESSION_READY_TIMEOUT_MS: 50,
|
SESSION_READY_TIMEOUT_MS: 50,
|
||||||
SESSION_MISSING_GRACE_MS: 1_000,
|
SESSION_MISSING_GRACE_MS: 1_000,
|
||||||
|
SESSION_TIMEOUT_MS: 600_000,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
afterAll(() => { mock.restore() })
|
afterAll(() => { mock.restore() })
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import type { BackgroundManager } from "../../features/background-agent"
|
import type { BackgroundManager } from "../../features/background-agent"
|
||||||
import { isAgentRegistered } from "../../features/claude-code-session-state"
|
import {
|
||||||
|
isAgentRegistered,
|
||||||
|
resolveRegisteredAgentName,
|
||||||
|
} from "../../features/claude-code-session-state"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared"
|
import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared"
|
||||||
import { HOOK_NAME } from "./hook-name"
|
import { HOOK_NAME } from "./hook-name"
|
||||||
@@ -55,7 +58,9 @@ export async function injectBoulderContinuation(input: {
|
|||||||
`\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]` +
|
`\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]` +
|
||||||
preferredSessionContext +
|
preferredSessionContext +
|
||||||
worktreeContext
|
worktreeContext
|
||||||
const continuationAgent = agent ?? (isAgentRegistered("atlas") ? "atlas" : undefined)
|
const continuationAgent = resolveRegisteredAgentName(
|
||||||
|
agent ?? (isAgentRegistered("atlas") ? "atlas" : undefined),
|
||||||
|
)
|
||||||
|
|
||||||
if (!continuationAgent || !isAgentRegistered(continuationAgent)) {
|
if (!continuationAgent || !isAgentRegistered(continuationAgent)) {
|
||||||
log(`[${HOOK_NAME}] Skipped injection: continuation agent unavailable`, {
|
log(`[${HOOK_NAME}] Skipped injection: continuation agent unavailable`, {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const { afterEach, describe, expect, mock, test, afterAll } = require("bun:test"
|
|||||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
|
import { PART_STORAGE } from "../../shared"
|
||||||
|
|
||||||
const testDirs: string[] = []
|
const testDirs: string[] = []
|
||||||
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-session-last-agent-${Date.now()}`)
|
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-session-last-agent-${Date.now()}`)
|
||||||
@@ -64,4 +65,36 @@ describe("getLastAgentFromSession JSON backend", () => {
|
|||||||
// then
|
// then
|
||||||
expect(result).toBe("atlas")
|
expect(result).toBe("atlas")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("skips JSON messages whose part storage contains a compaction marker", async () => {
|
||||||
|
// given
|
||||||
|
const sessionID = "ses_json_compaction_marker"
|
||||||
|
const messageDir = createTempMessageDir(sessionID)
|
||||||
|
const compactionMessageID = "msg_test_atlas_compaction_marker"
|
||||||
|
const partDir = join(PART_STORAGE, compactionMessageID)
|
||||||
|
testDirs.push(partDir)
|
||||||
|
writeFileSync(join(messageDir, "msg_0001.json"), JSON.stringify({
|
||||||
|
id: compactionMessageID,
|
||||||
|
agent: "atlas",
|
||||||
|
time: { created: 200 },
|
||||||
|
}), "utf-8")
|
||||||
|
mkdirSync(partDir, { recursive: true })
|
||||||
|
writeFileSync(join(partDir, "prt_0001.json"), JSON.stringify({
|
||||||
|
type: "compaction",
|
||||||
|
}), "utf-8")
|
||||||
|
|
||||||
|
writeFileSync(join(messageDir, "msg_0002.json"), JSON.stringify({
|
||||||
|
id: "msg_0002",
|
||||||
|
agent: "sisyphus-junior",
|
||||||
|
time: { created: 100 },
|
||||||
|
}), "utf-8")
|
||||||
|
|
||||||
|
const { getLastAgentFromSession } = await import("./session-last-agent")
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await getLastAgentFromSession(sessionID)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toBe("sisyphus-junior")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -52,6 +52,30 @@ describe("getLastAgentFromSession SQLite backend ordering", () => {
|
|||||||
expect(result).toBe("sisyphus-junior")
|
expect(result).toBe("sisyphus-junior")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("skips compaction marker user messages that retain the original agent", async () => {
|
||||||
|
// given
|
||||||
|
const client = {
|
||||||
|
session: {
|
||||||
|
messages: async () => ({
|
||||||
|
data: [
|
||||||
|
{ id: "msg_real", info: { agent: "sisyphus", time: { created: 100 } } },
|
||||||
|
{
|
||||||
|
id: "msg_compaction",
|
||||||
|
info: { agent: "atlas", time: { created: 200 } },
|
||||||
|
parts: [{ type: "compaction" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await getLastAgentFromSession("ses_sqlite_compaction_marker", client as never)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toBe("sisyphus")
|
||||||
|
})
|
||||||
|
|
||||||
test("returns null instead of throwing when SQLite message lookup fails", async () => {
|
test("returns null instead of throwing when SQLite message lookup fails", async () => {
|
||||||
// given
|
// given
|
||||||
const client = {
|
const client = {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { readFileSync, readdirSync } from "node:fs"
|
|||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
|
|
||||||
import { getMessageDir, isSqliteBackend, normalizeSDKResponse } from "../../shared"
|
import { getMessageDir, isSqliteBackend, normalizeSDKResponse } from "../../shared"
|
||||||
|
import { hasCompactionPartInStorage, isCompactionMessage } from "../../shared/compaction-marker"
|
||||||
|
|
||||||
type SessionMessagesClient = {
|
type SessionMessagesClient = {
|
||||||
session: {
|
session: {
|
||||||
@@ -9,10 +10,6 @@ type SessionMessagesClient = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isCompactionAgent(agent: unknown): boolean {
|
|
||||||
return typeof agent === "string" && agent.toLowerCase() === "compaction"
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLastAgentFromMessageDir(messageDir: string): string | null {
|
function getLastAgentFromMessageDir(messageDir: string): string | null {
|
||||||
try {
|
try {
|
||||||
const messages = readdirSync(messageDir)
|
const messages = readdirSync(messageDir)
|
||||||
@@ -20,9 +17,10 @@ function getLastAgentFromMessageDir(messageDir: string): string | null {
|
|||||||
.map((fileName) => {
|
.map((fileName) => {
|
||||||
try {
|
try {
|
||||||
const content = readFileSync(join(messageDir, fileName), "utf-8")
|
const content = readFileSync(join(messageDir, fileName), "utf-8")
|
||||||
const parsed = JSON.parse(content) as { agent?: unknown; time?: { created?: unknown } }
|
const parsed = JSON.parse(content) as { id?: string; agent?: unknown; time?: { created?: unknown } }
|
||||||
return {
|
return {
|
||||||
fileName,
|
fileName,
|
||||||
|
id: parsed.id,
|
||||||
agent: parsed.agent,
|
agent: parsed.agent,
|
||||||
createdAt: typeof parsed.time?.created === "number" ? parsed.time.created : Number.NEGATIVE_INFINITY,
|
createdAt: typeof parsed.time?.created === "number" ? parsed.time.created : Number.NEGATIVE_INFINITY,
|
||||||
}
|
}
|
||||||
@@ -30,11 +28,16 @@ function getLastAgentFromMessageDir(messageDir: string): string | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter((message): message is { fileName: string; agent: unknown; createdAt: number } => message !== null)
|
.filter((message): message is { fileName: string; id: string | undefined; agent: unknown; createdAt: number } => message !== null)
|
||||||
.sort((left, right) => right.createdAt - left.createdAt || right.fileName.localeCompare(left.fileName))
|
.sort((left, right) => (right?.createdAt ?? 0) - (left?.createdAt ?? 0) || (right?.fileName ?? "").localeCompare(left?.fileName ?? ""))
|
||||||
|
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
if (typeof message.agent === "string" && !isCompactionAgent(message.agent)) {
|
if (!message) continue
|
||||||
|
if (isCompactionMessage({ agent: message.agent }) || hasCompactionPartInStorage(message?.id)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof message.agent === "string") {
|
||||||
return message.agent.toLowerCase()
|
return message.agent.toLowerCase()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,7 +55,11 @@ export async function getLastAgentFromSession(
|
|||||||
if (isSqliteBackend() && client) {
|
if (isSqliteBackend() && client) {
|
||||||
try {
|
try {
|
||||||
const response = await client.session.messages({ path: { id: sessionID } })
|
const response = await client.session.messages({ path: { id: sessionID } })
|
||||||
const messages = normalizeSDKResponse(response, [] as Array<{ id?: string; info?: { agent?: string; time?: { created?: number } } }>, {
|
const messages = normalizeSDKResponse(response, [] as Array<{
|
||||||
|
id?: string
|
||||||
|
info?: { agent?: string; time?: { created?: number } }
|
||||||
|
parts?: Array<{ type?: string }>
|
||||||
|
}>, {
|
||||||
preferResponseOnMissingData: true,
|
preferResponseOnMissingData: true,
|
||||||
}).sort((left, right) => {
|
}).sort((left, right) => {
|
||||||
const leftTime = (left as { info?: { time?: { created?: number } } }).info?.time?.created ?? Number.NEGATIVE_INFINITY
|
const leftTime = (left as { info?: { time?: { created?: number } } }).info?.time?.created ?? Number.NEGATIVE_INFINITY
|
||||||
@@ -67,8 +74,12 @@ export async function getLastAgentFromSession(
|
|||||||
})
|
})
|
||||||
|
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
|
if (isCompactionMessage(message)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const agent = message.info?.agent
|
const agent = message.info?.agent
|
||||||
if (typeof agent === "string" && !isCompactionAgent(agent)) {
|
if (typeof agent === "string") {
|
||||||
return agent.toLowerCase()
|
return agent.toLowerCase()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import { join } from "node:path"
|
||||||
|
|
||||||
|
// Hold mutable mock state so beforeEach can swap the cache root for each test.
|
||||||
|
const mockState: { candidates: string[] } = { candidates: [] }
|
||||||
|
|
||||||
|
mock.module("../constants", () => ({
|
||||||
|
INSTALLED_PACKAGE_JSON_CANDIDATES: new Proxy([], {
|
||||||
|
get(_, prop) {
|
||||||
|
const current = mockState.candidates
|
||||||
|
// Forward array methods/properties to the mutable candidates list
|
||||||
|
// so getCachedVersion's `for (... of ...)` sees fresh data per test.
|
||||||
|
const value = (current as unknown as Record<PropertyKey, unknown>)[prop]
|
||||||
|
if (typeof value === "function") {
|
||||||
|
return (value as (...args: unknown[]) => unknown).bind(current)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module("./package-json-locator", () => ({
|
||||||
|
findPackageJsonUp: () => null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { getCachedVersion } from "./cached-version"
|
||||||
|
|
||||||
|
describe("getCachedVersion (GH-3257)", () => {
|
||||||
|
let cacheRoot: string
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cacheRoot = mkdtempSync(join(tmpdir(), "omo-cached-version-"))
|
||||||
|
mockState.candidates = [
|
||||||
|
join(cacheRoot, "node_modules", "oh-my-opencode", "package.json"),
|
||||||
|
join(cacheRoot, "node_modules", "oh-my-openagent", "package.json"),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(cacheRoot, { recursive: true, force: true })
|
||||||
|
mockState.candidates = []
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns the version when the package is installed under oh-my-opencode", () => {
|
||||||
|
const pkgDir = join(cacheRoot, "node_modules", "oh-my-opencode")
|
||||||
|
mkdirSync(pkgDir, { recursive: true })
|
||||||
|
writeFileSync(join(pkgDir, "package.json"), JSON.stringify({ name: "oh-my-opencode", version: "3.16.0" }))
|
||||||
|
|
||||||
|
expect(getCachedVersion()).toBe("3.16.0")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns the version when the package is installed under oh-my-openagent", () => {
|
||||||
|
// GH-3257: npm users who install the aliased `oh-my-openagent` package get
|
||||||
|
// node_modules/oh-my-openagent/package.json, not the canonical oh-my-opencode
|
||||||
|
// path. The cached version resolver must check both.
|
||||||
|
const pkgDir = join(cacheRoot, "node_modules", "oh-my-openagent")
|
||||||
|
mkdirSync(pkgDir, { recursive: true })
|
||||||
|
writeFileSync(join(pkgDir, "package.json"), JSON.stringify({ name: "oh-my-openagent", version: "3.16.0" }))
|
||||||
|
|
||||||
|
expect(getCachedVersion()).toBe("3.16.0")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("prefers oh-my-opencode when both are installed", () => {
|
||||||
|
const legacyDir = join(cacheRoot, "node_modules", "oh-my-opencode")
|
||||||
|
mkdirSync(legacyDir, { recursive: true })
|
||||||
|
writeFileSync(join(legacyDir, "package.json"), JSON.stringify({ name: "oh-my-opencode", version: "3.16.0" }))
|
||||||
|
|
||||||
|
const aliasDir = join(cacheRoot, "node_modules", "oh-my-openagent")
|
||||||
|
mkdirSync(aliasDir, { recursive: true })
|
||||||
|
writeFileSync(join(aliasDir, "package.json"), JSON.stringify({ name: "oh-my-openagent", version: "3.15.0" }))
|
||||||
|
|
||||||
|
expect(getCachedVersion()).toBe("3.16.0")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns null when neither candidate exists and fallbacks find nothing", () => {
|
||||||
|
expect(getCachedVersion()).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3,18 +3,20 @@ import * as path from "node:path"
|
|||||||
import { fileURLToPath } from "node:url"
|
import { fileURLToPath } from "node:url"
|
||||||
import { log } from "../../../shared/logger"
|
import { log } from "../../../shared/logger"
|
||||||
import type { PackageJson } from "../types"
|
import type { PackageJson } from "../types"
|
||||||
import { INSTALLED_PACKAGE_JSON } from "../constants"
|
import { INSTALLED_PACKAGE_JSON_CANDIDATES } from "../constants"
|
||||||
import { findPackageJsonUp } from "./package-json-locator"
|
import { findPackageJsonUp } from "./package-json-locator"
|
||||||
|
|
||||||
export function getCachedVersion(): string | null {
|
export function getCachedVersion(): string | null {
|
||||||
try {
|
for (const candidate of INSTALLED_PACKAGE_JSON_CANDIDATES) {
|
||||||
if (fs.existsSync(INSTALLED_PACKAGE_JSON)) {
|
try {
|
||||||
const content = fs.readFileSync(INSTALLED_PACKAGE_JSON, "utf-8")
|
if (fs.existsSync(candidate)) {
|
||||||
const pkg = JSON.parse(content) as PackageJson
|
const content = fs.readFileSync(candidate, "utf-8")
|
||||||
if (pkg.version) return pkg.version
|
const pkg = JSON.parse(content) as PackageJson
|
||||||
|
if (pkg.version) return pkg.version
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore; try next candidate
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as fs from "node:fs"
|
import * as fs from "node:fs"
|
||||||
import { fileURLToPath } from "node:url"
|
import { fileURLToPath } from "node:url"
|
||||||
import type { OpencodeConfig } from "../types"
|
import type { OpencodeConfig } from "../types"
|
||||||
import { PACKAGE_NAME } from "../constants"
|
import { ACCEPTED_PACKAGE_NAMES } from "../constants"
|
||||||
import { getConfigPaths } from "./config-paths"
|
import { getConfigPaths } from "./config-paths"
|
||||||
import { stripJsonComments } from "./jsonc-strip"
|
import { stripJsonComments } from "./jsonc-strip"
|
||||||
|
|
||||||
@@ -18,12 +18,12 @@ export function getLocalDevPath(directory: string): string | null {
|
|||||||
const plugins = config.plugin ?? []
|
const plugins = config.plugin ?? []
|
||||||
|
|
||||||
for (const entry of plugins) {
|
for (const entry of plugins) {
|
||||||
if (entry.startsWith("file://") && entry.includes(PACKAGE_NAME)) {
|
if (!entry.startsWith("file://")) continue
|
||||||
try {
|
if (!ACCEPTED_PACKAGE_NAMES.some(name => entry.includes(name))) continue
|
||||||
return fileURLToPath(entry)
|
try {
|
||||||
} catch {
|
return fileURLToPath(entry)
|
||||||
return entry.replace("file://", "")
|
} catch {
|
||||||
}
|
return entry.replace("file://", "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import { findPackageJsonUp } from "./package-json-locator"
|
||||||
|
|
||||||
|
describe("findPackageJsonUp", () => {
|
||||||
|
let workdir: string
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
workdir = mkdtempSync(join(tmpdir(), "omo-pkg-locator-"))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(workdir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("finds a package.json whose name is the canonical oh-my-opencode", () => {
|
||||||
|
const pkgPath = join(workdir, "package.json")
|
||||||
|
writeFileSync(pkgPath, JSON.stringify({ name: "oh-my-opencode", version: "3.16.0" }))
|
||||||
|
|
||||||
|
const found = findPackageJsonUp(workdir)
|
||||||
|
|
||||||
|
expect(found).toBe(pkgPath)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("finds a package.json whose name is the aliased oh-my-openagent (GH-3257)", () => {
|
||||||
|
// A user who installed `oh-my-openagent` from npm gets a node_modules entry
|
||||||
|
// whose package.json has `name: "oh-my-openagent"`. The auto-update-checker
|
||||||
|
// must still resolve it so the startup toast shows a real version instead
|
||||||
|
// of "unknown".
|
||||||
|
const pkgPath = join(workdir, "package.json")
|
||||||
|
writeFileSync(pkgPath, JSON.stringify({ name: "oh-my-openagent", version: "3.16.0" }))
|
||||||
|
|
||||||
|
const found = findPackageJsonUp(workdir)
|
||||||
|
|
||||||
|
expect(found).toBe(pkgPath)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("walks up directories to find the matching package.json", () => {
|
||||||
|
const nested = join(workdir, "dist", "checker")
|
||||||
|
mkdirSync(nested, { recursive: true })
|
||||||
|
const pkgPath = join(workdir, "package.json")
|
||||||
|
writeFileSync(pkgPath, JSON.stringify({ name: "oh-my-openagent", version: "3.16.0" }))
|
||||||
|
|
||||||
|
const found = findPackageJsonUp(nested)
|
||||||
|
|
||||||
|
expect(found).toBe(pkgPath)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("ignores unrelated package.json files", () => {
|
||||||
|
const pkgPath = join(workdir, "package.json")
|
||||||
|
writeFileSync(pkgPath, JSON.stringify({ name: "some-other-package", version: "1.0.0" }))
|
||||||
|
|
||||||
|
const found = findPackageJsonUp(workdir)
|
||||||
|
|
||||||
|
expect(found).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns null when no package.json exists", () => {
|
||||||
|
const found = findPackageJsonUp(workdir)
|
||||||
|
|
||||||
|
expect(found).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import * as fs from "node:fs"
|
import * as fs from "node:fs"
|
||||||
import * as path from "node:path"
|
import * as path from "node:path"
|
||||||
import type { PackageJson } from "../types"
|
import type { PackageJson } from "../types"
|
||||||
import { PACKAGE_NAME } from "../constants"
|
import { ACCEPTED_PACKAGE_NAMES } from "../constants"
|
||||||
|
|
||||||
|
const ACCEPTED_NAME_SET = new Set<string>(ACCEPTED_PACKAGE_NAMES)
|
||||||
|
|
||||||
export function findPackageJsonUp(startPath: string): string | null {
|
export function findPackageJsonUp(startPath: string): string | null {
|
||||||
try {
|
try {
|
||||||
@@ -14,7 +16,7 @@ export function findPackageJsonUp(startPath: string): string | null {
|
|||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(pkgPath, "utf-8")
|
const content = fs.readFileSync(pkgPath, "utf-8")
|
||||||
const pkg = JSON.parse(content) as PackageJson
|
const pkg = JSON.parse(content) as PackageJson
|
||||||
if (pkg.name === PACKAGE_NAME) return pkgPath
|
if (pkg.name && ACCEPTED_NAME_SET.has(pkg.name)) return pkgPath
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as fs from "node:fs"
|
|||||||
import * as os from "node:os"
|
import * as os from "node:os"
|
||||||
import * as path from "node:path"
|
import * as path from "node:path"
|
||||||
import { PACKAGE_NAME } from "../constants"
|
import { PACKAGE_NAME } from "../constants"
|
||||||
|
import { LEGACY_PLUGIN_NAME, PLUGIN_NAME } from "../../../shared/plugin-identity"
|
||||||
|
|
||||||
type PluginEntryResult = {
|
type PluginEntryResult = {
|
||||||
entry: string
|
entry: string
|
||||||
@@ -120,6 +121,64 @@ describe("findPluginEntry", () => {
|
|||||||
expect(pluginInfo?.pinnedVersion).toBe("3.5.2")
|
expect(pluginInfo?.pinnedVersion).toBe("3.5.2")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("finds preferred plugin entry", async () => {
|
||||||
|
// #given preferred plugin entry is configured
|
||||||
|
fs.writeFileSync(configPath, JSON.stringify({ plugin: [PLUGIN_NAME] }))
|
||||||
|
|
||||||
|
// #when plugin entry is detected
|
||||||
|
const execution = runFindPluginEntry(temporaryDirectory)
|
||||||
|
|
||||||
|
// #then preferred entry is returned
|
||||||
|
expect(execution.status).toBe(0)
|
||||||
|
const pluginInfo = JSON.parse(execution.stdout.trim()) as PluginEntryResult
|
||||||
|
expect(pluginInfo?.entry).toBe(PLUGIN_NAME)
|
||||||
|
expect(pluginInfo?.isPinned).toBe(false)
|
||||||
|
expect(pluginInfo?.pinnedVersion).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("finds legacy plugin entry", async () => {
|
||||||
|
// #given legacy plugin entry is configured
|
||||||
|
fs.writeFileSync(configPath, JSON.stringify({ plugin: [LEGACY_PLUGIN_NAME] }))
|
||||||
|
|
||||||
|
// #when plugin entry is detected
|
||||||
|
const execution = runFindPluginEntry(temporaryDirectory)
|
||||||
|
|
||||||
|
// #then legacy entry is returned
|
||||||
|
expect(execution.status).toBe(0)
|
||||||
|
const pluginInfo = JSON.parse(execution.stdout.trim()) as PluginEntryResult
|
||||||
|
expect(pluginInfo?.entry).toBe(LEGACY_PLUGIN_NAME)
|
||||||
|
expect(pluginInfo?.isPinned).toBe(false)
|
||||||
|
expect(pluginInfo?.pinnedVersion).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("finds preferred plugin entry with pinned version", async () => {
|
||||||
|
// #given preferred plugin entry includes semver version
|
||||||
|
fs.writeFileSync(configPath, JSON.stringify({ plugin: [`${PLUGIN_NAME}@3.15.0`] }))
|
||||||
|
|
||||||
|
// #when plugin entry is detected
|
||||||
|
const execution = runFindPluginEntry(temporaryDirectory)
|
||||||
|
|
||||||
|
// #then preferred versioned entry is returned
|
||||||
|
expect(execution.status).toBe(0)
|
||||||
|
const pluginInfo = JSON.parse(execution.stdout.trim()) as PluginEntryResult
|
||||||
|
expect(pluginInfo?.entry).toBe(`${PLUGIN_NAME}@3.15.0`)
|
||||||
|
expect(pluginInfo?.isPinned).toBe(true)
|
||||||
|
expect(pluginInfo?.pinnedVersion).toBe("3.15.0")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns null for unrelated plugin entry", async () => {
|
||||||
|
// #given unrelated plugin entry is configured
|
||||||
|
fs.writeFileSync(configPath, JSON.stringify({ plugin: ["some-other-plugin"] }))
|
||||||
|
|
||||||
|
// #when plugin entry is detected
|
||||||
|
const execution = runFindPluginEntry(temporaryDirectory)
|
||||||
|
|
||||||
|
// #then no matching entry is returned
|
||||||
|
expect(execution.status).toBe(0)
|
||||||
|
const pluginInfo = JSON.parse(execution.stdout.trim()) as PluginEntryResult
|
||||||
|
expect(pluginInfo).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
test("reads user config from profile dir even when OPENCODE_CONFIG_DIR changes after import", async () => {
|
test("reads user config from profile dir even when OPENCODE_CONFIG_DIR changes after import", async () => {
|
||||||
// #given profile-specific user config after module import
|
// #given profile-specific user config after module import
|
||||||
const profileConfigDir = path.join(temporaryDirectory, "profiles", "today")
|
const profileConfigDir = path.join(temporaryDirectory, "profiles", "today")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { OpencodeConfig } from "../types"
|
|||||||
import { PACKAGE_NAME } from "../constants"
|
import { PACKAGE_NAME } from "../constants"
|
||||||
import { getConfigPaths } from "./config-paths"
|
import { getConfigPaths } from "./config-paths"
|
||||||
import { stripJsonComments } from "./jsonc-strip"
|
import { stripJsonComments } from "./jsonc-strip"
|
||||||
|
import { LEGACY_PLUGIN_NAME, PLUGIN_NAME } from "../../../shared/plugin-identity"
|
||||||
|
|
||||||
export interface PluginEntryInfo {
|
export interface PluginEntryInfo {
|
||||||
entry: string
|
entry: string
|
||||||
@@ -12,6 +13,7 @@ export interface PluginEntryInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const EXACT_SEMVER_REGEX = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/
|
const EXACT_SEMVER_REGEX = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/
|
||||||
|
const MATCH_PLUGIN_NAMES = [PACKAGE_NAME, PLUGIN_NAME, LEGACY_PLUGIN_NAME]
|
||||||
|
|
||||||
export function findPluginEntry(directory: string): PluginEntryInfo | null {
|
export function findPluginEntry(directory: string): PluginEntryInfo | null {
|
||||||
for (const configPath of getConfigPaths(directory)) {
|
for (const configPath of getConfigPaths(directory)) {
|
||||||
@@ -22,13 +24,15 @@ export function findPluginEntry(directory: string): PluginEntryInfo | null {
|
|||||||
const plugins = config.plugin ?? []
|
const plugins = config.plugin ?? []
|
||||||
|
|
||||||
for (const entry of plugins) {
|
for (const entry of plugins) {
|
||||||
if (entry === PACKAGE_NAME) {
|
for (const pluginName of MATCH_PLUGIN_NAMES) {
|
||||||
return { entry, isPinned: false, pinnedVersion: null, configPath }
|
if (entry === pluginName) {
|
||||||
}
|
return { entry, isPinned: false, pinnedVersion: null, configPath }
|
||||||
if (entry.startsWith(`${PACKAGE_NAME}@`)) {
|
}
|
||||||
const pinnedVersion = entry.slice(PACKAGE_NAME.length + 1)
|
if (entry.startsWith(`${pluginName}@`)) {
|
||||||
const isPinned = EXACT_SEMVER_REGEX.test(pinnedVersion.trim())
|
const pinnedVersion = entry.slice(pluginName.length + 1)
|
||||||
return { entry, isPinned, pinnedVersion, configPath }
|
const isPinned = EXACT_SEMVER_REGEX.test(pinnedVersion.trim())
|
||||||
|
return { entry, isPinned, pinnedVersion, configPath }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -26,4 +26,24 @@ describe("auto-update-checker constants", () => {
|
|||||||
// then PACKAGE_NAME equals the actually published package name
|
// then PACKAGE_NAME equals the actually published package name
|
||||||
expect(PACKAGE_NAME).toBe(repoPackageJson.name)
|
expect(PACKAGE_NAME).toBe(repoPackageJson.name)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("ACCEPTED_PACKAGE_NAMES contains both the canonical and aliased npm names (GH-3257)", async () => {
|
||||||
|
const { ACCEPTED_PACKAGE_NAMES } = await import(`./constants?test=${Date.now()}`)
|
||||||
|
|
||||||
|
expect(ACCEPTED_PACKAGE_NAMES).toContain("oh-my-opencode")
|
||||||
|
expect(ACCEPTED_PACKAGE_NAMES).toContain("oh-my-openagent")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("INSTALLED_PACKAGE_JSON_CANDIDATES covers every accepted package name (GH-3257)", async () => {
|
||||||
|
const { ACCEPTED_PACKAGE_NAMES, INSTALLED_PACKAGE_JSON_CANDIDATES, CACHE_DIR } = await import(
|
||||||
|
`./constants?test=${Date.now()}`
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(INSTALLED_PACKAGE_JSON_CANDIDATES).toHaveLength(ACCEPTED_PACKAGE_NAMES.length)
|
||||||
|
for (const name of ACCEPTED_PACKAGE_NAMES) {
|
||||||
|
expect(INSTALLED_PACKAGE_JSON_CANDIDATES).toContain(
|
||||||
|
join(CACHE_DIR, "node_modules", name, "package.json")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,16 @@ import { getOpenCodeCacheDir } from "../../shared/data-path"
|
|||||||
import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir"
|
import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir"
|
||||||
|
|
||||||
export const PACKAGE_NAME = "oh-my-opencode"
|
export const PACKAGE_NAME = "oh-my-opencode"
|
||||||
|
/**
|
||||||
|
* All package names the canonical plugin may be published under.
|
||||||
|
*
|
||||||
|
* The package is published to npm as both `oh-my-opencode` (legacy canonical)
|
||||||
|
* and `oh-my-openagent` (current canonical). Any code that *reads* an
|
||||||
|
* installed package.json or walks up from an import path must accept both,
|
||||||
|
* because the installed name depends on which package the user added to
|
||||||
|
* their config. Code that *writes* continues to use {@link PACKAGE_NAME}.
|
||||||
|
*/
|
||||||
|
export const ACCEPTED_PACKAGE_NAMES = ["oh-my-opencode", "oh-my-openagent"] as const
|
||||||
export const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`
|
export const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`
|
||||||
export const NPM_FETCH_TIMEOUT = 5000
|
export const NPM_FETCH_TIMEOUT = 5000
|
||||||
|
|
||||||
@@ -34,3 +44,11 @@ export const INSTALLED_PACKAGE_JSON = path.join(
|
|||||||
PACKAGE_NAME,
|
PACKAGE_NAME,
|
||||||
"package.json"
|
"package.json"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Candidate paths where the installed package.json may live, in priority order.
|
||||||
|
* Readers should try each path in order and stop on the first success.
|
||||||
|
*/
|
||||||
|
export const INSTALLED_PACKAGE_JSON_CANDIDATES = ACCEPTED_PACKAGE_NAMES.map(
|
||||||
|
name => path.join(CACHE_DIR, "node_modules", name, "package.json")
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { updateSessionAgent } from "../../features/claude-code-session-state"
|
import {
|
||||||
|
resolveRegisteredAgentName,
|
||||||
|
updateSessionAgent,
|
||||||
|
} from "../../features/claude-code-session-state"
|
||||||
import {
|
import {
|
||||||
getCompactionAgentConfigCheckpoint,
|
getCompactionAgentConfigCheckpoint,
|
||||||
} from "../../shared/compaction-agent-config-checkpoint"
|
} from "../../shared/compaction-agent-config-checkpoint"
|
||||||
@@ -66,6 +69,7 @@ export function createRecoveryLogic(
|
|||||||
checkpointWithAgent,
|
checkpointWithAgent,
|
||||||
currentPromptConfig,
|
currentPromptConfig,
|
||||||
)
|
)
|
||||||
|
const launchAgent = resolveRegisteredAgentName(expectedPromptConfig.agent)
|
||||||
const model = expectedPromptConfig.model
|
const model = expectedPromptConfig.model
|
||||||
const tools = expectedPromptConfig.tools
|
const tools = expectedPromptConfig.tools
|
||||||
|
|
||||||
@@ -81,7 +85,7 @@ export function createRecoveryLogic(
|
|||||||
path: { id: sessionID },
|
path: { id: sessionID },
|
||||||
body: {
|
body: {
|
||||||
noReply: true,
|
noReply: true,
|
||||||
agent: expectedPromptConfig.agent,
|
agent: launchAgent ?? expectedPromptConfig.agent,
|
||||||
...(model ? { model } : {}),
|
...(model ? { model } : {}),
|
||||||
...(tools ? { tools } : {}),
|
...(tools ? { tools } : {}),
|
||||||
parts: [createInternalAgentTextPart(AGENT_RECOVERY_PROMPT)],
|
parts: [createInternalAgentTextPart(AGENT_RECOVERY_PROMPT)],
|
||||||
|
|||||||
@@ -86,6 +86,44 @@ describe("keyword-detector ralph-loop activation", () => {
|
|||||||
expect(startLoopCalls[0].options.ultrawork).toBe(true)
|
expect(startLoopCalls[0].options.ultrawork).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("#given ulw mentioned mid-sentence #when chat.message fires #then ralph-loop startLoop is not invoked", async () => {
|
||||||
|
// given
|
||||||
|
setMainSession("main-session")
|
||||||
|
const startLoopCalls: StartLoopCall[] = []
|
||||||
|
const ralphLoop = createMockRalphLoop(startLoopCalls)
|
||||||
|
const hook = createKeywordDetectorHook(createMockPluginInput(), undefined, ralphLoop)
|
||||||
|
const output = {
|
||||||
|
message: {} as Record<string, unknown>,
|
||||||
|
parts: [{ type: "text", text: "I think ulw is cool" }],
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
await hook["chat.message"]({ sessionID: "main-session", agent: "sisyphus" }, output)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(startLoopCalls).toHaveLength(0)
|
||||||
|
expect(output.parts[0]?.text).toBe("I think ulw is cool")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given question about ultrawork #when chat.message fires #then ralph-loop startLoop is not invoked", async () => {
|
||||||
|
// given
|
||||||
|
setMainSession("main-session")
|
||||||
|
const startLoopCalls: StartLoopCall[] = []
|
||||||
|
const ralphLoop = createMockRalphLoop(startLoopCalls)
|
||||||
|
const hook = createKeywordDetectorHook(createMockPluginInput(), undefined, ralphLoop)
|
||||||
|
const output = {
|
||||||
|
message: {} as Record<string, unknown>,
|
||||||
|
parts: [{ type: "text", text: "what is ultrawork?" }],
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
await hook["chat.message"]({ sessionID: "main-session", agent: "sisyphus" }, output)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(startLoopCalls).toHaveLength(0)
|
||||||
|
expect(output.parts[0]?.text).toBe("what is ultrawork?")
|
||||||
|
})
|
||||||
|
|
||||||
test("#given non-ulw message #when chat.message fires #then ralph-loop startLoop is not invoked", async () => {
|
test("#given non-ulw message #when chat.message fires #then ralph-loop startLoop is not invoked", async () => {
|
||||||
// given
|
// given
|
||||||
setMainSession("main-session")
|
setMainSession("main-session")
|
||||||
|
|||||||
@@ -16,11 +16,16 @@ import type { RalphLoopHook } from "../ralph-loop"
|
|||||||
import { parseRalphLoopArguments } from "../ralph-loop/command-arguments"
|
import { parseRalphLoopArguments } from "../ralph-loop/command-arguments"
|
||||||
|
|
||||||
const ULTRAWORK_KEYWORD_PATTERN = /\b(ultrawork|ulw)\b/i
|
const ULTRAWORK_KEYWORD_PATTERN = /\b(ultrawork|ulw)\b/i
|
||||||
|
const LEADING_ULTRAWORK_PATTERN = /^\s*(ultrawork|ulw)\b/i
|
||||||
|
|
||||||
function extractUltraworkTask(cleanText: string): string {
|
function extractUltraworkTask(cleanText: string): string {
|
||||||
return cleanText.replace(ULTRAWORK_KEYWORD_PATTERN, "").trim()
|
return cleanText.replace(ULTRAWORK_KEYWORD_PATTERN, "").trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasLeadingUltraworkKeyword(cleanText: string): boolean {
|
||||||
|
return LEADING_ULTRAWORK_PATTERN.test(cleanText)
|
||||||
|
}
|
||||||
|
|
||||||
export function createKeywordDetectorHook(
|
export function createKeywordDetectorHook(
|
||||||
ctx: PluginInput,
|
ctx: PluginInput,
|
||||||
_collector?: ContextCollector,
|
_collector?: ContextCollector,
|
||||||
@@ -76,6 +81,16 @@ export function createKeywordDetectorHook(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!hasLeadingUltraworkKeyword(cleanText)) {
|
||||||
|
const preFilterCount = detectedKeywords.length
|
||||||
|
detectedKeywords = detectedKeywords.filter((k) => k.type !== "ultrawork")
|
||||||
|
if (preFilterCount > detectedKeywords.length) {
|
||||||
|
log(`[keyword-detector] Filtered non-leading ultrawork keyword`, {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (detectedKeywords.length === 0) {
|
if (detectedKeywords.length === 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import { isGptModel } from "../../agents/types"
|
import { isGptModel } from "../../agents/types"
|
||||||
import { getSessionAgent, updateSessionAgent } from "../../features/claude-code-session-state"
|
import {
|
||||||
|
getSessionAgent,
|
||||||
|
resolveRegisteredAgentName,
|
||||||
|
updateSessionAgent,
|
||||||
|
} from "../../features/claude-code-session-state"
|
||||||
import { log } from "../../shared"
|
import { log } from "../../shared"
|
||||||
import { getAgentConfigKey, getAgentDisplayName } from "../../shared/agent-display-names"
|
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
||||||
|
|
||||||
const TOAST_TITLE = "NEVER Use Hephaestus with Non-GPT"
|
const TOAST_TITLE = "NEVER Use Hephaestus with Non-GPT"
|
||||||
const TOAST_MESSAGE = [
|
const TOAST_MESSAGE = [
|
||||||
@@ -10,8 +14,6 @@ const TOAST_MESSAGE = [
|
|||||||
"Hephaestus is trash without GPT.",
|
"Hephaestus is trash without GPT.",
|
||||||
"For Claude/Kimi/GLM models, always use Sisyphus.",
|
"For Claude/Kimi/GLM models, always use Sisyphus.",
|
||||||
].join("\n")
|
].join("\n")
|
||||||
const SISYPHUS_DISPLAY = getAgentDisplayName("sisyphus")
|
|
||||||
|
|
||||||
type NoHephaestusNonGptHookOptions = {
|
type NoHephaestusNonGptHookOptions = {
|
||||||
allowNonGptModel?: boolean
|
allowNonGptModel?: boolean
|
||||||
}
|
}
|
||||||
@@ -54,9 +56,9 @@ export function createNoHephaestusNonGptHook(
|
|||||||
if (allowNonGptModel) {
|
if (allowNonGptModel) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
input.agent = "sisyphus"
|
input.agent = resolveRegisteredAgentName("sisyphus") ?? "sisyphus"
|
||||||
if (output?.message) {
|
if (output?.message) {
|
||||||
output.message.agent = "sisyphus"
|
output.message.agent = resolveRegisteredAgentName("sisyphus") ?? "sisyphus"
|
||||||
}
|
}
|
||||||
updateSessionAgent(input.sessionID, "sisyphus")
|
updateSessionAgent(input.sessionID, "sisyphus")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import { isGptModel, isGpt5_4Model } from "../../agents/types"
|
import { isGptModel, isGpt5_4Model } from "../../agents/types"
|
||||||
import { getSessionAgent, updateSessionAgent } from "../../features/claude-code-session-state"
|
import {
|
||||||
|
getSessionAgent,
|
||||||
|
resolveRegisteredAgentName,
|
||||||
|
updateSessionAgent,
|
||||||
|
} from "../../features/claude-code-session-state"
|
||||||
import { log } from "../../shared"
|
import { log } from "../../shared"
|
||||||
import { getAgentConfigKey, getAgentDisplayName } from "../../shared/agent-display-names"
|
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
||||||
|
|
||||||
const TOAST_TITLE = "NEVER Use Sisyphus with GPT"
|
const TOAST_TITLE = "NEVER Use Sisyphus with GPT"
|
||||||
const TOAST_MESSAGE = [
|
const TOAST_MESSAGE = [
|
||||||
@@ -10,8 +14,6 @@ const TOAST_MESSAGE = [
|
|||||||
"Do NOT use Sisyphus with GPT (except GPT-5.4 which has specialized support).",
|
"Do NOT use Sisyphus with GPT (except GPT-5.4 which has specialized support).",
|
||||||
"For GPT models (other than 5.4), always use Hephaestus.",
|
"For GPT models (other than 5.4), always use Hephaestus.",
|
||||||
].join("\n")
|
].join("\n")
|
||||||
const HEPHAESTUS_DISPLAY = getAgentDisplayName("hephaestus")
|
|
||||||
|
|
||||||
function showToast(ctx: PluginInput, sessionID: string): void {
|
function showToast(ctx: PluginInput, sessionID: string): void {
|
||||||
ctx.client.tui.showToast({
|
ctx.client.tui.showToast({
|
||||||
body: {
|
body: {
|
||||||
@@ -43,9 +45,9 @@ export function createNoSisyphusGptHook(ctx: PluginInput) {
|
|||||||
|
|
||||||
if (agentKey === "sisyphus" && modelID && isGptModel(modelID) && !isGpt5_4Model(modelID)) {
|
if (agentKey === "sisyphus" && modelID && isGptModel(modelID) && !isGpt5_4Model(modelID)) {
|
||||||
showToast(ctx, input.sessionID)
|
showToast(ctx, input.sessionID)
|
||||||
input.agent = "hephaestus"
|
input.agent = resolveRegisteredAgentName("hephaestus") ?? "hephaestus"
|
||||||
if (output?.message) {
|
if (output?.message) {
|
||||||
output.message.agent = "hephaestus"
|
output.message.agent = resolveRegisteredAgentName("hephaestus") ?? "hephaestus"
|
||||||
}
|
}
|
||||||
updateSessionAgent(input.sessionID, "hephaestus")
|
updateSessionAgent(input.sessionID, "hephaestus")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,6 @@ export function createPostCompactionDegradationMonitor(args: {
|
|||||||
postCompactionNoTextStreak.delete(sessionID)
|
postCompactionNoTextStreak.delete(sessionID)
|
||||||
postCompactionRecoveryTriggered.delete(sessionID)
|
postCompactionRecoveryTriggered.delete(sessionID)
|
||||||
postCompactionEpoch.delete(sessionID)
|
postCompactionEpoch.delete(sessionID)
|
||||||
postCompactionRecoveryCount.delete(sessionID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onSessionCompacted = (sessionID: string): void => {
|
const onSessionCompacted = (sessionID: string): void => {
|
||||||
|
|||||||
@@ -284,10 +284,57 @@ describe("preemptive-compaction", () => {
|
|||||||
//#then
|
//#then
|
||||||
expect(logMock).toHaveBeenCalledWith("[preemptive-compaction] Compaction failed", {
|
expect(logMock).toHaveBeenCalledWith("[preemptive-compaction] Compaction failed", {
|
||||||
sessionID,
|
sessionID,
|
||||||
|
providerID: "anthropic",
|
||||||
|
modelID: "claude-sonnet-4-6",
|
||||||
error: String(summarizeError),
|
error: String(summarizeError),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// #given compaction fails
|
||||||
|
// #when tool.execute.after completes the catch block
|
||||||
|
// #then should show a warning toast explaining the failure to the user
|
||||||
|
it("should show a warning toast when preemptive compaction fails", async () => {
|
||||||
|
//#given
|
||||||
|
const hook = createPreemptiveCompactionHook(ctx as never, {} as never)
|
||||||
|
const sessionID = "ses_toast_on_failure"
|
||||||
|
const summarizeError = new Error("upstream rate limited")
|
||||||
|
ctx.client.session.summarize.mockRejectedValueOnce(summarizeError)
|
||||||
|
|
||||||
|
await hook.event({
|
||||||
|
event: {
|
||||||
|
type: "message.updated",
|
||||||
|
properties: {
|
||||||
|
info: {
|
||||||
|
role: "assistant",
|
||||||
|
sessionID,
|
||||||
|
providerID: "anthropic",
|
||||||
|
modelID: "claude-sonnet-4-6",
|
||||||
|
finish: true,
|
||||||
|
tokens: {
|
||||||
|
input: 170000,
|
||||||
|
output: 0,
|
||||||
|
reasoning: 0,
|
||||||
|
cache: { read: 10000, write: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await hook["tool.execute.after"](
|
||||||
|
{ tool: "bash", sessionID, callID: "call_toast" },
|
||||||
|
{ title: "", output: "test", metadata: null },
|
||||||
|
)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(ctx.client.tui.showToast).toHaveBeenCalledTimes(1)
|
||||||
|
const toastCall = ctx.client.tui.showToast.mock.calls[0]?.[0]
|
||||||
|
expect(toastCall?.body?.title).toBe("Preemptive compaction failed")
|
||||||
|
expect(toastCall?.body?.variant).toBe("warning")
|
||||||
|
expect(String(toastCall?.body?.message)).toContain("upstream rate limited")
|
||||||
|
})
|
||||||
|
|
||||||
// #given compaction fails
|
// #given compaction fails
|
||||||
// #when tool.execute.after is called again immediately
|
// #when tool.execute.after is called again immediately
|
||||||
// #then should NOT retry due to cooldown
|
// #then should NOT retry due to cooldown
|
||||||
@@ -475,6 +522,8 @@ describe("preemptive-compaction", () => {
|
|||||||
expect(ctx.client.session.summarize).toHaveBeenCalledTimes(1)
|
expect(ctx.client.session.summarize).toHaveBeenCalledTimes(1)
|
||||||
expect(logMock).toHaveBeenCalledWith("[preemptive-compaction] Compaction failed", {
|
expect(logMock).toHaveBeenCalledWith("[preemptive-compaction] Compaction failed", {
|
||||||
sessionID,
|
sessionID,
|
||||||
|
providerID: "anthropic",
|
||||||
|
modelID: "claude-sonnet-4-6",
|
||||||
error: expect.stringContaining("Compaction summarize timed out"),
|
error: expect.stringContaining("Compaction summarize timed out"),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
import { resolveCompactionModel } from "./shared/compaction-model-resolver"
|
import { resolveCompactionModel } from "./shared/compaction-model-resolver"
|
||||||
import { createPostCompactionDegradationMonitor } from "./preemptive-compaction-degradation-monitor"
|
import { createPostCompactionDegradationMonitor } from "./preemptive-compaction-degradation-monitor"
|
||||||
|
|
||||||
const PREEMPTIVE_COMPACTION_TIMEOUT_MS = 120_000
|
const PREEMPTIVE_COMPACTION_TIMEOUT_MS = 60_000
|
||||||
const PREEMPTIVE_COMPACTION_THRESHOLD = 0.78
|
const PREEMPTIVE_COMPACTION_THRESHOLD = 0.78
|
||||||
const PREEMPTIVE_COMPACTION_COOLDOWN_MS = 60_000
|
const PREEMPTIVE_COMPACTION_COOLDOWN_MS = 60_000
|
||||||
|
|
||||||
@@ -134,7 +134,25 @@ export function createPreemptiveCompactionHook(
|
|||||||
|
|
||||||
compactedSessions.add(sessionID)
|
compactedSessions.add(sessionID)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log("[preemptive-compaction] Compaction failed", { sessionID, error: String(error) })
|
log("[preemptive-compaction] Compaction failed", {
|
||||||
|
sessionID,
|
||||||
|
providerID: cached.providerID,
|
||||||
|
modelID: cached.modelID,
|
||||||
|
error: String(error),
|
||||||
|
})
|
||||||
|
ctx.client.tui.showToast({
|
||||||
|
body: {
|
||||||
|
title: "Preemptive compaction failed",
|
||||||
|
message: `Context window is above ${Math.round(PREEMPTIVE_COMPACTION_THRESHOLD * 100)}% and auto-compaction could not run. The session may grow large. Error: ${String(error)}`,
|
||||||
|
variant: "warning",
|
||||||
|
duration: 10000,
|
||||||
|
},
|
||||||
|
}).catch((toastError: unknown) => {
|
||||||
|
log("[preemptive-compaction] Failed to show toast", {
|
||||||
|
sessionID,
|
||||||
|
toastError: String(toastError),
|
||||||
|
})
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
compactionInProgress.delete(sessionID)
|
compactionInProgress.delete(sessionID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from "node:fs"
|
|||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
import { HOOK_NAME } from "./constants"
|
import { HOOK_NAME } from "./constants"
|
||||||
import { ULTRAWORK_VERIFICATION_PROMISE } from "./constants"
|
import { ULTRAWORK_VERIFICATION_PROMISE } from "./constants"
|
||||||
|
import { isOracleVerified } from "./oracle-verification-detector"
|
||||||
import { withTimeout } from "./with-timeout"
|
import { withTimeout } from "./with-timeout"
|
||||||
|
|
||||||
interface OpenCodeSessionMessage {
|
interface OpenCodeSessionMessage {
|
||||||
@@ -17,8 +18,6 @@ interface TranscriptEntry {
|
|||||||
tool_output?: { output?: string } | string
|
tool_output?: { output?: string } | string
|
||||||
}
|
}
|
||||||
|
|
||||||
const ORACLE_AGENT_PATTERN = /Agent:\s*oracle/i
|
|
||||||
|
|
||||||
function extractTranscriptEntryText(entry: TranscriptEntry): string {
|
function extractTranscriptEntryText(entry: TranscriptEntry): string {
|
||||||
if (typeof entry.content === "string") return entry.content
|
if (typeof entry.content === "string") return entry.content
|
||||||
if (typeof entry.tool_output === "string") return entry.tool_output
|
if (typeof entry.tool_output === "string") return entry.tool_output
|
||||||
@@ -47,7 +46,7 @@ function shouldInspectSessionMessagePart(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return promise === ULTRAWORK_VERIFICATION_PROMISE && ORACLE_AGENT_PATTERN.test(partText)
|
return promise === ULTRAWORK_VERIFICATION_PROMISE && isOracleVerified(partText)
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldInspectTranscriptEntry(
|
function shouldInspectTranscriptEntry(
|
||||||
@@ -63,7 +62,7 @@ function shouldInspectTranscriptEntry(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return promise === ULTRAWORK_VERIFICATION_PROMISE && ORACLE_AGENT_PATTERN.test(entryText)
|
return promise === ULTRAWORK_VERIFICATION_PROMISE && isOracleVerified(entryText)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function detectCompletionInTranscript(
|
export function detectCompletionInTranscript(
|
||||||
|
|||||||
@@ -2,5 +2,6 @@ export const HOOK_NAME = "ralph-loop"
|
|||||||
export const DEFAULT_STATE_FILE = ".sisyphus/ralph-loop.local.md"
|
export const DEFAULT_STATE_FILE = ".sisyphus/ralph-loop.local.md"
|
||||||
export const COMPLETION_TAG_PATTERN = /<promise>(.*?)<\/promise>/is
|
export const COMPLETION_TAG_PATTERN = /<promise>(.*?)<\/promise>/is
|
||||||
export const DEFAULT_MAX_ITERATIONS = 100
|
export const DEFAULT_MAX_ITERATIONS = 100
|
||||||
|
export const ULTRAWORK_MAX_ITERATIONS = 500
|
||||||
export const DEFAULT_COMPLETION_PROMISE = "DONE"
|
export const DEFAULT_COMPLETION_PROMISE = "DONE"
|
||||||
export const ULTRAWORK_VERIFICATION_PROMISE = "VERIFIED"
|
export const ULTRAWORK_VERIFICATION_PROMISE = "VERIFIED"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
DEFAULT_COMPLETION_PROMISE,
|
DEFAULT_COMPLETION_PROMISE,
|
||||||
DEFAULT_MAX_ITERATIONS,
|
DEFAULT_MAX_ITERATIONS,
|
||||||
HOOK_NAME,
|
HOOK_NAME,
|
||||||
|
ULTRAWORK_MAX_ITERATIONS,
|
||||||
ULTRAWORK_VERIFICATION_PROMISE,
|
ULTRAWORK_VERIFICATION_PROMISE,
|
||||||
} from "./constants"
|
} from "./constants"
|
||||||
import { clearState, incrementIteration, readState, writeState } from "./storage"
|
import { clearState, incrementIteration, readState, writeState } from "./storage"
|
||||||
@@ -36,7 +37,7 @@ export function createLoopStateController(options: {
|
|||||||
active: true,
|
active: true,
|
||||||
iteration: 1,
|
iteration: 1,
|
||||||
max_iterations: loopOptions?.ultrawork
|
max_iterations: loopOptions?.ultrawork
|
||||||
? undefined
|
? ULTRAWORK_MAX_ITERATIONS
|
||||||
: loopOptions?.maxIterations ??
|
: loopOptions?.maxIterations ??
|
||||||
config?.default_max_iterations ??
|
config?.default_max_iterations ??
|
||||||
DEFAULT_MAX_ITERATIONS,
|
DEFAULT_MAX_ITERATIONS,
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import {
|
||||||
|
extractOracleSessionID,
|
||||||
|
isOracleVerified,
|
||||||
|
parseOracleVerificationEvidence,
|
||||||
|
} from "./oracle-verification-detector"
|
||||||
|
import { ULTRAWORK_VERIFICATION_PROMISE } from "./constants"
|
||||||
|
|
||||||
|
describe("parseOracleVerificationEvidence", () => {
|
||||||
|
test("#given valid oracle verification text #then should parse all fields", () => {
|
||||||
|
// #given
|
||||||
|
const text = `Task completed.
|
||||||
|
|
||||||
|
Agent: oracle
|
||||||
|
|
||||||
|
<promise>VERIFIED</promise>
|
||||||
|
|
||||||
|
<task_metadata>
|
||||||
|
session_id: ses_oracle_123
|
||||||
|
</task_metadata>`
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const evidence = parseOracleVerificationEvidence(text)
|
||||||
|
|
||||||
|
// #then
|
||||||
|
expect(evidence).toBeDefined()
|
||||||
|
expect(evidence?.agent).toBe("oracle")
|
||||||
|
expect(evidence?.promise).toBe("VERIFIED")
|
||||||
|
expect(evidence?.sessionID).toBe("ses_oracle_123")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given text without agent line #then should return undefined", () => {
|
||||||
|
// #given
|
||||||
|
const text = `<promise>VERIFIED</promise>`
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const evidence = parseOracleVerificationEvidence(text)
|
||||||
|
|
||||||
|
// #then
|
||||||
|
expect(evidence).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given text without promise tag #then should return undefined", () => {
|
||||||
|
// #given
|
||||||
|
const text = `Agent: oracle`
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const evidence = parseOracleVerificationEvidence(text)
|
||||||
|
|
||||||
|
// #then
|
||||||
|
expect(evidence).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given text with empty agent #then should return undefined", () => {
|
||||||
|
// #given
|
||||||
|
const text = `Agent:
|
||||||
|
|
||||||
|
<promise>VERIFIED</promise>`
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const evidence = parseOracleVerificationEvidence(text)
|
||||||
|
|
||||||
|
// #then
|
||||||
|
expect(evidence).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given text with empty promise #then should return undefined", () => {
|
||||||
|
// #given
|
||||||
|
const text = `Agent: oracle
|
||||||
|
|
||||||
|
<promise> </promise>`
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const evidence = parseOracleVerificationEvidence(text)
|
||||||
|
|
||||||
|
// #then
|
||||||
|
expect(evidence).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given text without metadata #then should parse agent and promise only", () => {
|
||||||
|
// #given
|
||||||
|
const text = `Agent: oracle
|
||||||
|
|
||||||
|
<promise>VERIFIED</promise>`
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const evidence = parseOracleVerificationEvidence(text)
|
||||||
|
|
||||||
|
// #then
|
||||||
|
expect(evidence).toBeDefined()
|
||||||
|
expect(evidence?.agent).toBe("oracle")
|
||||||
|
expect(evidence?.promise).toBe("VERIFIED")
|
||||||
|
expect(evidence?.sessionID).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given text with metadata but no session_id #then should parse agent and promise only", () => {
|
||||||
|
// #given
|
||||||
|
const text = `Agent: oracle
|
||||||
|
|
||||||
|
<promise>VERIFIED</promise>
|
||||||
|
|
||||||
|
<task_metadata>
|
||||||
|
other_field: value
|
||||||
|
</task_metadata>`
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const evidence = parseOracleVerificationEvidence(text)
|
||||||
|
|
||||||
|
// #then
|
||||||
|
expect(evidence).toBeDefined()
|
||||||
|
expect(evidence?.agent).toBe("oracle")
|
||||||
|
expect(evidence?.promise).toBe("VERIFIED")
|
||||||
|
expect(evidence?.sessionID).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given empty text #then should return undefined", () => {
|
||||||
|
// #given
|
||||||
|
const text = ""
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const evidence = parseOracleVerificationEvidence(text)
|
||||||
|
|
||||||
|
// #then
|
||||||
|
expect(evidence).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given whitespace-only text #then should return undefined", () => {
|
||||||
|
// #given
|
||||||
|
const text = " \n\t "
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const evidence = parseOracleVerificationEvidence(text)
|
||||||
|
|
||||||
|
// #then
|
||||||
|
expect(evidence).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given agent with different casing #then should preserve original case", () => {
|
||||||
|
// #given
|
||||||
|
const text = `Agent: ORACLE
|
||||||
|
|
||||||
|
<promise>VERIFIED</promise>`
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const evidence = parseOracleVerificationEvidence(text)
|
||||||
|
|
||||||
|
// #then
|
||||||
|
expect(evidence).toBeDefined()
|
||||||
|
expect(evidence?.agent).toBe("ORACLE")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("isOracleVerified", () => {
|
||||||
|
test("#given valid oracle verification #then should return true", () => {
|
||||||
|
// #given
|
||||||
|
const text = `Agent: oracle
|
||||||
|
|
||||||
|
<promise>${ULTRAWORK_VERIFICATION_PROMISE}</promise>`
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const result = isOracleVerified(text)
|
||||||
|
|
||||||
|
// #then
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given non-oracle agent #then should return false", () => {
|
||||||
|
// #given
|
||||||
|
const text = `Agent: sisyphus
|
||||||
|
|
||||||
|
<promise>${ULTRAWORK_VERIFICATION_PROMISE}</promise>`
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const result = isOracleVerified(text)
|
||||||
|
|
||||||
|
// #then
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given wrong promise #then should return false", () => {
|
||||||
|
// #given
|
||||||
|
const text = `Agent: oracle
|
||||||
|
|
||||||
|
<promise>DONE</promise>`
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const result = isOracleVerified(text)
|
||||||
|
|
||||||
|
// #then
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given oracle agent with different casing #then should return true", () => {
|
||||||
|
// #given
|
||||||
|
const text = `Agent: ORACLE
|
||||||
|
|
||||||
|
<promise>${ULTRAWORK_VERIFICATION_PROMISE}</promise>`
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const result = isOracleVerified(text)
|
||||||
|
|
||||||
|
// #then
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given empty text #then should return false", () => {
|
||||||
|
// #given
|
||||||
|
const text = ""
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const result = isOracleVerified(text)
|
||||||
|
|
||||||
|
// #then
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("extractOracleSessionID", () => {
|
||||||
|
test("#given valid oracle verification with session_id #then should return session_id", () => {
|
||||||
|
// #given
|
||||||
|
const text = `Agent: oracle
|
||||||
|
|
||||||
|
<promise>${ULTRAWORK_VERIFICATION_PROMISE}</promise>
|
||||||
|
|
||||||
|
<task_metadata>
|
||||||
|
session_id: ses_oracle_123
|
||||||
|
</task_metadata>`
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const sessionID = extractOracleSessionID(text)
|
||||||
|
|
||||||
|
// #then
|
||||||
|
expect(sessionID).toBe("ses_oracle_123")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given valid oracle verification without session_id #then should return undefined", () => {
|
||||||
|
// #given
|
||||||
|
const text = `Agent: oracle
|
||||||
|
|
||||||
|
<promise>${ULTRAWORK_VERIFICATION_PROMISE}</promise>`
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const sessionID = extractOracleSessionID(text)
|
||||||
|
|
||||||
|
// #then
|
||||||
|
expect(sessionID).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given non-oracle agent #then should return undefined", () => {
|
||||||
|
// #given
|
||||||
|
const text = `Agent: sisyphus
|
||||||
|
|
||||||
|
<promise>${ULTRAWORK_VERIFICATION_PROMISE}</promise>
|
||||||
|
|
||||||
|
<task_metadata>
|
||||||
|
session_id: ses_sis_123
|
||||||
|
</task_metadata>`
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const sessionID = extractOracleSessionID(text)
|
||||||
|
|
||||||
|
// #then
|
||||||
|
expect(sessionID).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given non-oracle agent with different casing #then should return undefined", () => {
|
||||||
|
// #given
|
||||||
|
const text = `Agent: SISYPHUS
|
||||||
|
|
||||||
|
<promise>${ULTRAWORK_VERIFICATION_PROMISE}</promise>
|
||||||
|
|
||||||
|
<task_metadata>
|
||||||
|
session_id: ses_sis_123
|
||||||
|
</task_metadata>`
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const sessionID = extractOracleSessionID(text)
|
||||||
|
|
||||||
|
// #then
|
||||||
|
expect(sessionID).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given empty text #then should return undefined", () => {
|
||||||
|
// #given
|
||||||
|
const text = ""
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const sessionID = extractOracleSessionID(text)
|
||||||
|
|
||||||
|
// #then
|
||||||
|
expect(sessionID).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { ULTRAWORK_VERIFICATION_PROMISE } from "./constants"
|
||||||
|
|
||||||
|
export interface OracleVerificationEvidence {
|
||||||
|
agent: string
|
||||||
|
promise: string
|
||||||
|
sessionID?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const AGENT_LINE_PATTERN = /^Agent:[ \t]*(\S+)$/im
|
||||||
|
const PROMISE_TAG_PATTERN = /<promise>[ \t]*(\S+?)[ \t]*<\/promise>/is
|
||||||
|
const TASK_METADATA_PATTERN = /<task_metadata>[ \t]*([\s\S]*?)[ \t]*<\/task_metadata>/is
|
||||||
|
const SESSION_ID_LINE_PATTERN = /^session_id:[ \t]*(\S+)$/im
|
||||||
|
|
||||||
|
export function parseOracleVerificationEvidence(text: string): OracleVerificationEvidence | undefined {
|
||||||
|
const trimmedText = text.trim()
|
||||||
|
if (!trimmedText) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentMatch = trimmedText.match(AGENT_LINE_PATTERN)
|
||||||
|
if (!agentMatch) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const agent = agentMatch[1]?.trim()
|
||||||
|
if (!agent) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const promiseMatch = trimmedText.match(PROMISE_TAG_PATTERN)
|
||||||
|
if (!promiseMatch) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const promise = promiseMatch[1]?.trim()
|
||||||
|
if (!promise) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadataMatch = trimmedText.match(TASK_METADATA_PATTERN)
|
||||||
|
let sessionID: string | undefined
|
||||||
|
if (metadataMatch) {
|
||||||
|
const metadataContent = metadataMatch[1]
|
||||||
|
const sessionIDMatch = metadataContent.match(SESSION_ID_LINE_PATTERN)
|
||||||
|
if (sessionIDMatch) {
|
||||||
|
sessionID = sessionIDMatch[1]?.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { agent, promise, sessionID }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOracleVerified(text: string): boolean {
|
||||||
|
const evidence = parseOracleVerificationEvidence(text)
|
||||||
|
if (!evidence) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOracleAgent = evidence.agent.toLowerCase() === "oracle"
|
||||||
|
const isVerifiedPromise = evidence.promise === ULTRAWORK_VERIFICATION_PROMISE
|
||||||
|
|
||||||
|
return isOracleAgent && isVerifiedPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractOracleSessionID(text: string): string | undefined {
|
||||||
|
const evidence = parseOracleVerificationEvidence(text)
|
||||||
|
if (!evidence || evidence.agent.toLowerCase() !== "oracle") {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return evidence.sessionID
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
import { HOOK_NAME } from "./constants"
|
import { HOOK_NAME } from "./constants"
|
||||||
import { ULTRAWORK_VERIFICATION_PROMISE } from "./constants"
|
import { extractOracleSessionID, isOracleVerified } from "./oracle-verification-detector"
|
||||||
import type { RalphLoopState } from "./types"
|
import type { RalphLoopState } from "./types"
|
||||||
import { handleFailedVerification } from "./verification-failure-handler"
|
import { handleFailedVerification } from "./verification-failure-handler"
|
||||||
import { withTimeout } from "./with-timeout"
|
import { withTimeout } from "./with-timeout"
|
||||||
@@ -11,13 +11,6 @@ type OpenCodeSessionMessage = {
|
|||||||
parts?: Array<{ type?: string; text?: string }>
|
parts?: Array<{ type?: string; text?: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
const ORACLE_AGENT_PATTERN = /Agent:\s*oracle/i
|
|
||||||
const TASK_METADATA_SESSION_PATTERN = /<task_metadata>[\s\S]*?session_id:\s*([^\s<]+)[\s\S]*?<\/task_metadata>/i
|
|
||||||
const VERIFIED_PROMISE_PATTERN = new RegExp(
|
|
||||||
`<promise>\\s*${ULTRAWORK_VERIFICATION_PROMISE}\\s*<\\/promise>`,
|
|
||||||
"i",
|
|
||||||
)
|
|
||||||
|
|
||||||
function collectAssistantText(message: OpenCodeSessionMessage): string {
|
function collectAssistantText(message: OpenCodeSessionMessage): string {
|
||||||
if (!Array.isArray(message.parts)) {
|
if (!Array.isArray(message.parts)) {
|
||||||
return ""
|
return ""
|
||||||
@@ -67,12 +60,11 @@ async function detectOracleVerificationFromParentSession(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const assistantText = collectAssistantText(message)
|
const assistantText = collectAssistantText(message)
|
||||||
if (!VERIFIED_PROMISE_PATTERN.test(assistantText) || !ORACLE_AGENT_PATTERN.test(assistantText)) {
|
if (!isOracleVerified(assistantText)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionMatch = assistantText.match(TASK_METADATA_SESSION_PATTERN)
|
const detectedOracleSessionID = extractOracleSessionID(assistantText)
|
||||||
const detectedOracleSessionID = sessionMatch?.[1]?.trim()
|
|
||||||
if (detectedOracleSessionID) {
|
if (detectedOracleSessionID) {
|
||||||
return detectedOracleSessionID
|
return detectedOracleSessionID
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -279,8 +279,8 @@ describe("ulw-loop verification", () => {
|
|||||||
await hook.event({ event: { type: "session.idle", properties: { sessionID: "session-123" } } })
|
await hook.event({ event: { type: "session.idle", properties: { sessionID: "session-123" } } })
|
||||||
|
|
||||||
expect(hook.getState()?.iteration).toBe(2)
|
expect(hook.getState()?.iteration).toBe(2)
|
||||||
expect(hook.getState()?.max_iterations).toBeUndefined()
|
expect(hook.getState()?.max_iterations).toBe(500)
|
||||||
expect(promptCalls[0].text).toContain("2/unbounded")
|
expect(promptCalls[0].text).toContain("2/500")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("#given prior transcript completion from older run #when new ulw loop starts #then old completion is ignored", async () => {
|
test("#given prior transcript completion from older run #when new ulw loop starts #then old completion is ignored", async () => {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user