Merge remote-tracking branch 'origin/dev' into fix/doctor-custom-providers-rebased

This commit is contained in:
YeonGyu-Kim
2026-04-09 11:03:12 +09:00
161 changed files with 5085 additions and 464 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
- name: Run tests
run: bun test
run: bun run script/run-ci-tests.ts
typecheck:
runs-on: ubuntu-latest
+1 -1
View File
@@ -46,7 +46,7 @@ jobs:
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
- name: Run tests
run: bun test
run: bun run script/run-ci-tests.ts
typecheck:
runs-on: ubuntu-latest
+1 -1
View File
@@ -1,6 +1,6 @@
# 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
+4 -4
View File
@@ -1,5 +1,8 @@
{
"$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",
"properties": {
"$schema": {
@@ -6068,8 +6071,5 @@
"required": [
"git_master"
],
"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"
"additionalProperties": false
}
+17 -45
View File
@@ -10,8 +10,8 @@
"@clack/prompts": "^0.11.0",
"@code-yeongyu/comment-checker": "^0.7.0",
"@modelcontextprotocol/sdk": "^1.25.2",
"@opencode-ai/plugin": "^1.2.24",
"@opencode-ai/sdk": "^1.2.24",
"@opencode-ai/plugin": "^1.4.0",
"@opencode-ai/sdk": "^1.4.0",
"commander": "^14.0.2",
"detect-libc": "^2.0.0",
"diff": "^8.0.3",
@@ -20,8 +20,7 @@
"picocolors": "^1.1.1",
"picomatch": "^4.0.2",
"vscode-jsonrpc": "^8.2.0",
"zod": "^3.24.0",
"zod-to-json-schema": "^3.25.1",
"zod": "^4.3.0",
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
@@ -30,17 +29,17 @@
"typescript": "^5.7.3",
},
"optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.15.3",
"oh-my-opencode-darwin-x64": "3.15.3",
"oh-my-opencode-darwin-x64-baseline": "3.15.3",
"oh-my-opencode-linux-arm64": "3.15.3",
"oh-my-opencode-linux-arm64-musl": "3.15.3",
"oh-my-opencode-linux-x64": "3.15.3",
"oh-my-opencode-linux-x64-baseline": "3.15.3",
"oh-my-opencode-linux-x64-musl": "3.15.3",
"oh-my-opencode-linux-x64-musl-baseline": "3.15.3",
"oh-my-opencode-windows-x64": "3.15.3",
"oh-my-opencode-windows-x64-baseline": "3.15.3",
"oh-my-opencode-darwin-arm64": "3.16.0",
"oh-my-opencode-darwin-x64": "3.16.0",
"oh-my-opencode-darwin-x64-baseline": "3.16.0",
"oh-my-opencode-linux-arm64": "3.16.0",
"oh-my-opencode-linux-arm64-musl": "3.16.0",
"oh-my-opencode-linux-x64": "3.16.0",
"oh-my-opencode-linux-x64-baseline": "3.16.0",
"oh-my-opencode-linux-x64-musl": "3.16.0",
"oh-my-opencode-linux-x64-musl-baseline": "3.16.0",
"oh-my-opencode-windows-x64": "3.16.0",
"oh-my-opencode-windows-x64-baseline": "3.16.0",
},
},
},
@@ -49,9 +48,6 @@
"@ast-grep/napi",
"@code-yeongyu/comment-checker",
],
"overrides": {
"@opencode-ai/sdk": "^1.2.24",
},
"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=="],
@@ -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=="],
"@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=="],
@@ -239,28 +235,6 @@
"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=="],
"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=="],
"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=="],
"@modelcontextprotocol/sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"@opencode-ai/plugin/zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
}
}
+16 -19
View File
@@ -1,6 +1,6 @@
{
"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",
"main": "./dist/index.js",
"types": "dist/index.d.ts",
@@ -59,8 +59,8 @@
"@clack/prompts": "^0.11.0",
"@code-yeongyu/comment-checker": "^0.7.0",
"@modelcontextprotocol/sdk": "^1.25.2",
"@opencode-ai/plugin": "^1.2.24",
"@opencode-ai/sdk": "^1.2.24",
"@opencode-ai/plugin": "^1.4.0",
"@opencode-ai/sdk": "^1.4.0",
"commander": "^14.0.2",
"detect-libc": "^2.0.0",
"diff": "^8.0.3",
@@ -69,8 +69,7 @@
"picocolors": "^1.1.1",
"picomatch": "^4.0.2",
"vscode-jsonrpc": "^8.2.0",
"zod-to-json-schema": "^3.25.1",
"zod": "^3.24.0"
"zod": "^4.3.0"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
@@ -79,21 +78,19 @@
"typescript": "^5.7.3"
},
"optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.15.3",
"oh-my-opencode-darwin-x64": "3.15.3",
"oh-my-opencode-darwin-x64-baseline": "3.15.3",
"oh-my-opencode-linux-arm64": "3.15.3",
"oh-my-opencode-linux-arm64-musl": "3.15.3",
"oh-my-opencode-linux-x64": "3.15.3",
"oh-my-opencode-linux-x64-baseline": "3.15.3",
"oh-my-opencode-linux-x64-musl": "3.15.3",
"oh-my-opencode-linux-x64-musl-baseline": "3.15.3",
"oh-my-opencode-windows-x64": "3.15.3",
"oh-my-opencode-windows-x64-baseline": "3.15.3"
},
"overrides": {
"@opencode-ai/sdk": "^1.2.24"
"oh-my-opencode-darwin-arm64": "3.16.0",
"oh-my-opencode-darwin-x64": "3.16.0",
"oh-my-opencode-darwin-x64-baseline": "3.16.0",
"oh-my-opencode-linux-arm64": "3.16.0",
"oh-my-opencode-linux-arm64-musl": "3.16.0",
"oh-my-opencode-linux-x64": "3.16.0",
"oh-my-opencode-linux-x64-baseline": "3.16.0",
"oh-my-opencode-linux-x64-musl": "3.16.0",
"oh-my-opencode-linux-x64-musl-baseline": "3.16.0",
"oh-my-opencode-windows-x64": "3.16.0",
"oh-my-opencode-windows-x64-baseline": "3.16.0"
},
"overrides": {},
"trustedDependencies": [
"@ast-grep/cli",
"@ast-grep/napi",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-arm64",
"version": "3.15.3",
"version": "3.16.0",
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"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)",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-x64",
"version": "3.15.3",
"version": "3.16.0",
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"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)",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-arm64",
"version": "3.15.3",
"version": "3.16.0",
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"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)",
"license": "MIT",
"repository": {
@@ -1,6 +1,6 @@
{
"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)",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"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)",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-x64",
"version": "3.15.3",
"version": "3.16.0",
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"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)",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-windows-x64",
"version": "3.15.3",
"version": "3.16.0",
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
"license": "MIT",
"repository": {
+63 -1
View File
@@ -7,6 +7,60 @@ import { getPlatformPackageCandidates, getBinaryPath } from "./bin/platform.js";
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
*/
@@ -36,7 +90,15 @@ function main() {
const { platform, arch } = process;
const libcFamily = getLibcFamily();
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 {
const packageCandidates = getPlatformPackageCandidates({
platform,
+6 -3
View File
@@ -1,14 +1,17 @@
import { zodToJsonSchema } from "zod-to-json-schema"
import { z } from "zod"
import { OhMyOpenCodeConfigSchema } from "../src/config/schema"
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 {
...jsonSchema,
$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",
...jsonSchema,
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ describe("test workflows", () => {
const workflow = readFileSync(workflowPath, "utf8")
expect(workflow).toContain("- name: Run tests")
expect(workflow).toContain("run: bun test")
expect(workflow).toMatch(/run: bun (test|run script\/run-ci-tests\.ts)/)
}
})
})
+1 -7
View File
@@ -29,13 +29,7 @@ async function usesModuleMock(rootDirectory: string, testFile: string): Promise<
}
function toIsolatedTarget(testFile: string): string {
const pathSegments = testFile.split("/")
if (pathSegments.length <= 3) {
return testFile
}
return pathSegments.slice(0, -1).join("/")
return testFile
}
function isCoveredByTarget(testFile: string, isolatedTarget: string): boolean {
+48
View File
@@ -2599,6 +2599,54 @@
"created_at": "2026-04-06T15:37:54Z",
"repoId": 1108837393,
"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
}
]
}
+54 -5
View File
@@ -21,11 +21,12 @@ describe("runCliInstaller", () => {
console.error = originalConsoleError
})
it("completes installation without auth plugin or provider config steps", async () => {
//#given
it("blocks installation when OpenCode is below the minimum version", async () => {
// given
const restoreSpies = [
spyOn(configManager, "detectCurrentConfig").mockReturnValue({
isInstalled: false,
installedVersion: null,
hasClaude: false,
isMax20: false,
hasOpenAI: false,
@@ -34,9 +35,56 @@ describe("runCliInstaller", () => {
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
}),
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({
success: true,
configPath: "/tmp/opencode.jsonc",
@@ -56,12 +104,13 @@ describe("runCliInstaller", () => {
opencodeZen: "no",
zaiCodingPlan: "no",
kimiForCoding: "no",
opencodeGo: "no",
}
//#when
// when
const result = await runCliInstaller(args, "3.4.0")
//#then
// then
expect(result).toBe(0)
for (const spy of restoreSpies) {
+7
View File
@@ -22,6 +22,7 @@ import {
printWarning,
validateNonTuiArgs,
} from "./install-validators"
import { getUnsupportedOpenCodeVersionMessage } from "./minimum-opencode-version"
export async function runCliInstaller(args: InstallArgs, version: string): Promise<number> {
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")
} else {
printSuccess(`OpenCode ${openCodeVersion ?? ""} detected`)
const unsupportedVersionMessage = getUnsupportedOpenCodeVersionMessage(openCodeVersion)
if (unsupportedVersionMessage) {
printWarning(unsupportedVersionMessage)
return 1
}
}
if (isUpdate) {
+9
View File
@@ -18,3 +18,12 @@ export { detectCurrentConfig } from "./config-manager/detect-current-config"
export type { BunInstallResult } 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 type { ConfigMergeResult } from "../types"
import { PLUGIN_NAME, LEGACY_PLUGIN_NAME } from "../../shared"
import { backupConfigFile } from "./backup-config"
import { getConfigDir } from "./config-context"
import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists"
import { formatErrorWithSuggestion } from "./format-error-with-suggestion"
import { detectConfigFormat } from "./opencode-config-format"
import { parseOpenCodeConfigFileWithError, type OpenCodeConfig } from "./parse-opencode-config-file"
import { getPluginNameWithVersion } from "./plugin-name-with-version"
import { checkVersionCompatibility, extractVersionFromPluginEntry } from "./version-compatibility"
export async function addPluginToOpenCodeConfig(currentVersion: string): Promise<ConfigMergeResult> {
try {
@@ -52,14 +54,33 @@ export async function addPluginToOpenCodeConfig(currentVersion: string): Promise
&& !(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]
if (canonicalEntries.length > 0) {
normalizedPlugins.push(canonicalEntries[0])
} else if (legacyEntries.length > 0) {
const versionMatch = legacyEntries[0].match(/@(.+)$/)
const preservedVersion = versionMatch ? versionMatch[1] : null
normalizedPlugins.push(preservedVersion ? `${PLUGIN_NAME}@${preservedVersion}` : pluginEntry)
if (canonicalEntries.length > 0 || legacyEntries.length > 0) {
normalizedPlugins.push(pluginEntry)
} else {
normalizedPlugins.push(pluginEntry)
}
+32
View File
@@ -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 { detectConfigFormat } from "./opencode-config-format"
import { parseOpenCodeConfigFileWithError } from "./parse-opencode-config-file"
import { extractVersionFromPluginEntry } from "./version-compatibility"
function detectProvidersFromOmoConfig(): {
hasOpenAI: boolean
@@ -60,9 +61,14 @@ function isOurPlugin(plugin: string): boolean {
plugin === LEGACY_PLUGIN_NAME || plugin.startsWith(`${LEGACY_PLUGIN_NAME}@`)
}
function findOurPluginEntry(plugins: string[]): string | null {
return plugins.find(isOurPlugin) ?? null
}
export function detectCurrentConfig(): DetectedConfig {
const result: DetectedConfig = {
isInstalled: false,
installedVersion: null,
hasClaude: true,
isMax20: true,
hasOpenAI: true,
@@ -86,7 +92,12 @@ export function detectCurrentConfig(): DetectedConfig {
const openCodeConfig = parseResult.config
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) {
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 { tmpdir } from "node:os"
import { join } from "node:path"
@@ -6,6 +6,7 @@ import { join } from "node:path"
import { resetConfigContext } from "./config-context"
import { detectCurrentConfig } from "./detect-current-config"
import { addPluginToOpenCodeConfig } from "./add-plugin-to-opencode-config"
import * as pluginNameWithVersion from "./plugin-name-with-version"
describe("detectCurrentConfig - single package detection", () => {
let testConfigDir = ""
@@ -109,17 +110,19 @@ describe("addPluginToOpenCodeConfig - single package writes", () => {
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
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
const result = await addPluginToOpenCodeConfig("3.11.0")
const result = await addPluginToOpenCodeConfig("3.16.0")
// then
expect(result.success).toBe(true)
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 () => {
@@ -135,17 +138,36 @@ describe("addPluginToOpenCodeConfig - single package writes", () => {
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
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")
// when
const result = await addPluginToOpenCodeConfig("3.11.0")
const result = await addPluginToOpenCodeConfig("3.10.0")
// then
expect(result.success).toBe(true)
const savedConfig = JSON.parse(readFileSync(testConfigPath, "utf-8"))
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 () => {
@@ -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,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
}
function getRecord(value: unknown): Record<string, unknown> {
@@ -1,6 +1,7 @@
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"
import { parseJsonc } from "../../shared"
import type { ConfigMergeResult, InstallConfig } from "../types"
import { backupConfigFile } from "./backup-config"
import { getConfigDir, getOmoConfigPath } from "./config-context"
import { deepMergeRecord } from "./deep-merge-record"
import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists"
@@ -28,6 +29,15 @@ export function writeOmoConfig(installConfig: InstallConfig): ConfigMergeResult
const newConfig = generateOmoConfig(installConfig)
if (existsSync(omoConfigPath)) {
const backupResult = backupConfigFile(omoConfigPath)
if (!backupResult.success) {
return {
success: false,
configPath: omoConfigPath,
error: `Failed to create backup: ${backupResult.error}`,
}
}
try {
const stat = statSync(omoConfigPath)
const content = readFileSync(omoConfigPath, "utf-8")
+1 -1
View File
@@ -37,7 +37,7 @@ export const EXIT_CODES = {
FAILURE: 1,
} as const
export const MIN_OPENCODE_VERSION = "1.0.150"
export const MIN_OPENCODE_VERSION = "1.4.0"
export const PACKAGE_NAME = PLUGIN_NAME
+2 -2
View File
@@ -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 () => {
// given OpenCode binary IS installed
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
globalThis.fetch = mock(() =>
@@ -157,6 +157,6 @@ describe("install CLI - binary check behavior", () => {
// then should have printed success (OK symbol)
const allCalls = mockConsoleLog.mock.calls.flat().join("\n")
expect(allCalls).toContain("[OK]")
expect(allCalls).toContain("OpenCode 1.0.200")
expect(allCalls).toContain("OpenCode 1.4.0")
})
})
+14
View File
@@ -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.`
}
+129
View File
@@ -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()
}
})
})
+8
View File
@@ -10,6 +10,7 @@ import {
writeOmoConfig,
} from "./config-manager"
import { detectedToInitialValues, formatConfigSummary, SYMBOLS } from "./install-validators"
import { getUnsupportedOpenCodeVersionMessage } from "./minimum-opencode-version"
import { promptInstallConfig } from "./tui-install-prompts"
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")
} else {
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)
+1
View File
@@ -34,6 +34,7 @@ export interface ConfigMergeResult {
export interface DetectedConfig {
isInstalled: boolean
installedVersion: string | null
hasClaude: boolean
isMax20: boolean
hasOpenAI: boolean
@@ -1,5 +1,5 @@
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 { tmpdir } from "node:os"
import {
@@ -11,6 +11,7 @@ import {
clearCompactionAgentConfigCheckpoint,
setCompactionAgentConfigCheckpoint,
} from "../../shared/compaction-agent-config-checkpoint"
import { PART_STORAGE } from "../../shared"
describe("isCompactionAgent", () => {
describe("#given agent name variations", () => {
@@ -73,6 +74,7 @@ describe("findNearestMessageExcludingCompaction", () => {
afterEach(() => {
rmSync(tempDir, { force: true, recursive: true })
rmSync(join(PART_STORAGE, "msg_test_background_compaction_marker"), { force: true, recursive: true })
clearCompactionAgentConfigCheckpoint("ses_checkpoint")
})
@@ -116,6 +118,30 @@ describe("findNearestMessageExcludingCompaction", () => {
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", () => {
// given
const messageWithAgentOnly = {
@@ -256,4 +282,28 @@ describe("resolvePromptContextFromSessionMessages", () => {
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 type { StoredMessage } from "../hook-message-injector"
import { getCompactionAgentConfigCheckpoint } from "../../shared/compaction-agent-config-checkpoint"
import {
hasCompactionPartInStorage,
isCompactionAgent,
isCompactionMessage,
} from "../../shared/compaction-marker"
export { isCompactionAgent } from "../../shared/compaction-marker"
type SessionMessage = {
id?: string
info?: {
agent?: string
model?: {
@@ -15,10 +23,7 @@ type SessionMessage = {
modelID?: string
tools?: StoredMessage["tools"]
}
}
export function isCompactionAgent(agent: string | undefined): boolean {
return agent?.trim().toLowerCase() === "compaction"
parts?: Array<{ type?: string }>
}
function hasFullAgentAndModel(message: StoredMessage): boolean {
@@ -35,6 +40,10 @@ function hasPartialAgentOrModel(message: StoredMessage): boolean {
}
function convertSessionMessageToStoredMessage(message: SessionMessage): StoredMessage | null {
if (isCompactionMessage(message)) {
return null
}
const info = message.info
if (!info) {
return null
@@ -138,7 +147,11 @@ export function findNearestMessageExcludingCompaction(
for (const file of files) {
try {
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 {
continue
}
+153 -2
View File
@@ -218,6 +218,10 @@ function getRootDescendantCounts(manager: BackgroundManager): Map<string, number
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(
manager: BackgroundManager
): Map<string, Array<{ task: BackgroundTask; input: import("./types").LaunchInput }>> {
@@ -1144,7 +1148,18 @@ describe("BackgroundManager.notifyParentSession - notifications toggle", () => {
prompt: promptMock,
promptAsync: promptMock,
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(
@@ -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", () => {
test("should prepend queued notifications to first text part and clear queue", () => {
// given
@@ -1437,6 +1547,7 @@ describe("BackgroundManager.tryCompleteTask", () => {
const task = createMockTask({
id: "task-zombie-session",
sessionID: "session-zombie-placeholder",
parentSessionID: "parent-zombie",
status: "pending",
agent: "explore",
@@ -1779,10 +1890,10 @@ describe("BackgroundManager.resume model persistence", () => {
expect(getSessionPromptParams("session-advanced")).toEqual({
temperature: 0.25,
topP: 0.55,
maxOutputTokens: 8192,
options: {
reasoningEffort: "high",
thinking: { type: "disabled" },
maxTokens: 8192,
},
})
})
@@ -2379,6 +2490,46 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
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 () => {
// given
const firstSessionID = "ses-first-cancelled-during-create"
+5 -5
View File
@@ -422,10 +422,6 @@ export class BackgroundManager {
this.concurrencyManager.release(key)
}
if (item.task.rootSessionID) {
this.unregisterRootDescendant(item.task.rootSessionID)
}
removeTaskToastTracking(item.task.id)
// 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 model: { providerID: string; modelID: string } | undefined
let tools: Record<string, boolean> | undefined = task.parentTools
let promptContext: ReturnType<typeof resolvePromptContextFromSessionMessages> = null
if (this.enableParentSessionNotifications) {
try {
@@ -1796,7 +1793,7 @@ export class BackgroundManager {
tools?: Record<string, boolean | "allow" | "deny" | "ask">
}
}>)
const promptContext = resolvePromptContextFromSessionMessages(
promptContext = resolvePromptContextFromSessionMessages(
messages,
task.parentSessionID,
)
@@ -1840,6 +1837,8 @@ export class BackgroundManager {
const isTaskFailure = task.status === "error" || task.status === "cancelled" || task.status === "interrupt"
const shouldReply = allComplete || isTaskFailure
const variant = promptContext?.model?.variant
try {
await this.client.session.promptAsync({
path: { id: task.parentSessionID },
@@ -1847,6 +1846,7 @@ export class BackgroundManager {
noReply: !shouldReply,
...(agent !== undefined ? { agent } : {}),
...(model !== undefined ? { model } : {}),
...(variant !== undefined ? { variant } : {}),
...(resolvedTools ? { tools: resolvedTools } : {}),
parts: [createInternalAgentTextPart(notification)],
},
+55 -1
View File
@@ -400,10 +400,10 @@ describe("background-agent spawner fallback model promotion", () => {
expect(getSessionPromptParams("session-123")).toEqual({
temperature: 0.4,
topP: 0.7,
maxOutputTokens: 4096,
options: {
reasoningEffort: "high",
thinking: { type: "disabled" },
maxTokens: 4096,
},
})
})
@@ -466,4 +466,58 @@ describe("background-agent spawner fallback model promotion", () => {
})
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")
})
})
+4 -2
View File
@@ -6,6 +6,7 @@ import { applySessionPromptParams } from "../../shared/session-prompt-params-hel
import { subagentSessions } from "../claude-code-session-state"
import { getTaskToastManager } from "../task-toast-manager"
import { isInsideTmux } from "../../shared/tmux"
import { stripAgentListSortPrefix } from "../../shared/agent-display-names"
import type { ConcurrencyManager } from "./concurrency"
export const FALLBACK_AGENT = "general"
@@ -168,11 +169,12 @@ export async function startTask(
}
: undefined
const launchVariant = input.model?.variant
const normalizedAgent = stripAgentListSortPrefix(input.agent)
applySessionPromptParams(sessionID, input.model)
const promptBody = {
agent: input.agent,
agent: normalizedAgent,
...(launchModel ? { model: launchModel } : {}),
...(launchVariant ? { variant: launchVariant } : {}),
system: input.skillContent,
@@ -180,7 +182,7 @@ export async function startTask(
task: false,
call_omo_agent: true,
question: false,
...getAgentToolRestrictions(input.agent),
...getAgentToolRestrictions(normalizedAgent),
},
parts: [createInternalAgentTextPart(input.prompt)],
}
@@ -650,6 +650,65 @@ describe("boulder-state", () => {
expect(progress.completed).toBe(1)
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", () => {
+5 -3
View File
@@ -226,7 +226,9 @@ export function getPlanProgress(planPath: string): PlanProgress {
const lines = content.split(/\r?\n/)
// 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) {
// Structured plan: only count top-level checkboxes with numbered labels
@@ -291,8 +293,8 @@ function getStructuredPlanProgress(lines: string[]): PlanProgress {
}
function getSimplePlanProgress(content: string): PlanProgress {
const uncheckedMatches = content.match(/^\s*[-*]\s*\[\s*\]/gm) || []
const checkedMatches = content.match(/^\s*[-*]\s*\[[xX]\]/gm) || []
const uncheckedMatches = content.match(/^[-*]\s*\[\s*\]/gm) || []
const checkedMatches = content.match(/^[-*]\s*\[[xX]\]/gm) || []
const total = uncheckedMatches.length + 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>\`
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
5. There is no iteration limit
5. The iteration limit is 500 for ultrawork mode, 100 for normal mode
## Rules
@@ -10,6 +10,7 @@ import { join } from "node:path"
const originalClaudePluginsHome = process.env.CLAUDE_PLUGINS_HOME
const temporaryDirectories: string[] = []
const originalCwd = process.cwd()
function createTemporaryDirectory(prefix: string): string {
const directory = mkdtempSync(join(tmpdir(), prefix))
@@ -17,6 +18,14 @@ function createTemporaryDirectory(prefix: string): string {
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", () => {
beforeEach(() => {
mock.module("../../shared/logger", () => ({
@@ -36,6 +45,10 @@ describe("discoverInstalledPlugins", () => {
process.env.CLAUDE_PLUGINS_HOME = originalClaudePluginsHome
}
if (process.cwd() !== originalCwd) {
process.chdir(originalCwd)
}
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
}
@@ -156,4 +169,488 @@ describe("discoverInstalledPlugins", () => {
expect(discovered.plugins).toHaveLength(1)
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 { fileURLToPath } from "url"
import { log } from "../../shared/logger"
import { shouldLoadPluginForCwd } from "./scope-filter"
import type {
InstalledPluginsDatabase,
InstalledPluginEntryV3,
@@ -132,6 +133,7 @@ function v3EntryToInstallation(entry: InstalledPluginEntryV3): PluginInstallatio
installedAt: entry.lastUpdated,
lastUpdated: entry.lastUpdated,
gitCommitSha: entry.gitCommitSha,
projectPath: entry.projectPath,
}
}
@@ -177,6 +179,7 @@ export function discoverInstalledPlugins(options?: PluginLoaderOptions): PluginL
const settingsEnabledPlugins = settings?.enabledPlugins
const overrideEnabledPlugins = options?.enabledPluginsOverride
const pluginManifestLoader = options?.loadPluginManifestOverride ?? loadPluginManifest
const cwd = process.cwd()
for (const [pluginKey, installation] of extractPluginEntries(db)) {
if (!installation) continue
@@ -186,6 +189,14 @@ export function discoverInstalledPlugins(options?: PluginLoaderOptions): PluginL
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
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
gitCommitSha?: string
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
lastUpdated: 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,
registerAgentName,
isAgentRegistered,
resolveRegisteredAgentName,
_resetForTesting,
} from "./state"
@@ -140,6 +141,15 @@ describe("claude-code-session-state", () => {
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("#when checking registration without the zero-width prefix", () => {
test("#then it treats the display name as registered", () => {
@@ -14,6 +14,7 @@ export function getMainSessionID(): string | undefined {
}
const registeredAgentNames = new Set<string>()
const registeredAgentAliases = new Map<string, string>()
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 {
const normalizedName = normalizeRegisteredAgentName(name)
registeredAgentNames.add(normalizedName)
if (!registeredAgentAliases.has(normalizedName)) {
registeredAgentAliases.set(normalizedName, name)
}
const configKey = normalizeRegisteredAgentName(getAgentConfigKey(name))
if (configKey !== normalizedName) {
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))
}
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 */
export function _resetForTesting(): void {
_mainSessionID = undefined
@@ -46,6 +62,7 @@ export function _resetForTesting(): void {
syncSubagentSessions.clear()
sessionAgentMap.clear()
registeredAgentNames.clear()
registeredAgentAliases.clear()
}
const sessionAgentMap = new Map<string, string>()
@@ -11,6 +11,7 @@ import {
generatePartId,
injectHookMessage,
} from "./injector"
import { PART_STORAGE } from "../../shared"
import { isSqliteBackend, resetSqliteBackendCache } from "../../shared/opencode-storage-detection"
//#region Mocks
@@ -53,6 +54,7 @@ function createMockClient(messages: Array<{
tools?: Record<string, boolean>
time?: { created?: number }
}
parts?: Array<{ type?: string }>
}>): {
session: {
messages: (opts: { path: { id: string } }) => Promise<{ data: typeof messages }>
@@ -176,6 +178,24 @@ describe("findNearestMessageWithFieldsFromSDK", () => {
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", () => {
@@ -197,6 +217,34 @@ describe("findNearestMessageWithFields JSON backend ordering", () => {
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", () => {
@@ -222,6 +270,17 @@ describe("findFirstMessageWithAgentFromSDK", () => {
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 () => {
const mockClient = createMockClient([
{ info: {} },
+36 -2
View File
@@ -7,6 +7,7 @@ import type { MessageMeta, OriginalMessageContext, TextPart, ToolPermission } fr
import { log } from "../../shared/logger"
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import { createInternalAgentTextPart, normalizeSDKResponse } from "../../shared"
import { hasCompactionPartInStorage, isCompactionMessage } from "../../shared/compaction-marker"
export interface StoredMessage {
agent?: string
@@ -32,6 +33,7 @@ interface SDKMessage {
created?: number
}
}
parts?: Array<{ type?: string }>
}
const processPrefix = randomBytes(4).toString("hex")
@@ -39,6 +41,10 @@ let messageCounter = 0
let partCounter = 0
function convertSDKMessageToStoredMessage(msg: SDKMessage): StoredMessage | null {
if (isCompactionMessage(msg)) {
return null
}
const info = msg.info
if (!info) return null
@@ -164,22 +170,38 @@ export function findNearestMessageWithFields(messageDir: string): StoredMessage
return {
fileName,
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,
}
} catch {
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))
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) {
return entry.msg
}
}
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)) {
return entry.msg
}
@@ -216,16 +238,28 @@ export function findFirstMessageWithAgent(messageDir: string): string | null {
return {
fileName,
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,
}
} catch {
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))
for (const entry of messages) {
if (entry.hasCompactionMarker || isCompactionMessage({ agent: entry.msg.agent })) {
continue
}
if (entry.msg.agent) {
return entry.msg.agent
}
+58
View File
@@ -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
}
+5 -3
View File
@@ -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 { getOpenCodeConfigDir } from "../../shared"
@@ -82,8 +82,10 @@ function writeStore(store: TokenStore): boolean {
mkdirSync(dir, { recursive: true })
}
writeFileSync(filePath, JSON.stringify(store, null, 2), { encoding: "utf-8", mode: 0o600 })
chmodSync(filePath, 0o600)
const tempPath = `${filePath}.tmp.${Date.now()}`
writeFileSync(tempPath, JSON.stringify(store, null, 2), { encoding: "utf-8", mode: 0o600 })
chmodSync(tempPath, 0o600)
renameSync(tempPath, filePath)
return true
} catch {
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 { SkillMcpClientInfo, SkillMcpManagerState } from "./types"
@@ -89,11 +89,15 @@ function createState(): SkillMcpManagerState {
return state
}
function createClientInfo(serverName: string): SkillMcpClientInfo {
function createClientInfo(
serverName: string,
scope?: SkillMcpClientInfo["scope"],
): SkillMcpClientInfo {
return {
serverName,
skillName: "env-skill",
sessionID: "session-env",
...(scope !== undefined ? { scope } : {}),
}
}
@@ -125,6 +129,68 @@ afterEach(async () => {
})
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", () => {
it("#when creating the client #then sensitive env vars in args are expanded", async () => {
// given
@@ -95,6 +95,7 @@ function createClientInfo(sessionID: string): SkillMcpClientInfo {
serverName: "race-server",
skillName: "race-skill",
sessionID,
scope: "builtin",
}
}
+4 -1
View File
@@ -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: {
state: SkillMcpManagerState
clientKey: string
@@ -38,7 +40,8 @@ export async function getOrCreateClient(params: {
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>
state.inFlightConnections.set(info.sessionID, (state.inFlightConnections.get(info.sessionID) ?? 0) + 1)
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)
})
})
+40 -5
View File
@@ -65,6 +65,7 @@ describe("SkillMcpManager", () => {
serverName: "test-server",
skillName: "test-skill",
sessionID: "session-1",
scope: "builtin",
}
const config: ClaudeCodeMcpServer = {}
@@ -80,6 +81,7 @@ describe("SkillMcpManager", () => {
serverName: "my-mcp",
skillName: "data-skill",
sessionID: "session-1",
scope: "builtin",
}
const config: ClaudeCodeMcpServer = {}
@@ -95,6 +97,7 @@ describe("SkillMcpManager", () => {
serverName: "custom-server",
skillName: "custom-skill",
sessionID: "session-1",
scope: "builtin",
}
const config: ClaudeCodeMcpServer = {}
@@ -112,6 +115,7 @@ describe("SkillMcpManager", () => {
serverName: "http-server",
skillName: "test-skill",
sessionID: "session-1",
scope: "builtin",
}
const config: ClaudeCodeMcpServer = {
type: "http",
@@ -130,6 +134,7 @@ describe("SkillMcpManager", () => {
serverName: "sse-server",
skillName: "test-skill",
sessionID: "session-1",
scope: "builtin",
}
const config: ClaudeCodeMcpServer = {
type: "sse",
@@ -148,6 +153,7 @@ describe("SkillMcpManager", () => {
serverName: "inferred-http",
skillName: "test-skill",
sessionID: "session-1",
scope: "builtin",
}
const config: ClaudeCodeMcpServer = {
url: "https://example.com/mcp",
@@ -165,6 +171,7 @@ describe("SkillMcpManager", () => {
serverName: "stdio-server",
skillName: "test-skill",
sessionID: "session-1",
scope: "builtin",
}
const config: ClaudeCodeMcpServer = {
type: "stdio",
@@ -184,6 +191,7 @@ describe("SkillMcpManager", () => {
serverName: "inferred-stdio",
skillName: "test-skill",
sessionID: "session-1",
scope: "builtin",
}
const config: ClaudeCodeMcpServer = {
command: "node",
@@ -202,6 +210,7 @@ describe("SkillMcpManager", () => {
serverName: "mixed-config",
skillName: "test-skill",
sessionID: "session-1",
scope: "builtin",
}
const config: ClaudeCodeMcpServer = {
type: "stdio",
@@ -224,6 +233,7 @@ describe("SkillMcpManager", () => {
serverName: "bad-url-server",
skillName: "test-skill",
sessionID: "session-1",
scope: "builtin",
}
const config: ClaudeCodeMcpServer = {
type: "http",
@@ -242,6 +252,7 @@ describe("SkillMcpManager", () => {
serverName: "http-error-server",
skillName: "test-skill",
sessionID: "session-1",
scope: "builtin",
}
const config: ClaudeCodeMcpServer = {
url: "https://nonexistent.example.com/mcp",
@@ -259,6 +270,7 @@ describe("SkillMcpManager", () => {
serverName: "hint-server",
skillName: "test-skill",
sessionID: "session-1",
scope: "builtin",
}
const config: ClaudeCodeMcpServer = {
url: "https://nonexistent.example.com/mcp",
@@ -276,6 +288,7 @@ describe("SkillMcpManager", () => {
serverName: "mock-test-server",
skillName: "test-skill",
sessionID: "session-1",
scope: "builtin",
}
const config: ClaudeCodeMcpServer = {
url: "https://example.com/mcp",
@@ -302,6 +315,7 @@ describe("SkillMcpManager", () => {
serverName: "missing-command",
skillName: "test-skill",
sessionID: "session-1",
scope: "builtin",
}
const config: ClaudeCodeMcpServer = {
type: "stdio",
@@ -320,6 +334,7 @@ describe("SkillMcpManager", () => {
serverName: "test-server",
skillName: "test-skill",
sessionID: "session-1",
scope: "builtin",
}
const config: ClaudeCodeMcpServer = {
command: "nonexistent-command-xyz",
@@ -338,6 +353,7 @@ describe("SkillMcpManager", () => {
serverName: "test-server",
skillName: "test-skill",
sessionID: "session-1",
scope: "builtin",
}
const config: ClaudeCodeMcpServer = {
command: "nonexistent-command",
@@ -358,11 +374,13 @@ describe("SkillMcpManager", () => {
serverName: "server1",
skillName: "skill1",
sessionID: "session-1",
scope: "builtin",
}
const session2Info: SkillMcpClientInfo = {
serverName: "server1",
skillName: "skill1",
sessionID: "session-2",
scope: "builtin",
}
// when
@@ -396,6 +414,7 @@ describe("SkillMcpManager", () => {
serverName: "signal-server",
skillName: "signal-skill",
sessionID: "session-1",
scope: "builtin",
}
const config: ClaudeCodeMcpServer = {
url: "https://example.com/mcp",
@@ -423,11 +442,12 @@ describe("SkillMcpManager", () => {
describe("isConnected", () => {
it("returns false for unconnected server", () => {
// given
const info: SkillMcpClientInfo = {
serverName: "unknown",
skillName: "test",
sessionID: "session-1",
}
const info: SkillMcpClientInfo = {
serverName: "$1",
skillName: "$2",
sessionID: "$3",
scope: "builtin",
}
// when / #then
expect(manager.isConnected(info)).toBe(false)
@@ -448,6 +468,7 @@ describe("SkillMcpManager", () => {
serverName: "test-server",
skillName: "test-skill",
sessionID: "session-1",
scope: "builtin",
}
const configWithoutEnv: ClaudeCodeMcpServer = {
command: "node",
@@ -471,6 +492,7 @@ describe("SkillMcpManager", () => {
serverName: "test-server",
skillName: "test-skill",
sessionID: "session-2",
scope: "builtin",
}
const configWithEnv: ClaudeCodeMcpServer = {
command: "node",
@@ -498,6 +520,7 @@ describe("SkillMcpManager", () => {
serverName: "auth-server",
skillName: "test-skill",
sessionID: "session-1",
scope: "builtin",
}
const config: ClaudeCodeMcpServer = {
url: "https://example.com/mcp",
@@ -526,6 +549,7 @@ describe("SkillMcpManager", () => {
serverName: "no-auth-server",
skillName: "test-skill",
sessionID: "session-1",
scope: "builtin",
}
const config: ClaudeCodeMcpServer = {
url: "https://example.com/mcp",
@@ -546,6 +570,7 @@ describe("SkillMcpManager", () => {
serverName: "retry-server",
skillName: "retry-skill",
sessionID: "session-retry-1",
scope: "builtin",
}
const context: SkillMcpServerContext = {
config: {
@@ -584,6 +609,7 @@ describe("SkillMcpManager", () => {
serverName: "fail-server",
skillName: "fail-skill",
sessionID: "session-fail-1",
scope: "builtin",
}
const context: SkillMcpServerContext = {
config: {
@@ -615,6 +641,7 @@ describe("SkillMcpManager", () => {
serverName: "error-server",
skillName: "error-skill",
sessionID: "session-error-1",
scope: "builtin",
}
const context: SkillMcpServerContext = {
config: {
@@ -653,6 +680,7 @@ describe("SkillMcpManager", () => {
serverName: "oauth-server",
skillName: "oauth-skill",
sessionID: "session-oauth-1",
scope: "builtin",
}
const config: ClaudeCodeMcpServer = {
url: "https://mcp.example.com/mcp",
@@ -679,6 +707,7 @@ describe("SkillMcpManager", () => {
serverName: "oauth-no-token",
skillName: "oauth-skill",
sessionID: "session-oauth-2",
scope: "builtin",
}
const config: ClaudeCodeMcpServer = {
url: "https://mcp.example.com/mcp",
@@ -705,6 +734,7 @@ describe("SkillMcpManager", () => {
serverName: "oauth-with-headers",
skillName: "oauth-skill",
sessionID: "session-oauth-3",
scope: "builtin",
}
const config: ClaudeCodeMcpServer = {
url: "https://mcp.example.com/mcp",
@@ -734,6 +764,7 @@ describe("SkillMcpManager", () => {
serverName: "oauth-refresh",
skillName: "oauth-skill",
sessionID: "session-oauth-refresh",
scope: "builtin",
}
const config: ClaudeCodeMcpServer = {
url: "https://mcp.example.com/mcp",
@@ -766,6 +797,7 @@ describe("SkillMcpManager", () => {
serverName: "oauth-refresh-fallback",
skillName: "oauth-skill",
sessionID: "session-oauth-refresh-fallback",
scope: "builtin",
}
const config: ClaudeCodeMcpServer = {
url: "https://mcp.example.com/mcp",
@@ -799,6 +831,7 @@ describe("SkillMcpManager", () => {
serverName: "no-oauth-server",
skillName: "test-skill",
sessionID: "session-no-oauth",
scope: "builtin",
}
const config: ClaudeCodeMcpServer = {
url: "https://mcp.example.com/mcp",
@@ -824,6 +857,7 @@ describe("SkillMcpManager", () => {
serverName: "stepup-server",
skillName: "stepup-skill",
sessionID: "session-stepup-1",
scope: "builtin",
}
const config: ClaudeCodeMcpServer = {
url: "https://mcp.example.com/mcp",
@@ -869,6 +903,7 @@ describe("SkillMcpManager", () => {
serverName: "no-stepup-server",
skillName: "no-stepup-skill",
sessionID: "session-no-stepup",
scope: "builtin",
}
const context: SkillMcpServerContext = {
config: {
+13 -1
View File
@@ -4,7 +4,7 @@ import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
import { McpOAuthProvider } from "../mcp-oauth/provider"
import { disconnectAll, disconnectSession, forceReconnect } from "./cleanup"
import { getOrCreateClient, getOrCreateClientWithRetryImpl } from "./connection"
import { handleStepUpIfNeeded } from "./oauth-handler"
import { handlePostRequestAuthError, handleStepUpIfNeeded } from "./oauth-handler"
import type {
OAuthProviderFactory,
SkillMcpClientInfo,
@@ -110,6 +110,7 @@ export class SkillMcpManager {
): Promise<T> {
const maxRetries = 3
let lastError: Error | null = null
const refreshAttempted = new Set<string>()
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
@@ -130,6 +131,17 @@ export class SkillMcpManager {
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")) {
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 { McpOAuthProvider } from "../mcp-oauth/provider"
import { withRefreshMutex } from "../mcp-oauth/refresh-mutex"
import type { OAuthTokenData } from "../mcp-oauth/storage"
import { isStepUpRequired, mergeScopes } from "../mcp-oauth/step-up"
import type { OAuthProviderFactory, OAuthProviderLike } from "./types"
@@ -52,14 +53,15 @@ export async function buildHttpRequestInit(
}
}
if (tokenData && isTokenExpired(tokenData)) {
try {
tokenData = tokenData.refreshToken
? await provider.refresh(tokenData.refreshToken)
: await provider.login()
} catch {
if (tokenData && isTokenExpired(tokenData)) {
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 {
tokenData = null
}
@@ -116,3 +118,43 @@ export async function handleStepUpIfNeeded(params: {
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 { createCleanMcpEnvironment } from "./env-cleaner"
import { registerProcessCleanup, startCleanupTimer } from "./cleanup"
import { redactSensitiveData } from "./error-redaction"
import type { ManagedClient, SkillMcpClientConnectionParams } from "./types"
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 fullCommand = `${command} ${args.join(" ")}`
const safeCommand = redactSensitiveData(fullCommand)
const safeErrorMessage = redactSensitiveData(errorMessage)
throw new Error(
`Failed to connect to MCP server "${info.serverName}".\n\n` +
`Command: ${command} ${args.join(" ")}\n` +
`Reason: ${errorMessage}\n\n` +
`Command: ${safeCommand}\n` +
`Reason: ${safeErrorMessage}\n\n` +
`Hints:\n` +
` - Ensure the command is installed and available in PATH\n` +
` - Check if the MCP server package exists\n` +
+2
View File
@@ -3,6 +3,7 @@ import type { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdi
import type { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
import type { McpOAuthProvider } from "../mcp-oauth/provider"
import type { SkillScope } from "../opencode-skill-loader/types"
export type SkillMcpConfig = Record<string, ClaudeCodeMcpServer>
@@ -10,6 +11,7 @@ export interface SkillMcpClientInfo {
serverName: string
skillName: string
sessionID: string
scope?: SkillScope | "local"
}
export interface SkillMcpServerContext {
@@ -40,10 +40,22 @@ mock.module("./action-executor", () => ({
mock.module("../../shared/tmux", () => ({
isInsideTmux: mockIsInsideTmux,
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,
SESSION_READY_POLL_INTERVAL_MS: 10,
SESSION_READY_TIMEOUT_MS: 50,
SESSION_MISSING_GRACE_MS: 1_000,
SESSION_TIMEOUT_MS: 600_000,
}))
afterAll(() => { mock.restore() })
@@ -1,6 +1,9 @@
import type { PluginInput } from "@opencode-ai/plugin"
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 { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared"
import { HOOK_NAME } from "./hook-name"
@@ -55,7 +58,9 @@ export async function injectBoulderContinuation(input: {
`\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]` +
preferredSessionContext +
worktreeContext
const continuationAgent = agent ?? (isAgentRegistered("atlas") ? "atlas" : undefined)
const continuationAgent = resolveRegisteredAgentName(
agent ?? (isAgentRegistered("atlas") ? "atlas" : undefined),
)
if (!continuationAgent || !isAgentRegistered(continuationAgent)) {
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 { join } from "node:path"
import { tmpdir } from "node:os"
import { PART_STORAGE } from "../../shared"
const testDirs: string[] = []
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-session-last-agent-${Date.now()}`)
@@ -64,4 +65,36 @@ describe("getLastAgentFromSession JSON backend", () => {
// then
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")
})
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 () => {
// given
const client = {
+21 -10
View File
@@ -2,6 +2,7 @@ import { readFileSync, readdirSync } from "node:fs"
import { join } from "node:path"
import { getMessageDir, isSqliteBackend, normalizeSDKResponse } from "../../shared"
import { hasCompactionPartInStorage, isCompactionMessage } from "../../shared/compaction-marker"
type SessionMessagesClient = {
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 {
try {
const messages = readdirSync(messageDir)
@@ -20,9 +17,10 @@ function getLastAgentFromMessageDir(messageDir: string): string | null {
.map((fileName) => {
try {
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 {
fileName,
id: parsed.id,
agent: parsed.agent,
createdAt: typeof parsed.time?.created === "number" ? parsed.time.created : Number.NEGATIVE_INFINITY,
}
@@ -30,11 +28,16 @@ function getLastAgentFromMessageDir(messageDir: string): string | null {
return null
}
})
.filter((message): message is { fileName: string; agent: unknown; createdAt: number } => message !== null)
.sort((left, right) => right.createdAt - left.createdAt || right.fileName.localeCompare(left.fileName))
.filter((message): message is { fileName: string; id: string | undefined; agent: unknown; createdAt: number } => message !== null)
.sort((left, right) => (right?.createdAt ?? 0) - (left?.createdAt ?? 0) || (right?.fileName ?? "").localeCompare(left?.fileName ?? ""))
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()
}
}
@@ -52,7 +55,11 @@ export async function getLastAgentFromSession(
if (isSqliteBackend() && client) {
try {
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,
}).sort((left, right) => {
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) {
if (isCompactionMessage(message)) {
continue
}
const agent = message.info?.agent
if (typeof agent === "string" && !isCompactionAgent(agent)) {
if (typeof agent === "string") {
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 { log } from "../../../shared/logger"
import type { PackageJson } from "../types"
import { INSTALLED_PACKAGE_JSON } from "../constants"
import { INSTALLED_PACKAGE_JSON_CANDIDATES } from "../constants"
import { findPackageJsonUp } from "./package-json-locator"
export function getCachedVersion(): string | null {
try {
if (fs.existsSync(INSTALLED_PACKAGE_JSON)) {
const content = fs.readFileSync(INSTALLED_PACKAGE_JSON, "utf-8")
const pkg = JSON.parse(content) as PackageJson
if (pkg.version) return pkg.version
for (const candidate of INSTALLED_PACKAGE_JSON_CANDIDATES) {
try {
if (fs.existsSync(candidate)) {
const content = fs.readFileSync(candidate, "utf-8")
const pkg = JSON.parse(content) as PackageJson
if (pkg.version) return pkg.version
}
} catch {
// ignore; try next candidate
}
} catch {
// ignore
}
try {
@@ -1,7 +1,7 @@
import * as fs from "node:fs"
import { fileURLToPath } from "node:url"
import type { OpencodeConfig } from "../types"
import { PACKAGE_NAME } from "../constants"
import { ACCEPTED_PACKAGE_NAMES } from "../constants"
import { getConfigPaths } from "./config-paths"
import { stripJsonComments } from "./jsonc-strip"
@@ -18,12 +18,12 @@ export function getLocalDevPath(directory: string): string | null {
const plugins = config.plugin ?? []
for (const entry of plugins) {
if (entry.startsWith("file://") && entry.includes(PACKAGE_NAME)) {
try {
return fileURLToPath(entry)
} catch {
return entry.replace("file://", "")
}
if (!entry.startsWith("file://")) continue
if (!ACCEPTED_PACKAGE_NAMES.some(name => entry.includes(name))) continue
try {
return fileURLToPath(entry)
} catch {
return entry.replace("file://", "")
}
}
} 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 path from "node:path"
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 {
try {
@@ -14,7 +16,7 @@ export function findPackageJsonUp(startPath: string): string | null {
try {
const content = fs.readFileSync(pkgPath, "utf-8")
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 {
// ignore
}
@@ -4,6 +4,7 @@ import * as fs from "node:fs"
import * as os from "node:os"
import * as path from "node:path"
import { PACKAGE_NAME } from "../constants"
import { LEGACY_PLUGIN_NAME, PLUGIN_NAME } from "../../../shared/plugin-identity"
type PluginEntryResult = {
entry: string
@@ -120,6 +121,64 @@ describe("findPluginEntry", () => {
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 () => {
// #given profile-specific user config after module import
const profileConfigDir = path.join(temporaryDirectory, "profiles", "today")
@@ -3,6 +3,7 @@ import type { OpencodeConfig } from "../types"
import { PACKAGE_NAME } from "../constants"
import { getConfigPaths } from "./config-paths"
import { stripJsonComments } from "./jsonc-strip"
import { LEGACY_PLUGIN_NAME, PLUGIN_NAME } from "../../../shared/plugin-identity"
export interface PluginEntryInfo {
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 MATCH_PLUGIN_NAMES = [PACKAGE_NAME, PLUGIN_NAME, LEGACY_PLUGIN_NAME]
export function findPluginEntry(directory: string): PluginEntryInfo | null {
for (const configPath of getConfigPaths(directory)) {
@@ -22,13 +24,15 @@ export function findPluginEntry(directory: string): PluginEntryInfo | null {
const plugins = config.plugin ?? []
for (const entry of plugins) {
if (entry === PACKAGE_NAME) {
return { entry, isPinned: false, pinnedVersion: null, configPath }
}
if (entry.startsWith(`${PACKAGE_NAME}@`)) {
const pinnedVersion = entry.slice(PACKAGE_NAME.length + 1)
const isPinned = EXACT_SEMVER_REGEX.test(pinnedVersion.trim())
return { entry, isPinned, pinnedVersion, configPath }
for (const pluginName of MATCH_PLUGIN_NAMES) {
if (entry === pluginName) {
return { entry, isPinned: false, pinnedVersion: null, configPath }
}
if (entry.startsWith(`${pluginName}@`)) {
const pinnedVersion = entry.slice(pluginName.length + 1)
const isPinned = EXACT_SEMVER_REGEX.test(pinnedVersion.trim())
return { entry, isPinned, pinnedVersion, configPath }
}
}
}
} catch {
@@ -26,4 +26,24 @@ describe("auto-update-checker constants", () => {
// then PACKAGE_NAME equals the actually published package 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"
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_FETCH_TIMEOUT = 5000
@@ -34,3 +44,11 @@ export const INSTALLED_PACKAGE_JSON = path.join(
PACKAGE_NAME,
"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 {
getCompactionAgentConfigCheckpoint,
} from "../../shared/compaction-agent-config-checkpoint"
@@ -66,6 +69,7 @@ export function createRecoveryLogic(
checkpointWithAgent,
currentPromptConfig,
)
const launchAgent = resolveRegisteredAgentName(expectedPromptConfig.agent)
const model = expectedPromptConfig.model
const tools = expectedPromptConfig.tools
@@ -81,7 +85,7 @@ export function createRecoveryLogic(
path: { id: sessionID },
body: {
noReply: true,
agent: expectedPromptConfig.agent,
agent: launchAgent ?? expectedPromptConfig.agent,
...(model ? { model } : {}),
...(tools ? { tools } : {}),
parts: [createInternalAgentTextPart(AGENT_RECOVERY_PROMPT)],
@@ -86,6 +86,44 @@ describe("keyword-detector ralph-loop activation", () => {
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 () => {
// given
setMainSession("main-session")
+15
View File
@@ -16,11 +16,16 @@ import type { RalphLoopHook } from "../ralph-loop"
import { parseRalphLoopArguments } from "../ralph-loop/command-arguments"
const ULTRAWORK_KEYWORD_PATTERN = /\b(ultrawork|ulw)\b/i
const LEADING_ULTRAWORK_PATTERN = /^\s*(ultrawork|ulw)\b/i
function extractUltraworkTask(cleanText: string): string {
return cleanText.replace(ULTRAWORK_KEYWORD_PATTERN, "").trim()
}
function hasLeadingUltraworkKeyword(cleanText: string): boolean {
return LEADING_ULTRAWORK_PATTERN.test(cleanText)
}
export function createKeywordDetectorHook(
ctx: PluginInput,
_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) {
return
}
+8 -6
View File
@@ -1,8 +1,12 @@
import type { PluginInput } from "@opencode-ai/plugin"
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 { 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_MESSAGE = [
@@ -10,8 +14,6 @@ const TOAST_MESSAGE = [
"Hephaestus is trash without GPT.",
"For Claude/Kimi/GLM models, always use Sisyphus.",
].join("\n")
const SISYPHUS_DISPLAY = getAgentDisplayName("sisyphus")
type NoHephaestusNonGptHookOptions = {
allowNonGptModel?: boolean
}
@@ -54,9 +56,9 @@ export function createNoHephaestusNonGptHook(
if (allowNonGptModel) {
return
}
input.agent = "sisyphus"
input.agent = resolveRegisteredAgentName("sisyphus") ?? "sisyphus"
if (output?.message) {
output.message.agent = "sisyphus"
output.message.agent = resolveRegisteredAgentName("sisyphus") ?? "sisyphus"
}
updateSessionAgent(input.sessionID, "sisyphus")
}
+8 -6
View File
@@ -1,8 +1,12 @@
import type { PluginInput } from "@opencode-ai/plugin"
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 { getAgentConfigKey, getAgentDisplayName } from "../../shared/agent-display-names"
import { getAgentConfigKey } from "../../shared/agent-display-names"
const TOAST_TITLE = "NEVER Use Sisyphus with GPT"
const TOAST_MESSAGE = [
@@ -10,8 +14,6 @@ const TOAST_MESSAGE = [
"Do NOT use Sisyphus with GPT (except GPT-5.4 which has specialized support).",
"For GPT models (other than 5.4), always use Hephaestus.",
].join("\n")
const HEPHAESTUS_DISPLAY = getAgentDisplayName("hephaestus")
function showToast(ctx: PluginInput, sessionID: string): void {
ctx.client.tui.showToast({
body: {
@@ -43,9 +45,9 @@ export function createNoSisyphusGptHook(ctx: PluginInput) {
if (agentKey === "sisyphus" && modelID && isGptModel(modelID) && !isGpt5_4Model(modelID)) {
showToast(ctx, input.sessionID)
input.agent = "hephaestus"
input.agent = resolveRegisteredAgentName("hephaestus") ?? "hephaestus"
if (output?.message) {
output.message.agent = "hephaestus"
output.message.agent = resolveRegisteredAgentName("hephaestus") ?? "hephaestus"
}
updateSessionAgent(input.sessionID, "hephaestus")
}
@@ -85,7 +85,6 @@ export function createPostCompactionDegradationMonitor(args: {
postCompactionNoTextStreak.delete(sessionID)
postCompactionRecoveryTriggered.delete(sessionID)
postCompactionEpoch.delete(sessionID)
postCompactionRecoveryCount.delete(sessionID)
}
const onSessionCompacted = (sessionID: string): void => {
+49
View File
@@ -284,10 +284,57 @@ describe("preemptive-compaction", () => {
//#then
expect(logMock).toHaveBeenCalledWith("[preemptive-compaction] Compaction failed", {
sessionID,
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
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
// #when tool.execute.after is called again immediately
// #then should NOT retry due to cooldown
@@ -475,6 +522,8 @@ describe("preemptive-compaction", () => {
expect(ctx.client.session.summarize).toHaveBeenCalledTimes(1)
expect(logMock).toHaveBeenCalledWith("[preemptive-compaction] Compaction failed", {
sessionID,
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
error: expect.stringContaining("Compaction summarize timed out"),
})
+20 -2
View File
@@ -8,7 +8,7 @@ import {
import { resolveCompactionModel } from "./shared/compaction-model-resolver"
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_COOLDOWN_MS = 60_000
@@ -134,7 +134,25 @@ export function createPreemptiveCompactionHook(
compactedSessions.add(sessionID)
} 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 {
compactionInProgress.delete(sessionID)
}
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from "node:fs"
import { log } from "../../shared/logger"
import { HOOK_NAME } from "./constants"
import { ULTRAWORK_VERIFICATION_PROMISE } from "./constants"
import { isOracleVerified } from "./oracle-verification-detector"
import { withTimeout } from "./with-timeout"
interface OpenCodeSessionMessage {
@@ -17,8 +18,6 @@ interface TranscriptEntry {
tool_output?: { output?: string } | string
}
const ORACLE_AGENT_PATTERN = /Agent:\s*oracle/i
function extractTranscriptEntryText(entry: TranscriptEntry): string {
if (typeof entry.content === "string") return entry.content
if (typeof entry.tool_output === "string") return entry.tool_output
@@ -47,7 +46,7 @@ function shouldInspectSessionMessagePart(
return false
}
return promise === ULTRAWORK_VERIFICATION_PROMISE && ORACLE_AGENT_PATTERN.test(partText)
return promise === ULTRAWORK_VERIFICATION_PROMISE && isOracleVerified(partText)
}
function shouldInspectTranscriptEntry(
@@ -63,7 +62,7 @@ function shouldInspectTranscriptEntry(
return false
}
return promise === ULTRAWORK_VERIFICATION_PROMISE && ORACLE_AGENT_PATTERN.test(entryText)
return promise === ULTRAWORK_VERIFICATION_PROMISE && isOracleVerified(entryText)
}
export function detectCompletionInTranscript(
+1
View File
@@ -2,5 +2,6 @@ export const HOOK_NAME = "ralph-loop"
export const DEFAULT_STATE_FILE = ".sisyphus/ralph-loop.local.md"
export const COMPLETION_TAG_PATTERN = /<promise>(.*?)<\/promise>/is
export const DEFAULT_MAX_ITERATIONS = 100
export const ULTRAWORK_MAX_ITERATIONS = 500
export const DEFAULT_COMPLETION_PROMISE = "DONE"
export const ULTRAWORK_VERIFICATION_PROMISE = "VERIFIED"
@@ -3,6 +3,7 @@ import {
DEFAULT_COMPLETION_PROMISE,
DEFAULT_MAX_ITERATIONS,
HOOK_NAME,
ULTRAWORK_MAX_ITERATIONS,
ULTRAWORK_VERIFICATION_PROMISE,
} from "./constants"
import { clearState, incrementIteration, readState, writeState } from "./storage"
@@ -36,7 +37,7 @@ export function createLoopStateController(options: {
active: true,
iteration: 1,
max_iterations: loopOptions?.ultrawork
? undefined
? ULTRAWORK_MAX_ITERATIONS
: loopOptions?.maxIterations ??
config?.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 { log } from "../../shared/logger"
import { HOOK_NAME } from "./constants"
import { ULTRAWORK_VERIFICATION_PROMISE } from "./constants"
import { extractOracleSessionID, isOracleVerified } from "./oracle-verification-detector"
import type { RalphLoopState } from "./types"
import { handleFailedVerification } from "./verification-failure-handler"
import { withTimeout } from "./with-timeout"
@@ -11,13 +11,6 @@ type OpenCodeSessionMessage = {
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 {
if (!Array.isArray(message.parts)) {
return ""
@@ -67,12 +60,11 @@ async function detectOracleVerificationFromParentSession(
}
const assistantText = collectAssistantText(message)
if (!VERIFIED_PROMISE_PATTERN.test(assistantText) || !ORACLE_AGENT_PATTERN.test(assistantText)) {
if (!isOracleVerified(assistantText)) {
continue
}
const sessionMatch = assistantText.match(TASK_METADATA_SESSION_PATTERN)
const detectedOracleSessionID = sessionMatch?.[1]?.trim()
const detectedOracleSessionID = extractOracleSessionID(assistantText)
if (detectedOracleSessionID) {
return detectedOracleSessionID
}
@@ -279,8 +279,8 @@ describe("ulw-loop verification", () => {
await hook.event({ event: { type: "session.idle", properties: { sessionID: "session-123" } } })
expect(hook.getState()?.iteration).toBe(2)
expect(hook.getState()?.max_iterations).toBeUndefined()
expect(promptCalls[0].text).toContain("2/unbounded")
expect(hook.getState()?.max_iterations).toBe(500)
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 () => {

Some files were not shown because too many files have changed in this diff Show More