diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f68d9a0..a2c1e12 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,7 @@ name: Build and Test on: pull_request: + workflow_dispatch: push: branches: - main @@ -41,12 +42,63 @@ jobs: - name: Test run: npm test - - name: Build - run: npm run build + - name: Build dual-host package + run: npm run build:all - name: Verify package run: npm pack --dry-run + - name: Test packed package in OpenCode 2 with a deterministic provider + run: npm run test:v2:e2e + + hosts: + name: Package-name install across OpenCode hosts + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: "24" + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.9" + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Install the packed plugin by name in V1 and V2 hosts + run: npm run test:hosts:e2e + + v2-live: + name: V2 live-model E2E + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: "24" + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.9" + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Test packed package with a hosted free model + run: npm run test:v2:e2e:live + verify-windows-agent-config: runs-on: windows-latest steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9624fd9..de42c87 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,11 +63,20 @@ jobs: - name: Pack dry-run run: npm pack --dry-run + - name: Test packed package in OpenCode 2 with a deterministic provider + run: npm run test:v2:e2e + + - name: Test packed package in OpenCode 2 with a hosted free model + run: npm run test:v2:e2e:live + + - name: Install the packed plugin by name in V1 and V2 hosts + run: npm run test:hosts:e2e + publish-npm: needs: verify runs-on: ubuntu-latest permissions: - contents: read + contents: write id-token: write steps: - name: Checkout @@ -89,3 +98,13 @@ jobs: - name: Publish to npm run: npm publish --access public --provenance + + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: | + if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then + echo "GitHub release already exists: $GITHUB_REF_NAME" + else + gh release create "$GITHUB_REF_NAME" --verify-tag --generate-notes --title "$GITHUB_REF_NAME" + fi diff --git a/.github/workflows/v1-e2e.yml b/.github/workflows/v1-e2e.yml index d91580a..f488d5f 100644 --- a/.github/workflows/v1-e2e.yml +++ b/.github/workflows/v1-e2e.yml @@ -23,7 +23,7 @@ jobs: run: bun install --frozen-lockfile - name: Install OpenCode V1 - run: bun install --global opencode-ai@1.18.3 + run: bun install --global opencode-ai@1.18.29 - name: Run V1 headless E2E tests run: npm run test:v1:e2e diff --git a/README.md b/README.md index dbb68d6..b0a7e92 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,8 @@ Add to your `opencode.json`: } ``` +This works unchanged on OpenCode 1.x and OpenCode 2 (which also accepts `"plugins"`). On OpenCode 2, hooks cannot show toasts yet; everything else works the same. + ## Configuration ### Config Locations @@ -415,7 +417,7 @@ Tool-arg matching is exact. This example runs only when the tool arg `path` equa }, { "id": "session-idle", - "when": { "event": "session.idle" }, + "when": { "event": "session.idle", "excludeSubagentWait": true }, "run": ["notify-user.sh 'Waiting for input'"], }, ], @@ -448,6 +450,12 @@ background work remains or that OpenCode is specifically waiting for user input. If session lookup fails, hooks run without root filtering rather than being silently dropped. +`excludeSubagentWait` is opt-in. When true, that `session.idle` hook also waits +until all active `task` subagent calls for the root session have completed. Other +idle hooks still run while the parent is waiting on a subagent. This is separate +from `rootSessionOnly`: one filters child-session events, while the other filters +parent idle events during active subagent calls. + --- ## Template Placeholders diff --git a/bun.lock b/bun.lock index 82b9934..e6c2608 100644 --- a/bun.lock +++ b/bun.lock @@ -28,7 +28,7 @@ "packages": { "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], - "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g=="], + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.10.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg=="], "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], @@ -46,9 +46,11 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], - "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], + "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], - "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], + "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], + + "@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="], "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], @@ -66,45 +68,45 @@ "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], - "@opencode-ai/plugin": ["@opencode-ai/plugin@1.18.29", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "1.18.29", "effect": "4.0.0-beta.83", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.4.5", "@opentui/keymap": ">=0.4.5", "@opentui/solid": ">=0.4.5" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-IhF83EU4I/ASgWwvm0FIh1O3a8ZVuCLqPrCbmSHdSYq7GHxIYe773i6dHqcbrzCzvlG/lP2H+dyTQ+xPAwFpbw=="], + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.18.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "1.18.31", "effect": "4.0.0-beta.83", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.4.5", "@opentui/keymap": ">=0.4.5", "@opentui/solid": ">=0.4.5" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-Rdc1bPK06PByaGyGd0kf7JUZ4pTkexz2OOUNlqZWLpHOiMEZ+/rFGjt46ypZ3QwA697gNdWwwwQbHbKG5NMwGA=="], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.29", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-4CS+FoLPkymTlcga8jxivGDDb2AbWMIIl3b8+myoe2wtv/1ANYCErslgz1xy5hTVHymWE6CtVNKzRuPU0ED57A=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.31", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-Raouthf8Lhe9edjvYeeSK7SgvdoU6bBjH9qV3f70dHoa6h+z0X2TMz/e22/wKp/StlFUZ4kIRpYYxFnY8/k01w=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@types/bun": ["@types/bun@1.4.1", "", { "dependencies": { "bun-types": "1.4.1" } }, "sha512-0AVGiTXGajf1rgKom3N+c5L7CBxuoyyv1i44M0nX4UDK0G/fnRAMiri93nHuVPIb429KKtAgj7HatVmmOjeQLA=="], + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], - "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], + "@types/node": ["@types/node@26.6.2", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="], "@types/picomatch": ["@types/picomatch@4.0.3", "", {}, "sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.69.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.69.0", "@typescript-eslint/type-utils": "8.69.0", "@typescript-eslint/utils": "8.69.0", "@typescript-eslint/visitor-keys": "8.69.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.69.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.70.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.70.1", "@typescript-eslint/type-utils": "8.70.1", "@typescript-eslint/utils": "8.70.1", "@typescript-eslint/visitor-keys": "8.70.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.70.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.69.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.69.0", "@typescript-eslint/types": "8.69.0", "@typescript-eslint/typescript-estree": "8.69.0", "@typescript-eslint/visitor-keys": "8.69.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.70.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.70.1", "@typescript-eslint/types": "8.70.1", "@typescript-eslint/typescript-estree": "8.70.1", "@typescript-eslint/visitor-keys": "8.70.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.69.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.69.0", "@typescript-eslint/types": "^8.69.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.70.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.70.1", "@typescript-eslint/types": "^8.70.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.69.0", "", { "dependencies": { "@typescript-eslint/types": "8.69.0", "@typescript-eslint/visitor-keys": "8.69.0" } }, "sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.70.1", "", { "dependencies": { "@typescript-eslint/types": "8.70.1", "@typescript-eslint/visitor-keys": "8.70.1" } }, "sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.69.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.70.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.69.0", "", { "dependencies": { "@typescript-eslint/types": "8.69.0", "@typescript-eslint/typescript-estree": "8.69.0", "@typescript-eslint/utils": "8.69.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.70.1", "", { "dependencies": { "@typescript-eslint/types": "8.70.1", "@typescript-eslint/typescript-estree": "8.70.1", "@typescript-eslint/utils": "8.70.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.69.0", "", {}, "sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.70.1", "", {}, "sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.69.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.69.0", "@typescript-eslint/tsconfig-utils": "8.69.0", "@typescript-eslint/types": "8.69.0", "@typescript-eslint/visitor-keys": "8.69.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.70.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.70.1", "@typescript-eslint/tsconfig-utils": "8.70.1", "@typescript-eslint/types": "8.70.1", "@typescript-eslint/visitor-keys": "8.70.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.69.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.69.0", "@typescript-eslint/types": "8.69.0", "@typescript-eslint/typescript-estree": "8.69.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.70.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.70.1", "@typescript-eslint/types": "8.70.1", "@typescript-eslint/typescript-estree": "8.70.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.69.0", "", { "dependencies": { "@typescript-eslint/types": "8.69.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.70.1", "", { "dependencies": { "@typescript-eslint/types": "8.70.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw=="], - "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -116,9 +118,9 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + "brace-expansion": ["brace-expansion@1.1.21", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw=="], - "bun-types": ["bun-types@1.4.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-loKuVrAFZKfEv+JvWkHRS9GW5IqLuLRjVXN9p+vZvBN86O5hf/pBZQ5hSoyipsrMmWObZBDvWnlmKvjKTM0PdA=="], + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], @@ -150,7 +152,7 @@ "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], - "esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="], + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], @@ -158,7 +160,7 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], - "fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="], + "fast-check": ["fast-check@4.10.2", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -176,7 +178,7 @@ "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], - "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + "flatted": ["flatted@3.4.4", "", {}, "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], @@ -248,7 +250,7 @@ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], @@ -258,7 +260,7 @@ "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], - "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -268,7 +270,7 @@ "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "toml": ["toml@4.3.0", "", {}, "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A=="], @@ -278,9 +280,9 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "typescript-eslint": ["typescript-eslint@8.69.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.69.0", "@typescript-eslint/parser": "8.69.0", "@typescript-eslint/typescript-estree": "8.69.0", "@typescript-eslint/utils": "8.69.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-B3MltX0VqjUBNEe3b3sSuiRbfa6XrfHFtBiPamjT5AsW/dfq+y+bc0wyuS9DxAS1LyzCxRp2+rxzpLUvqM2BvA=="], + "typescript-eslint": ["typescript-eslint@8.70.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.70.1", "@typescript-eslint/parser": "8.70.1", "@typescript-eslint/typescript-estree": "8.70.1", "@typescript-eslint/utils": "8.70.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA=="], - "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], @@ -290,7 +292,7 @@ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], - "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], @@ -302,17 +304,13 @@ "@opencode-ai/plugin/zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], - "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.9", "", {}, "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], - "@typescript-eslint/utils/@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.10.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg=="], - "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], - - "@typescript-eslint/utils/@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], } diff --git a/index.js b/index.js new file mode 100644 index 0000000..804f210 --- /dev/null +++ b/index.js @@ -0,0 +1 @@ +export { default } from "./dist/entry.js" diff --git a/package.json b/package.json index cecdd01..e817179 100644 --- a/package.json +++ b/package.json @@ -3,21 +3,31 @@ "version": "0.7.1", "description": "OpenCode plugin for declaratively attaching shell commands to tool, subagent, and session events", "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", + "main": "dist/entry.js", + "types": "dist/entry.d.ts", "exports": { ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" + "types": "./dist/entry.d.ts", + "import": "./dist/entry.js" + }, + "./server": { + "types": "./dist/server.d.ts", + "import": "./dist/server.js" } }, "files": [ "dist", + "index.js", + "server.js", "types" ], "scripts": { - "build": "tsc", + "build": "rm -rf dist && tsc -p tsconfig.build.json", + "build:all": "npm run build", "test": "bun test", + "test:v2": "bun test tests/v2.plugin.test.ts tests/v2.package.test.ts", + "test:v2:e2e": "OPENCODE2_E2E=1 bun test tests/e2e.v2.test.ts", + "test:v2:e2e:live": "OPENCODE2_E2E=1 OPENCODE2_E2E_LIVE=1 bun test tests/e2e.v2.test.ts", "lint": "eslint src tests --ext .ts", "typecheck": "tsc --noEmit", "ci:check": "npm run lint && npm run typecheck && npm test", @@ -26,7 +36,8 @@ "typecheck:bun-run": "bun run src/index.ts", "test:v1:e2e": "npm run build && OPENCODE_E2E=1 bun test tests/e2e.v1-headless.test.ts", "dev": "tsc --watch", - "prepublishOnly": "npm run build" + "prepublishOnly": "npm run build", + "test:hosts:e2e": "OPENCODE_HOSTS_E2E=1 bun test tests/e2e.hosts.test.ts" }, "keywords": [ "opencode", diff --git a/server.js b/server.js new file mode 100644 index 0000000..c829723 --- /dev/null +++ b/server.js @@ -0,0 +1 @@ +export { default } from "./dist/server.js" diff --git a/src/config/global.ts b/src/config/global.ts index 794a396..c00bf04 100644 --- a/src/config/global.ts +++ b/src/config/global.ts @@ -15,7 +15,8 @@ import { mergeConfigs } from "./merge.js"; import { join, dirname } from "path"; import { homedir } from "os"; import { stat } from "fs/promises"; -import JsoncParser, { type ParseError } from "jsonc-parser"; +import * as JsoncParser from "jsonc-parser"; +import type { ParseError } from "jsonc-parser"; import { logger } from "../logging.js"; /** diff --git a/src/entry.ts b/src/entry.ts new file mode 100644 index 0000000..851e073 --- /dev/null +++ b/src/entry.ts @@ -0,0 +1,12 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { CommandHooksPlugin } from "./index.js" +import plugin from "./server.js" + +/** + * Package root for OpenCode V1 before 1.3.4, which ignores the `./server` + * export and calls every root export as a plugin function. Current hosts + * resolve `./server` first, so they never load this module. + */ +const legacyPlugin = Object.assign((input: PluginInput) => CommandHooksPlugin(input), plugin) + +export default legacyPlugin diff --git a/src/executor.ts b/src/executor.ts index e4c1ae0..1d06685 100644 --- a/src/executor.ts +++ b/src/executor.ts @@ -15,7 +15,6 @@ * Errors are logged and optionally injected into the session, but never thrown. */ -import type { OpencodeClient } from "@opencode-ai/sdk" import { open, mkdtemp, rm } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" @@ -32,6 +31,19 @@ import { interpolateTemplate } from "./execution/template.js" import { logger } from "./logging.js" import { compileToolArgGlob, compileToolArgRegex } from "./matcher.js" +export interface HookToast { + title?: string + message: string + variant?: "info" | "success" | "warning" | "error" + duration?: number +} + +export interface HookHost { + cwd: string + inject: (sessionId: string, message: string, hookId: string) => Promise + toast: (toast: HookToast) => Promise +} + /** * Check if a value matches a pattern (string, array of strings, or wildcard) */ @@ -76,7 +88,7 @@ const matchesToolArg = ( */ export const filterSessionHooks = ( hooks: SessionHook[], - criteria: { event: string; agent: string | undefined } + criteria: { event: string; agent: string | undefined; hasActiveSubagents?: boolean } ): SessionHook[] => { return hooks.filter((hook) => { // Normalize session.start to session.created @@ -86,6 +98,11 @@ export const filterSessionHooks = ( } if (normalizedEvent !== criteria.event) return false + if ( + normalizedEvent === "session.idle" && + hook.when.excludeSubagentWait && + criteria.hasActiveSubagents + ) return false if (hook.when.agent && !criteria.agent) { logger.debug( @@ -223,7 +240,7 @@ const createHookArgsFile = async ( * @returns Promise that resolves when toast is shown */ const showToast = async ( - client: OpencodeClient, + host: HookHost, title: string | undefined, message: string, variant: "info" | "success" | "warning" | "error" = "info", @@ -233,14 +250,7 @@ const showToast = async ( const finalTitle = title || "OpenCode Command Hook" logger.debug(`Showing toast: title="${finalTitle}", message="${message.substring(0, 50)}...", variant="${variant}", duration=${duration}`) - await client.tui.showToast({ - body: { - title: finalTitle, - message, - variant, - duration, - }, - }) + await host.toast({ title: finalTitle, message, variant, duration }) logger.info(`[toast] ${finalTitle}: ${message}`) } catch (error) { @@ -263,19 +273,15 @@ const showToast = async ( * @returns Promise that resolves when message is injected */ const injectMessage = async ( - client: OpencodeClient, + host: HookHost, sessionId: string, - message: string + message: string, + hookId: string, ): Promise => { try { logger.debug(`Injecting message into session ${sessionId}`) - await client.session.promptAsync({ - path: { id: sessionId }, - body: { - parts: [{ type: "text", text: message }], - }, - }) + await host.inject(sessionId, message, hookId) logger.info(`[inject] Message injected into session ${sessionId}: ${message.substring(0, 100)}${message.length > 100 ? '...' : ''}`) } catch (error) { @@ -302,7 +308,7 @@ const injectMessage = async ( const executeHook = async ( hook: ToolHook | SessionHook, context: HookExecutionContext, - client: OpencodeClient, + host: HookHost, truncationLimit?: number ): Promise => { const hookType = isToolHook(hook) ? "tool" : "session" @@ -327,10 +333,8 @@ const injectMessage = async ( hook.id, { truncateOutput: truncationLimit, - cwd: context.directory, - env: { - OPENCODE_HOOK_ARGS_FILE: argsFile.path, - }, + cwd: host.cwd, + env: { OPENCODE_HOOK_ARGS_FILE: argsFile.path }, }, ) } finally { @@ -346,7 +350,7 @@ const injectMessage = async ( } else if (hook.run) { results = await executeCommands(hook.run, hook.id, { truncateOutput: truncationLimit, - cwd: context.directory, + cwd: host.cwd, }) } @@ -370,7 +374,7 @@ const injectMessage = async ( // If inject is configured, prepare and inject the message if (hook.inject) { const message = interpolateTemplate(hook.inject, templateContext) - await injectMessage(client, context.sessionId, message) + await injectMessage(host, context.sessionId, message, hook.id) } // If toast is configured, interpolate and show toast notification @@ -379,7 +383,7 @@ const injectMessage = async ( const toastMessage = interpolateTemplate(hook.toast.message, templateContext) await showToast( - client, + host, toastTitle, toastMessage, hook.toast.variant || "info", @@ -391,7 +395,7 @@ const injectMessage = async ( logger.error(errorMessage) try { - await injectMessage(client, context.sessionId, errorMessage) + await injectMessage(host, context.sessionId, errorMessage, hook.id) } catch (injectionError) { const injectionErrorMsg = injectionError instanceof Error @@ -452,7 +456,7 @@ const isToolHook = (hook: ToolHook | SessionHook): hook is ToolHook => { export async function executeHooks( hooks: (ToolHook | SessionHook)[], context: HookExecutionContext, - client: OpencodeClient, + host: HookHost, truncationLimit?: number ): Promise { try { @@ -463,7 +467,7 @@ export async function executeHooks( // Execute each hook for (const hook of hooks) { try { - await executeHook(hook, context, client, truncationLimit) + await executeHook(hook, context, host, truncationLimit) } catch (error) { // Catch errors from individual hook execution and continue // (errors are already logged and injected by the hook execution functions) diff --git a/src/index.ts b/src/index.ts index 22bfd44..b8ed942 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,11 +2,12 @@ import type { Config, Plugin } from "@opencode-ai/plugin" import type { OpencodeClient } from "@opencode-ai/sdk" import type { CommandHooksConfig, HookExecutionContext, SessionHook } from "./types/hooks.js" import { createLogger, setGlobalLogger, logger } from "./logging.js" -import { executeHooks, filterSessionHooks, filterToolHooks } from "./executor.js" +import { executeHooks, filterSessionHooks, filterToolHooks, type HookHost } from "./executor.js" import { normalizeString } from "./utils.js" import { loadGlobalConfig } from "./config/global.js" import { loadAgentConfig } from "./config/agent.js" import { mergeConfigs } from "./config/merge.js" +import { createActiveSubagentTracker } from "./subagent-tracker.js" const notifyConfigError = async ( configError: string | null, @@ -205,7 +206,8 @@ const handleSessionEvent = async ( sessionId: string | undefined, agent: string | undefined, client: OpencodeClient, - projectDirectory: string, + host: HookHost, + hasActiveSubagents: boolean, cache: PluginCacheState, parentId: SessionParentId = undefined, ): Promise => { @@ -219,7 +221,7 @@ const handleSessionEvent = async ( } try { - const { config: globalConfig, error: globalConfigError } = await loadGlobalConfig(projectDirectory) + const { config: globalConfig, error: globalConfigError } = await loadGlobalConfig(host.cwd) await notifyConfigError(globalConfigError, sessionId, client, cache.notifiedConfigErrors) const markdownConfig = { tool: [], session: [] } @@ -228,6 +230,7 @@ const handleSessionEvent = async ( let matchedHooks = filterSessionHooks(mergedConfig.session || [], { event: eventType, agent, + hasActiveSubagents, }) if (matchedHooks.some((hook) => isRootSessionOnlyHook(hook, eventType))) { @@ -244,10 +247,9 @@ const handleSessionEvent = async ( const context: HookExecutionContext = { sessionId, agent: agent || "unknown", - directory: projectDirectory, } - await executeHooks(matchedHooks, context, client, mergedConfig.truncationLimit) + await executeHooks(matchedHooks, context, host, mergedConfig.truncationLimit) } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) logger.error(`Error handling ${eventType} event: ${errorMessage}`) @@ -259,10 +261,10 @@ const handleSessionEvent = async ( */ const handleToolExecutionHook = async ( phase: "before" | "after", - input: { tool: string; sessionID: string; callID?: string }, + input: { tool: string; sessionID: string; callID?: string; args?: Record }, toolArgs: Record | undefined, client: OpencodeClient, - projectDirectory: string, + host: HookHost, cache: PluginCacheState, ): Promise => { if (phase === "before") { @@ -274,7 +276,7 @@ const handleToolExecutionHook = async ( } try { - const { config: globalConfig, error: globalConfigError } = await loadGlobalConfig(projectDirectory) + const { config: globalConfig, error: globalConfigError } = await loadGlobalConfig(host.cwd) await notifyConfigError(globalConfigError, input.sessionID, client, cache.notifiedConfigErrors) let agentConfig: CommandHooksConfig = { tool: [], session: [] } @@ -284,7 +286,7 @@ const handleToolExecutionHook = async ( subagentType = normalizeString(toolArgs.subagent_type) || undefined if (subagentType) { logger.debug(`Detected task tool call with subagent_type: ${subagentType}`) - agentConfig = await loadAgentConfig(subagentType, projectDirectory) + agentConfig = await loadAgentConfig(subagentType, host.cwd) } } @@ -306,10 +308,9 @@ const handleToolExecutionHook = async ( tool: input.tool, callId: input.callID, toolArgs, - directory: projectDirectory, } - await executeHooks(matchedHooks, context, client, mergedConfig.truncationLimit) + await executeHooks(matchedHooks, context, host, mergedConfig.truncationLimit) } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) logger.error(`Error handling tool.execute.${phase}: ${errorMessage}`) @@ -329,13 +330,28 @@ const handleToolExecutionHook = async ( export const CommandHooksPlugin: Plugin = async ({ client, directory }) => { const clientLogger = createLogger(client) setGlobalLogger(clientLogger) - const projectDirectory = directory || process.cwd() + const typedClient = client as OpencodeClient + const host: HookHost = { + cwd: directory || process.cwd(), + inject: async (sessionId, message) => { + await typedClient.session.promptAsync({ + path: { id: sessionId }, + body: { parts: [{ type: "text", text: message }] }, + }) + }, + toast: async ({ title, message, variant, duration }) => { + await typedClient.tui.showToast({ + body: { title, message, variant: variant ?? "info", duration }, + }) + }, + } + const activeSubagents = createActiveSubagentTracker() const cache = createPluginCacheState() try { logger.info("Initializing OpenCode Command Hooks plugin...") - const hooks = { + const hooks = { /** * Config hook for plugin initialization * Called by OpenCode during plugin initialization to provide configuration. @@ -355,46 +371,52 @@ export const CommandHooksPlugin: Plugin = async ({ client, directory }) => { stripHookProviderOptions(output) }, - /** Handle session.created, session.idle, and the legacy tool.result fallback. */ - event: async ({ event }: { event: { type: string; properties?: Record } }) => { - // Handle session.created event - if (event.type === "session.created") { - logger.debug("Received session.created event") - - // session.created has info.id, not sessionID directly - const info = event.properties?.info as { id?: string; parentID?: string } | undefined - const sessionId = info?.id ? normalizeString(info.id) : undefined - const agent = normalizeString(event.properties?.agent) - const parentId = info?.parentID ? normalizeString(info.parentID) : null - - await handleSessionEvent( - "session.created", - sessionId, - agent, - client as OpencodeClient, - projectDirectory, - cache, - parentId, - ) - } - - // Handle session.idle event - if (event.type === "session.idle") { - logger.debug("Received session.idle event") - - const sessionId = normalizeString(event.properties?.sessionID) - const agent = normalizeString(event.properties?.agent) - - await handleSessionEvent( - "session.idle", - sessionId, - agent, - client as OpencodeClient, - projectDirectory, - cache, - undefined, - ) - } + /** Handle session.created, session.idle, and the legacy tool.result fallback. */ + event: async ({ event }: { event: { type: string; properties?: Record } }) => { + // Handle session.created event + if (event.type === "session.created") { + logger.debug("Received session.created event") + + // session.created has info.id, not sessionID directly + const info = event.properties?.info as { id?: string; parentID?: string; agent?: string } | undefined + const sessionId = info?.id ? normalizeString(info.id) : undefined + const agent = normalizeString(info?.agent ?? event.properties?.agent) + const parentId = info?.parentID ? normalizeString(info.parentID) : null + + await handleSessionEvent( + "session.created", + sessionId, + agent, + typedClient, + host, + sessionId ? activeSubagents.hasActive(sessionId) : false, + cache, + parentId, + ) + } + + if (event.type === "session.deleted") { + const info = event.properties?.info as { id?: string } | undefined + const sessionId = info?.id ? normalizeString(info.id) : undefined + if (sessionId) activeSubagents.clear(sessionId) + } + // Handle session.idle event + if (event.type === "session.idle") { + logger.debug("Received session.idle event") + + const sessionId = normalizeString(event.properties?.sessionID) + const agent = normalizeString(event.properties?.agent) + + await handleSessionEvent( + "session.idle", + sessionId, + agent, + typedClient, + host, + sessionId ? activeSubagents.hasActive(sessionId) : false, + cache, + ) + } // Backward-compat fallback for older OpenCode event streams. // Newer builds should already trigger tool.execute.after. @@ -411,14 +433,16 @@ export const CommandHooksPlugin: Plugin = async ({ client, directory }) => { event.properties?.callID ?? event.properties?.callId ) - if (!sessionId || !toolName) { + if (!sessionId || !toolName) { logger.debug( "tool.result event missing sessionID or tool name" ) - return - } + return + } - if (wasAfterHookProcessed(sessionId, callId, cache)) { + if (toolName === "task") activeSubagents.end(sessionId, callId) + + if (wasAfterHookProcessed(sessionId, callId, cache)) { logger.debug(`Skipping duplicate after-hook execution for callID: ${callId}`) deleteToolArgs(sessionId, callId, cache) return @@ -429,66 +453,70 @@ export const CommandHooksPlugin: Plugin = async ({ client, directory }) => { "after", { tool: toolName, sessionID: sessionId, callID: callId }, storedToolArgs, - client as OpencodeClient, - projectDirectory, + typedClient, + host, cache, ) } - }, - - /** - * Tool execution before hook - * Runs before a tool is executed - */ - "tool.execute.before": async ( - input: { tool: string; sessionID: string; callID: string }, - output: { args: Record } - ) => { - logger.debug( - `Received tool.execute.before for tool: ${input.tool}` - ) - logger.debug( - `Tool args: ${JSON.stringify(output.args)}` - ) - - await handleToolExecutionHook( - "before", - input, - output.args, - client as OpencodeClient, - projectDirectory, - cache, - ) - }, - - /** - * Tool execution after hook - * Runs after a tool completes. - */ - "tool.execute.after": async ( - input: { tool: string; sessionID: string; callID: string; args?: Record }, - toolOutput?: { title: string; output: string; metadata: Record } - ) => { - logger.debug( - `Received tool.execute.after for tool: ${input.tool}` - ) + }, - if (!toolOutput) { - logger.debug( - `tool.execute.after for ${input.tool} has no output payload; running hooks with input or cached args` - ) - } - - const storedToolArgs = input.args ?? getToolArgs(input.sessionID, input.callID, cache) - await handleToolExecutionHook( - "after", - input, - storedToolArgs, - client as OpencodeClient, - projectDirectory, - cache, + /** + * Tool execution before hook + * Runs before a tool is executed + */ + "tool.execute.before": async ( + input: { tool: string; sessionID: string; callID: string }, + output: { args: Record } + ) => { + logger.debug( + `Received tool.execute.before for tool: ${input.tool}` + ) + logger.debug( + `Tool args: ${JSON.stringify(output.args)}` + ) + + if (input.tool === "task") activeSubagents.begin(input.sessionID, input.callID) + + await handleToolExecutionHook( + "before", + input, + output.args, + typedClient, + host, + cache, + ) + }, + + /** + * Tool execution after hook + * Runs after a tool completes. + */ + "tool.execute.after": async ( + input: { tool: string; sessionID: string; callID: string; args?: Record }, + toolOutput?: { title: string; output: string; metadata: Record } + ) => { + logger.debug( + `Received tool.execute.after for tool: ${input.tool}` + ) + + if (!toolOutput) { + logger.debug( + `tool.execute.after for ${input.tool} has no output payload; running hooks with input or cached args` ) - }, + } + + if (input.tool === "task") activeSubagents.end(input.sessionID, input.callID) + + const toolArgs = input.args ?? getToolArgs(input.sessionID, input.callID, cache) + await handleToolExecutionHook( + "after", + input, + toolArgs, + typedClient, + host, + cache, + ) + }, } logger.info(`Plugin returning hooks: ${Object.keys(hooks).join(", ")}`) diff --git a/src/schemas.ts b/src/schemas.ts index bb46abb..609a85c 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -142,6 +142,7 @@ export const ToolHookSchema = z.object({ const SessionHookWhenSchema = z.object({ event: SessionEventSchema, agent: StringOrArray.optional(), + excludeSubagentWait: z.boolean().optional(), rootSessionOnly: z.boolean().optional(), }); diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..0ce7bdd --- /dev/null +++ b/src/server.ts @@ -0,0 +1,14 @@ +import { CommandHooksPlugin } from "./index.js" +import { createV2Plugin } from "./v2/plugin.js" + +/** + * Plugin definition for every host that reads the `./server` export: + * OpenCode V1 1.3.4+ uses `server`, and OpenCode V2 uses `setup`. + */ +const plugin = { + id: "opencode-command-hooks", + server: CommandHooksPlugin, + setup: createV2Plugin().setup, +} + +export default plugin diff --git a/src/subagent-tracker.ts b/src/subagent-tracker.ts new file mode 100644 index 0000000..6400239 --- /dev/null +++ b/src/subagent-tracker.ts @@ -0,0 +1,45 @@ +export type ActiveSubagentTracker = { + begin: (sessionId: string, callId?: string) => void + end: (sessionId: string, callId?: string) => void + hasActive: (sessionId: string) => boolean + clear: (sessionId: string) => void +} + +type ActiveCalls = { + ids: Set + anonymous: number +} + +export const createActiveSubagentTracker = (): ActiveSubagentTracker => { + const sessions = new Map() + + const state = (sessionId: string): ActiveCalls => { + const existing = sessions.get(sessionId) + if (existing) return existing + const created = { ids: new Set(), anonymous: 0 } + sessions.set(sessionId, created) + return created + } + + return { + begin: (sessionId, callId) => { + const active = state(sessionId) + if (callId) active.ids.add(callId) + else active.anonymous += 1 + }, + end: (sessionId, callId) => { + const active = sessions.get(sessionId) + if (!active) return + if (callId) active.ids.delete(callId) + else active.anonymous = Math.max(0, active.anonymous - 1) + if (active.ids.size === 0 && active.anonymous === 0) sessions.delete(sessionId) + }, + hasActive: sessionId => { + const active = sessions.get(sessionId) + return active !== undefined && (active.ids.size > 0 || active.anonymous > 0) + }, + clear: sessionId => { + sessions.delete(sessionId) + }, + } +} diff --git a/src/types/hooks.ts b/src/types/hooks.ts index af2d567..1bc6914 100644 --- a/src/types/hooks.ts +++ b/src/types/hooks.ts @@ -183,6 +183,9 @@ export interface SessionHookWhen { */ agent?: string | string[] + /** Skip this idle hook while the session has active subagent tool calls. */ + excludeSubagentWait?: boolean + /** * Restrict the hook to root sessions. Defaults to true for session.idle and * false for other session events. diff --git a/src/v2/plugin.ts b/src/v2/plugin.ts new file mode 100644 index 0000000..b3c6604 --- /dev/null +++ b/src/v2/plugin.ts @@ -0,0 +1,294 @@ +import type { CommandHooksConfig, HookExecutionContext, SessionHook } from "../types/hooks.js" +import { loadGlobalConfig } from "../config/global.js" +import { loadAgentConfig } from "../config/agent.js" +import { mergeConfigs } from "../config/merge.js" +import { executeHooks, filterSessionHooks, filterToolHooks, type HookHost } from "../executor.js" +import { normalizeString } from "../utils.js" +import { createActiveSubagentTracker } from "../subagent-tracker.js" + +type V2ToolEvent = { + tool: string + sessionID: string + id: string + agent?: string + input: unknown +} + +type V2Event = { + id?: string + type: string + data?: Record + location?: { directory?: string; workspaceID?: string } +} + +type V2SessionInfo = { + id: string + agent?: string + parentID?: string + location: { directory: string; workspaceID?: string } +} + +export interface V2Context { + location: { directory: string; workspaceID?: string } + tool: { + hook: ( + name: "execute.before" | "execute.after", + callback: (event: V2ToolEvent) => Promise | void, + ) => Promise<{ dispose: () => Promise }> + } + event: { + subscribe: (options?: { signal?: AbortSignal }) => AsyncIterable + } + session: { + get: (input: { sessionID: string }) => Promise + synthetic: (input: { + sessionID: string + text: string + description?: string + metadata?: Record + resume?: boolean + }) => Promise + } +} + +export interface V2Plugin { + id: string + setup: (context: V2Context) => Promise<(() => Promise) | void> +} + +const emptyConfig = (): CommandHooksConfig => ({ tool: [], session: [] }) + +const isRootSessionOnlyHook = ( + hook: SessionHook, + eventType: "session.created" | "session.idle", +): boolean => hook.when.rootSessionOnly ?? eventType === "session.idle" + +const asRecord = (value: unknown): Record | undefined => + typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : undefined + +const toolContext = (event: V2ToolEvent): { + toolName: string + toolArgs: Record | undefined + callingAgent: string | undefined + agentConfigName: string | undefined +} => { + const originalArgs = asRecord(event.input) + if (event.tool !== "subagent") { + return { + toolName: event.tool, + toolArgs: originalArgs, + callingAgent: normalizeString(event.agent) || undefined, + agentConfigName: undefined, + } + } + + const agent = normalizeString(originalArgs?.agent) || undefined + return { + // Keep the public command-hooks vocabulary compatible with V1. + toolName: "task", + toolArgs: originalArgs ? { ...originalArgs, subagent_type: agent } : originalArgs, + callingAgent: agent, + agentConfigName: agent, + } +} + +const diagnostic = (message: string, error?: unknown): void => { + const detail = error instanceof Error ? error.message : error === undefined ? "" : String(error) + console.error(`[opencode-command-hooks] ${message}${detail ? `: ${detail}` : ""}`) +} + +export const createV2Plugin = (): V2Plugin => ({ + id: "opencode-command-hooks.v2", + setup: async (ctx) => { + const notifiedConfigErrors = new Set() + const handledEvents = new Set() + const startedSessions = new Set() + const activeSubagents = createActiveSubagentTracker() + let warnedToastUnsupported = false + let stopped = false + const controller = new AbortController() + + const notifyConfigError = (error: string | null, directory: string): void => { + if (!error) return + const key = `${directory}:${error}` + if (notifiedConfigErrors.has(key)) return + notifiedConfigErrors.add(key) + diagnostic(error) + } + + const createHost = (directory: string): HookHost => ({ + cwd: directory, + inject: async (sessionId, message, hookId) => { + await ctx.session.synthetic({ + sessionID: sessionId, + text: message, + description: "opencode-command-hooks", + metadata: { hookId }, + resume: false, + }) + }, + toast: async () => { + if (warnedToastUnsupported) return + warnedToastUnsupported = true + console.warn( + "[opencode-command-hooks] Toast notifications are not available to V2 server plugins; hook execution and injection will continue.", + ) + }, + }) + + const sessionInfo = async ( + sessionID: string, + fallback?: { directory?: string; agent?: string }, + ): Promise<{ directory: string; agent: string | undefined; isRoot: boolean | undefined }> => { + try { + const session = await ctx.session.get({ sessionID }) + return { + directory: session.location.directory, + agent: normalizeString(session.agent) || normalizeString(fallback?.agent) || undefined, + isRoot: !session.parentID, + } + } catch (error) { + if (fallback?.directory) { + return { + directory: fallback.directory, + agent: normalizeString(fallback.agent) || undefined, + isRoot: undefined, + } + } + throw error + } + } + + const handleTool = async (phase: "before" | "after", event: V2ToolEvent): Promise => { + const normalized = toolContext(event) + if (normalized.toolName === "task") { + if (phase === "before") activeSubagents.begin(event.sessionID, event.id) + else activeSubagents.end(event.sessionID, event.id) + } + + try { + const resolved = await sessionInfo(event.sessionID, { + directory: ctx.location.directory, + agent: event.agent, + }) + const { config: globalConfig, error } = await loadGlobalConfig(resolved.directory) + notifyConfigError(error, resolved.directory) + + let agentConfig = emptyConfig() + if (normalized.agentConfigName) { + agentConfig = await loadAgentConfig(normalized.agentConfigName, resolved.directory) + } + + const { config } = mergeConfigs(globalConfig, agentConfig) + const hooks = filterToolHooks(config.tool ?? [], { + phase, + toolName: normalized.toolName, + callingAgent: normalized.callingAgent, + slashCommand: undefined, + toolArgs: normalized.toolArgs, + }) + const context: HookExecutionContext = { + sessionId: event.sessionID, + agent: normalized.callingAgent ?? resolved.agent ?? "unknown", + tool: normalized.toolName, + callId: event.id, + toolArgs: normalized.toolArgs, + } + await executeHooks(hooks, context, createHost(resolved.directory), config.truncationLimit) + } catch (error) { + diagnostic(`Failed to handle tool.execute.${phase}`, error) + } + } + + const handleSession = async (event: V2Event): Promise => { + if (event.type === "session.deleted") { + const sessionID = normalizeString(event.data?.sessionID) + if (sessionID) { + activeSubagents.clear(sessionID) + startedSessions.delete(sessionID) + } + return + } + + const eventType = event.type === "session.created" || event.type === "session.execution.started" + ? "session.created" + : event.type === "session.idle" || + event.type === "session.execution.succeeded" || + event.type === "session.execution.failed" || + event.type === "session.execution.interrupted" + ? "session.idle" + : undefined + if (!eventType) return + if (event.id && handledEvents.has(event.id)) return + if (event.id) { + handledEvents.add(event.id) + if (handledEvents.size > 1000) { + const oldest = handledEvents.values().next().value + if (oldest) handledEvents.delete(oldest) + } + } + + const sessionID = normalizeString(event.data?.sessionID) + if (!sessionID) return + if (eventType === "session.created") { + if (startedSessions.has(sessionID)) return + startedSessions.add(sessionID) + } + + try { + const resolved = await sessionInfo(sessionID, { + directory: normalizeString(event.location?.directory) || undefined, + agent: normalizeString(event.data?.agent) || undefined, + }) + const { config, error } = await loadGlobalConfig(resolved.directory) + notifyConfigError(error, resolved.directory) + let hooks = filterSessionHooks(config.session ?? [], { + event: eventType, + agent: resolved.agent, + hasActiveSubagents: activeSubagents.hasActive(sessionID), + }) + if (resolved.isRoot === false) { + hooks = hooks.filter(hook => !isRootSessionOnlyHook(hook, eventType)) + } + await executeHooks( + hooks, + { sessionId: sessionID, agent: resolved.agent ?? "unknown" }, + createHost(resolved.directory), + config.truncationLimit, + ) + } catch (error) { + diagnostic(`Failed to handle ${event.type}`, error) + } + } + + const registrations = [ + await ctx.tool.hook("execute.before", event => handleTool("before", event)), + await ctx.tool.hook("execute.after", event => handleTool("after", event)), + ] + + let iterator: AsyncIterator | undefined + const eventTask = (async () => { + try { + iterator = ctx.event.subscribe({ signal: controller.signal })[Symbol.asyncIterator]() + while (!stopped) { + const next = await iterator.next() + if (next.done) break + await handleSession(next.value) + } + } catch (error) { + if (!stopped) diagnostic("Event subscription failed", error) + } + })() + + return async () => { + stopped = true + controller.abort() + const disposed = Promise.allSettled(registrations.map(registration => registration.dispose())) + await iterator?.return?.() + await eventTask + await disposed + } + }, +}) diff --git a/tests/agent.config.test.ts b/tests/agent.config.test.ts index a37f458..d228631 100644 --- a/tests/agent.config.test.ts +++ b/tests/agent.config.test.ts @@ -77,6 +77,15 @@ describe("Agent Configuration", () => { } }); + it("should resolve from an explicit project directory", async () => { + const agentPath = join(testProjectDir, ".opencode", "agents", "located-agent.md"); + await writeFile(agentPath, "---\ndescription: Located agent\n---\n# Located agent content"); + + const result = await resolveAgentPath("located-agent", testProjectDir); + + expect(realpathSync(result!)).toBe(realpathSync(agentPath)); + }); + it("should prioritize project-level plural over project-level singular", async () => { const pluralPath = join(testProjectDir, ".opencode", "agents", "project-priority.md"); const singularPath = join(testProjectDir, ".opencode", "agent", "project-priority.md"); diff --git a/tests/config.global.test.ts b/tests/config.global.test.ts index 8c8bd04..7301d1b 100644 --- a/tests/config.global.test.ts +++ b/tests/config.global.test.ts @@ -243,6 +243,23 @@ describe("Global Configuration", () => { }); describe("Project config discovery", () => { + it("uses an explicit project directory without changing process cwd", async () => { + await writeProjectConfig({ + tool: [ + { + id: "explicit-directory-hook", + when: { phase: "before", tool: "bash" }, + run: "pwd", + }, + ], + }); + + const result = await loadGlobalConfig(testProjectDir); + + expect(process.cwd()).toBe(originalCwd); + expect(result.config.tool?.map(hook => hook.id)).toEqual(["explicit-directory-hook"]); + }); + it("should find project config in parent directory", async () => { const nestedDir = join(testProjectDir, "src", "components"); await mkdir(nestedDir, { recursive: true }); diff --git a/tests/e2e.hosts.test.ts b/tests/e2e.hosts.test.ts new file mode 100644 index 0000000..da81407 --- /dev/null +++ b/tests/e2e.hosts.test.ts @@ -0,0 +1,259 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test" +import { $ } from "bun" +import { existsSync } from "fs" +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "fs/promises" +import { tmpdir } from "os" +import { join, resolve } from "path" + +/** + * Installs the packed plugin by package name, the way users configure it, in + * each supported OpenCode host. Directory-path installs bypass package + * exports, so only a registry install proves which entrypoint a host selects. + */ + +const enabled = process.env.OPENCODE_HOSTS_E2E === "1" +const repositoryRoot = resolve(import.meta.dir, "..") +const providerFixture = resolve(repositoryRoot, "tests/fixtures/local-openai-server.ts") + +interface Host { + name: string + generation: "v1" | "v2" + spec: string + // Old V1 `run` can exit before the idle hook finishes, so idle is asserted + // only where the host reliably waits for it. + idle: boolean +} + +const hosts: Host[] = [ + { name: "V1 1.3.3 (package root)", generation: "v1", spec: "opencode-ai@1.3.3", idle: false }, + { name: "V1 1.3.4 (./server)", generation: "v1", spec: "opencode-ai@1.3.4", idle: false }, + { name: "V1 latest", generation: "v1", spec: "opencode-ai@latest", idle: true }, + { name: "V2 latest", generation: "v2", spec: "@opencode/cli@latest", idle: true }, +] + +let workspace = "" +let registryUrl = "" +let registry: Bun.Subprocess | undefined +let provider: Bun.ReadableSubprocess | undefined +let providerUrl = "" + +const sleep = (ms: number) => new Promise(done => setTimeout(done, ms)) + +const readUntil = async (stream: ReadableStream, pattern: RegExp, timeoutMs: number): Promise => { + const reader = stream.getReader() + const decoder = new TextDecoder() + const deadline = Date.now() + timeoutMs + let output = "" + while (Date.now() < deadline) { + const chunk = await Promise.race([reader.read(), sleep(deadline - Date.now()).then(() => undefined)]) + if (!chunk || chunk.done) break + output += decoder.decode(chunk.value) + const match = output.match(pattern) + if (match) { + reader.releaseLock() + return match[1] ?? match[0] + } + } + throw new Error(`Timed out waiting for ${pattern}:\n${output}`) +} + +const freePort = (): number => { + const probe = Bun.serve({ port: 0, fetch: () => new Response() }) + const port = probe.port + probe.stop(true) + return port +} + +const stop = async (child: Bun.Subprocess | undefined) => { + if (!child || child.exitCode !== null) return + child.kill(9) + await Promise.race([child.exited, sleep(5_000)]) +} + +const startRegistry = async () => { + const directory = join(workspace, "registry") + await mkdir(directory, { recursive: true }) + const port = freePort() + await writeFile(join(directory, "config.yaml"), [ + "storage: ./storage", + "auth:", + " htpasswd:", + " file: ./htpasswd", + "uplinks:", + " npmjs:", + " url: https://registry.npmjs.org/", + " timeout: 60s", + "packages:", + " 'opencode-command-hooks':", + " access: $all", + " publish: $all", + " '**':", + " access: $all", + " proxy: npmjs", + `listen: 127.0.0.1:${port}`, + "log: { type: stdout, format: pretty, level: warn }", + "", + ].join("\n")) + registry = Bun.spawn(["npx", "--yes", "verdaccio@6.10.4", "--config", "config.yaml"], { + cwd: directory, + stdout: "ignore", + stderr: "ignore", + }) + registryUrl = `http://127.0.0.1:${port}/` + for (let attempt = 0; attempt < 120; attempt++) { + const ready = await fetch(`${registryUrl}-/ping`).then(response => response.ok).catch(() => false) + if (ready) return + await sleep(500) + } + throw new Error("Local npm registry did not start") +} + +const publishPackage = async () => { + const userconfig = join(workspace, "publish.npmrc") + await writeFile(userconfig, `registry=${registryUrl}\n//${registryUrl.slice("http://".length)}:_authToken=e2e\n`) + await $`npm run build`.cwd(repositoryRoot).quiet() + const archive = (await $`npm pack --ignore-scripts --pack-destination ${workspace}`.cwd(repositoryRoot).text()) + .trim().split("\n").at(-1) + if (!archive) throw new Error("npm pack did not return an archive name") + await $`npm publish ${join(workspace, archive)} --userconfig ${userconfig} --tag latest`.quiet() +} + +const hostConfig = (host: Host) => { + const config: Record = { + $schema: "https://opencode.ai/config.json", + // Unchanged V1-style configuration, which V2 migrates. + plugin: ["opencode-command-hooks"], + model: "local/deterministic", + } + if (host.generation === "v1") { + config.provider = { + local: { + npm: "@ai-sdk/openai-compatible", + name: "Deterministic local provider", + options: { baseURL: `${providerUrl}/v1` }, + models: { deterministic: { name: "Deterministic", tool_call: true } }, + }, + } + } else { + config.providers = { + local: { + name: "Deterministic local provider", + package: "@opencode/ai/providers/openai-compatible", + settings: { baseURL: `${providerUrl}/v1` }, + models: { + deterministic: { + modelID: "deterministic", + capabilities: { tools: true, input: ["text"], output: ["text"] }, + limit: { context: 32_768, output: 4_096 }, + }, + }, + }, + } + } + return config +} + +const runHost = async (host: Host) => { + const root = await mkdtemp(join(workspace, "host-")) + const install = join(root, "install") + const project = join(root, "project") + const home = join(root, "home") + for (const directory of [install, join(project, ".opencode"), home, join(root, "tmp")]) { + await mkdir(directory, { recursive: true }) + } + await writeFile(join(install, "package.json"), "{\"private\":true}") + await $`npm install --no-audit --no-fund ${host.spec}`.cwd(install).quiet() + const binary = join(install, "node_modules", ".bin", "opencode") + + await writeFile(join(home, ".npmrc"), `registry=${registryUrl}\n`) + await writeFile(join(project, "opencode.json"), JSON.stringify(hostConfig(host), null, 2)) + await writeFile(join(project, ".opencode", "command-hooks.jsonc"), JSON.stringify({ + tool: [ + { id: "hosts-before", when: { phase: "before", tool: "*" }, run: "printf before > hook-before.txt" }, + { id: "hosts-after", when: { phase: "after", tool: "*" }, run: "printf after > hook-after.txt" }, + ], + session: [{ id: "hosts-idle", when: { event: "session.idle" }, run: "printf idle > hook-idle.txt" }], + })) + + const env: Record = { + PATH: process.env.PATH ?? "", + PWD: project, + HOME: home, + TMPDIR: join(root, "tmp"), + XDG_CONFIG_HOME: join(home, ".config"), + XDG_DATA_HOME: join(home, ".local", "share"), + XDG_CACHE_HOME: join(home, ".cache"), + XDG_STATE_HOME: join(home, ".local", "state"), + npm_config_registry: registryUrl, + BUN_CONFIG_REGISTRY: registryUrl, + OPENCODE_DISABLE_AUTOUPDATE: "1", + CI: "1", + } + // V1 `run` talks to its in-process server without credentials. + if (host.generation === "v2") Object.assign(env, { OPENCODE_PASSWORD: "hosts-e2e", OPENCODE_SERVER_PASSWORD: "hosts-e2e" }) + + const run = async (args: string[]) => { + const child = Bun.spawn([binary, ...args], { cwd: project, env, stdout: "pipe", stderr: "pipe" }) + const timer = setTimeout(() => child.kill(9), 150_000) + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]) + clearTimeout(timer) + return { exitCode, output: `${stdout}\n${stderr}` } + } + + const prompt = "V2_E2E_SHELL V2_E2E_SHELL_FILE=model-shell.txt" + let logs = "" + let server: Bun.ReadableSubprocess | undefined + try { + if (host.generation === "v1") { + // The first run installs the plugin; older hosts can race that install. + logs += (await run(["run", "--print-logs", "-m", "local/deterministic", "Reply DONE."])).output + for (const file of ["hook-before.txt", "hook-after.txt", "hook-idle.txt"]) await rm(join(project, file), { force: true }) + logs += (await run(["run", "--print-logs", "-m", "local/deterministic", prompt])).output + } else { + server = Bun.spawn([binary, "serve", "--hostname", "127.0.0.1", "--port", "0", "--print-logs"], { + cwd: project, env, stdout: "pipe", stderr: "pipe", + }) + const url = await readUntil(server.stdout, /listening on (http:\/\/\S+)/, 120_000) + logs += (await run(["run", "--server", url, "--model", "local/deterministic", "--auto", prompt])).output + } + const expected = ["model-shell.txt", "hook-before.txt", "hook-after.txt", ...(host.idle ? ["hook-idle.txt"] : [])] + for (let attempt = 0; attempt < 30 && expected.some(file => !existsSync(join(project, file))); attempt++) await sleep(500) + return Object.fromEntries(expected.map(file => [file, existsSync(join(project, file))])) as Record & { logs?: string } + } finally { + await stop(server) + if (!process.env.OPENCODE_HOSTS_E2E_KEEP) await rm(root, { recursive: true, force: true }) + if (logs) await writeFile(join(workspace, `${host.spec.replaceAll(/[@/]/g, "_")}.log`), logs) + } +} + +describe.skipIf(!enabled)("package-name installs across OpenCode hosts", () => { + beforeAll(async () => { + // Canonical paths keep each host in one project location when tmpdir is a symlink (macOS). + workspace = await realpath(await mkdtemp(join(tmpdir(), "opencode-hooks-hosts-"))) + provider = Bun.spawn([process.execPath, providerFixture, "0"], { stdout: "pipe", stderr: "ignore" }) + providerUrl = await readUntil(provider.stdout, /READY (http:\/\/\S+)/, 10_000) + await startRegistry() + await publishPackage() + }, 300_000) + + afterAll(async () => { + await stop(provider) + await stop(registry) + if (workspace && !process.env.OPENCODE_HOSTS_E2E_KEEP) await rm(workspace, { recursive: true, force: true }) + }) + + for (const host of hosts) { + it(`runs tool and session hooks in ${host.name}`, async () => { + const result = await runHost(host) + const log = await readFile(join(workspace, `${host.spec.replaceAll(/[@/]/g, "_")}.log`), "utf8").catch(() => "") + const context = `${host.spec}\n${log.split("\n").filter(line => /plugin|command-hooks|error/i.test(line)).join("\n")}` + for (const [file, present] of Object.entries(result)) { + expect(present, `${file} missing\n${context}`).toBe(true) + } + }, 300_000) + } +}) diff --git a/tests/e2e.v1-headless.test.ts b/tests/e2e.v1-headless.test.ts index 2a46b60..e64bb68 100644 --- a/tests/e2e.v1-headless.test.ts +++ b/tests/e2e.v1-headless.test.ts @@ -1,10 +1,12 @@ import { describe, it, expect, beforeAll, afterAll } from "bun:test" -import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, mkdtempSync, statSync, unlinkSync, rmSync } from "fs" -import { join } from "path" +import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, mkdtempSync, realpathSync, statSync, unlinkSync, rmSync } from "fs" +import { join, resolve } from "path" import { tmpdir } from "os" +import { $ } from "bun" const REPOSITORY_ROOT = process.cwd() let TEST_SANDBOX_DIR = "" +let TEST_PLUGIN_DIR = "" let TEST_CONFIG_DIR = "" let TEST_OPENCODE_SUBDIR = "" let TEST_OPENCODE_CONFIG = "" @@ -19,10 +21,21 @@ const LOG_WINDOW_MS = 15 * 60 * 1000 const LOG_FALLBACK_FILES = 3 const OPENCODE_COMMAND_TIMEOUT_MS = 110_000 const E2E_ENABLED = process.env.OPENCODE_E2E === "1" -let E2E_MODEL = process.env.OPENCODE_E2E_MODEL ?? "" +// Hosted models are opt-in; by default a deterministic local provider drives +// the real host so results never depend on an external model service. +const HOSTED_MODEL = process.env.OPENCODE_E2E_MODEL ?? "" +const E2E_MODEL = HOSTED_MODEL || "local/deterministic" +const PROVIDER_FIXTURE = resolve(import.meta.dir, "fixtures", "local-openai-server.ts") +let providerProcess: Bun.ReadableSubprocess | undefined +let providerUrl = "" +// A long-lived server, like the TUI, so hooks that run after the session goes +// idle are not cut short by a one-shot `opencode run` exiting. +let serverProcess: Bun.ReadableSubprocess | undefined +let serverUrl = "" function createTestSandbox(): void { - TEST_SANDBOX_DIR = mkdtempSync(join(tmpdir(), "opencode-command-hooks-e2e-")) + // Canonical paths keep tool permissions inside the project when tmpdir is a symlink (macOS). + TEST_SANDBOX_DIR = realpathSync(mkdtempSync(join(tmpdir(), "opencode-command-hooks-e2e-"))) TEST_CONFIG_DIR = join(TEST_SANDBOX_DIR, "project") TEST_OPENCODE_SUBDIR = join(TEST_CONFIG_DIR, ".opencode") TEST_OPENCODE_CONFIG = join(TEST_CONFIG_DIR, "opencode.jsonc") @@ -64,17 +77,70 @@ function formatOpenCodeCommand(args: string[]): string { return ["opencode", ...args].map(argument => JSON.stringify(argument)).join(" ") } -function selectFreeOpenCodeModel(output: string): string { - const models = output - .split("\n") - .map(line => line.trim()) - .filter(line => line.startsWith("opencode/") && line.endsWith("-free")) - const model = ["opencode/mimo-v2.5-free", "opencode/north-mini-code-free"] - .find(candidate => models.includes(candidate)) ?? models[0] - if (!model) { - throw new Error(`OpenCode did not advertise a credential-free model:\n${output}`) +const providerLogPath = () => join(TEST_SANDBOX_DIR, "provider.jsonl") + +/** + * Wait until text appears in model context: the local provider's request log, + * or the host log when a hosted model is used. + */ +async function waitForModelContext(text: string, timeoutMs = 10000): Promise { + if (HOSTED_MODEL) return waitForLogMatch(content => content.includes(text), timeoutMs) + const start = Date.now() + let content = "" + while (Date.now() - start < timeoutMs) { + content = existsSync(providerLogPath()) ? readFileSync(providerLogPath(), "utf-8") : "" + if (content.includes(text)) return text + await new Promise(resolve => setTimeout(resolve, 500)) + } + return content +} + +async function readUntil(stream: ReadableStream, pattern: RegExp, timeoutMs: number): Promise { + const reader = stream.getReader() + const decoder = new TextDecoder() + const deadline = Date.now() + timeoutMs + let output = "" + try { + while (Date.now() < deadline) { + const chunk = await Promise.race([ + reader.read(), + new Promise(resolve => setTimeout(resolve, deadline - Date.now())), + ]) + if (!chunk || chunk.done) break + output += decoder.decode(chunk.value) + const match = output.match(pattern) + if (match?.[1]) return match[1] + } + } finally { + reader.releaseLock() } - return model + throw new Error(`Timed out waiting for ${pattern}:\n${output}`) +} + +async function startServer(): Promise { + serverProcess = Bun.spawn(["opencode", "serve", "--hostname", "127.0.0.1", "--port", "0"], { + cwd: TEST_CONFIG_DIR, + env: getOpenCodeEnvironment(), + stdout: "pipe", + stderr: "pipe", + }) + serverUrl = await readUntil(serverProcess.stdout, /listening on (http:\/\/\S+)/, 60_000) +} + +async function startLocalProvider(): Promise { + providerProcess = Bun.spawn([process.execPath, PROVIDER_FIXTURE, "0", providerLogPath()], { stdout: "pipe", stderr: "ignore" }) + const reader = providerProcess.stdout.getReader() + const decoder = new TextDecoder() + let output = "" + const deadline = Date.now() + 10_000 + while (!providerUrl && Date.now() < deadline) { + const chunk = await reader.read() + if (chunk.done) break + output += decoder.decode(chunk.value) + providerUrl = output.match(/READY (http:\/\/\S+)/)?.[1] ?? "" + } + reader.releaseLock() + if (!providerUrl) throw new Error(`Local provider did not start:\n${output}`) } function formatOpenCodeCommandError( @@ -220,8 +286,18 @@ function writeTestOpencodeConfig(): void { if (!existsSync(TEST_CONFIG_DIR)) { mkdirSync(TEST_CONFIG_DIR, { recursive: true }) } - const pluginConfig = { - plugin: [join(REPOSITORY_ROOT, "dist", "index.js")], + const pluginConfig: Record = { + plugin: [TEST_PLUGIN_DIR], + } + if (!HOSTED_MODEL) { + pluginConfig.provider = { + local: { + npm: "@ai-sdk/openai-compatible", + name: "Deterministic local provider", + options: { baseURL: `${providerUrl}/v1` }, + models: { deterministic: { name: "Deterministic", tool_call: true } }, + }, + } } writeFileSync(TEST_OPENCODE_CONFIG, JSON.stringify(pluginConfig, null, 2)) } @@ -294,7 +370,7 @@ async function waitForMinimumLineCount( * Run OpenCode with a prompt and capture its successful output. */ async function runOpenCode(prompt: string): Promise { - const result = await runOpenCodeCommand(["-m", E2E_MODEL, "run", prompt]) + const result = await runOpenCodeCommand(["run", "--attach", serverUrl, "--dir", TEST_CONFIG_DIR, "-m", E2E_MODEL, prompt]) await new Promise(resolve => setTimeout(resolve, 2000)) return `${result.stdout}${result.stderr}` } @@ -303,22 +379,48 @@ describe.skipIf(!E2E_ENABLED)("V1 headless real-host E2E", () => { beforeAll(async () => { createTestSandbox() + const archive = (await $`npm pack --ignore-scripts --pack-destination ${TEST_SANDBOX_DIR}` + .cwd(REPOSITORY_ROOT) + .text()) + .trim() + .split("\n") + .at(-1) + if (!archive) throw new Error("npm pack did not return an archive name") + writeFileSync(join(TEST_SANDBOX_DIR, "package.json"), JSON.stringify({ private: true })) + await $`npm install --ignore-scripts ${join(TEST_SANDBOX_DIR, archive)}` + .cwd(TEST_SANDBOX_DIR) + .quiet() + TEST_PLUGIN_DIR = join(TEST_SANDBOX_DIR, "node_modules", "opencode-command-hooks") + // Verify the executable works in the same isolated environment as the tests. await runOpenCodeCommand(["--version"]) - if (!E2E_MODEL) { - const models = await runOpenCodeCommand(["models", "opencode"]) - E2E_MODEL = selectFreeOpenCodeModel(models.stdout) - } + if (!HOSTED_MODEL) await startLocalProvider() // Enable the plugin in the test opencode config writeTestOpencodeConfig() - }) + // The server discovers agents at startup. + writeTestAgent("e2e-worker", `--- +description: E2E worker that returns a fixed response +mode: subagent +model: ${E2E_MODEL} +--- - afterAll(() => { +Reply exactly WORKER_DONE. +`) + await startServer() + }, 120_000) + + afterAll(async () => { + for (const child of [serverProcess, providerProcess]) { + if (child?.exitCode === null) { + child.kill(9) + await child.exited + } + } if (TEST_SANDBOX_DIR) { rmSync(TEST_SANDBOX_DIR, { recursive: true, force: true }) } - }) + }, 30_000) it("runs one write after-hook transaction with isolated observable evidence", async () => { const uniqueId = generateUniqueId() @@ -351,12 +453,11 @@ describe.skipIf(!E2E_ENABLED)("V1 headless real-host E2E", () => { try { const opencodeResponse = await runOpenCode( - `Use the write tool, not bash, to create ${writtenFileName} with exactly this content and no trailing newline: ${writtenContent}` + `Use the write tool, not bash, to create ${writtenFileName} with exactly this content and no trailing newline: ${writtenContent} E2E_WRITE_FILE=${writtenFileName} E2E_WRITE_CONTENT=${writtenContent}` ) expect(await waitForFileContent(writtenFilePath, writtenContent)).toBe(writtenContent) expect(await waitForFileContent(hookProofFilePath, hookProofContent)).toBe(hookProofContent) - const logContent = await waitForLogMatch(content => content.includes(injectedMarker)) - expect(logContent).toContain(injectedMarker) + expect(await waitForModelContext(injectedMarker)).toContain(injectedMarker) expect(opencodeResponse).not.toContain(toastMarker) } finally { for (const filePath of [writtenFilePath, hookProofFilePath]) { @@ -374,18 +475,9 @@ describe.skipIf(!E2E_ENABLED)("V1 headless real-host E2E", () => { const rootOnlyFilePath = join(TEST_CONFIG_DIR, rootOnlyFileName) const allSessionsFilePath = join(TEST_CONFIG_DIR, allSessionsFileName) const runSubagent = () => runOpenCode( - "Use the task tool to invoke the e2e-worker subagent. Ask it to reply exactly WORKER_DONE, then reply exactly PARENT_DONE." + "Use the task tool to invoke the e2e-worker subagent. Ask it to reply exactly WORKER_DONE, then reply exactly PARENT_DONE. V2_E2E_SUBAGENT_ROOT" ) - writeTestAgent("e2e-worker", `--- -description: E2E worker that returns a fixed response -mode: subagent -model: ${E2E_MODEL} ---- - -Reply exactly WORKER_DONE. -`) - try { writeTestConfig({ session: [ @@ -400,12 +492,16 @@ Reply exactly WORKER_DONE. await runSubagent() expect(await waitForMinimumLineCount(rootOnlyFilePath, 1)).toBe(1) - const logContent = getRecentLogContent() - const childCreation = logContent - .split("\n") - .find(line => line.includes("message=created") && line.includes("agent=e2e-worker")) - expect(childCreation).toBeDefined() - expect(childCreation).not.toContain("parentID=undefined") + if (HOSTED_MODEL) { + const childCreation = (await waitForLogMatch(content => content.includes("agent=e2e-worker"))) + .split("\n") + .find(line => line.includes("message=created") && line.includes("agent=e2e-worker")) + expect(childCreation).toBeDefined() + expect(childCreation).not.toContain("parentID=undefined") + } else { + // The child session's own model request proves the subagent ran. + expect(await waitForModelContext("V2_E2E_SUBAGENT_CHILD")).toContain("V2_E2E_SUBAGENT_CHILD") + } writeTestConfig({ session: [ diff --git a/tests/e2e.v2.test.ts b/tests/e2e.v2.test.ts new file mode 100644 index 0000000..f2c3af2 --- /dev/null +++ b/tests/e2e.v2.test.ts @@ -0,0 +1,646 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test" +import { access, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "fs/promises" +import { tmpdir } from "os" +import { join, resolve } from "path" +import { $ } from "bun" + +const enabled = process.env.OPENCODE2_E2E === "1" +const live = process.env.OPENCODE2_E2E_LIVE === "1" +const cliVersion = process.env.OPENCODE2_CLI_VERSION ?? "2.0.12" +const requestedLiveModel = process.env.OPENCODE2_E2E_MODEL?.trim() ?? "" +const repositoryRoot = process.cwd() +const workerFixture = resolve(repositoryRoot, "tests/fixtures/agents/e2e-worker.md") +const providerFixture = resolve(repositoryRoot, "tests/fixtures/local-openai-server.ts") + +let projectDirectory = "" +let configPath = "" +let hooksPath = "" +let binary = "" +let homeDirectory = "" +let environment: Record = {} +let hostProcess: Bun.ReadableSubprocess | undefined +let providerProcess: Bun.ReadableSubprocess | undefined +let hostUrl = "" +let providerUrl = "" +let hostOutput = "" +let hostError = "" +let providerOutput = "" +let providerError = "" +let liveModel = "" + +type CommandResult = { exitCode: number; stdout: string; stderr: string } +type ProviderLog = { + body: { messages?: unknown[]; tools?: unknown[] } + decision?: { mode: string; tool?: string; schema?: Record } +} +type SessionSummary = { id: string; parentID?: string; directory?: string } +type HostedModel = { + id?: unknown + modelID?: unknown + providerID?: unknown + name?: unknown + enabled?: unknown + status?: unknown + capabilities?: { tools?: unknown } + cost?: unknown +} + +const sleep = (milliseconds: number) => new Promise(resolve => setTimeout(resolve, milliseconds)) + +const consumeOutput = async ( + stream: ReadableStream, + append: (chunk: string) => void, +): Promise => { + const reader = stream.getReader() + const decoder = new TextDecoder() + while (true) { + const { done, value } = await reader.read() + if (done) break + append(decoder.decode(value, { stream: true })) + } + append(decoder.decode()) +} + +const waitFor = async (read: () => Promise, matches: (value: T) => boolean, timeoutMs = 15_000): Promise => { + const deadline = Date.now() + timeoutMs + let value = await read() + while (!matches(value) && Date.now() < deadline) { + await sleep(100) + value = await read() + } + return value +} + +const missingFiles = async (paths: string[]): Promise => { + const result = await Promise.all(paths.map(async path => { + try { + await access(path) + return undefined + } catch { + return path + } + })) + return result.filter((path): path is string => path !== undefined) +} + +const waitForFiles = (paths: string[], timeoutMs = 15_000) => + waitFor(() => missingFiles(paths), missing => missing.length === 0, timeoutMs) + +const runCommand = async (args: string[], timeoutMs = 90_000): Promise => { + const child = Bun.spawn([binary, ...args], { + cwd: projectDirectory, + env: environment, + stdout: "pipe", + stderr: "pipe", + }) + let timedOut = false + const timeout = setTimeout(() => { + timedOut = true + if (child.exitCode === null) child.kill(9) + }, timeoutMs) + try { + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]) + return { exitCode: timedOut ? -1 : exitCode, stdout, stderr } + } finally { + clearTimeout(timeout) + } +} + +const stopProcess = async (child: Bun.ReadableSubprocess | undefined): Promise => { + if (!child) return + if (child.exitCode === null) child.kill(9) + await Promise.race([ + child.exited.then(() => undefined), + sleep(5_000), + ]) +} + +const readProviderLog = async (): Promise => { + try { + const content = await readFile(join(projectDirectory, "provider.jsonl"), "utf8") + return content + .split("\n") + .filter(Boolean) + .map(line => JSON.parse(line) as ProviderLog) + } catch { + return [] + } +} + +const readLineCount = async (path: string): Promise => { + try { + return (await readFile(path, "utf8")).split(/\r?\n/).filter(Boolean).length + } catch { + return 0 + } +} + +const writeHooks = async (config: object): Promise => { + await writeFile(hooksPath, JSON.stringify(config, null, 2)) +} + +const writeHostConfig = async (plugins: string[], includeLocalProvider = true): Promise => { + const config: Record = { + "$schema": "https://opencode.ai/config.json", + plugins, + } + if (includeLocalProvider) { + config.model = "local/deterministic" + config.providers = { + local: { + name: "Deterministic local provider", + package: "@opencode/ai/providers/openai-compatible", + settings: { baseURL: `${providerUrl}/v1` }, + models: { + deterministic: { + modelID: "deterministic", + capabilities: { tools: true, input: ["text"], output: ["text"] }, + limit: { context: 32_768, output: 4_096 }, + }, + }, + }, + } + } + await writeFile(configPath, JSON.stringify(config, null, 2)) +} + +const pluginIsLoaded = async (): Promise => { + try { + const response = await fetch(`${hostUrl}/api/plugin`, { headers: apiHeaders(), signal: AbortSignal.timeout(2_000) }) + if (!response.ok) return undefined + return (await response.text()).includes("\"opencode-command-hooks\"") + } catch { + return undefined + } +} + +const apiHeaders = (): Record => ({ + "x-opencode-directory": projectDirectory, + authorization: `Basic ${Buffer.from("opencode:v2-e2e-password").toString("base64")}`, +}) + +const modelReference = (model: HostedModel): string => { + if (typeof model.id === "string" && model.id.includes("/")) return model.id + if (typeof model.providerID === "string" && typeof model.modelID === "string") { + return `${model.providerID}/${model.modelID}` + } + return typeof model.id === "string" ? model.id : "" +} + +const isFreeOpenCodeModel = (model: HostedModel): boolean => { + const reference = modelReference(model) + const provider = typeof model.providerID === "string" ? model.providerID : reference.split("/", 1)[0] + const modelID = typeof model.modelID === "string" ? model.modelID : reference.split("/").at(-1) ?? "" + const hasNoUsageCost = Array.isArray(model.cost) && model.cost.every(cost => { + if (typeof cost !== "object" || cost === null) return false + const value = cost as { input?: unknown; output?: unknown; cache?: { read?: unknown; write?: unknown } } + return value.input === 0 + && value.output === 0 + && typeof value.cache === "object" + && value.cache !== null + && value.cache.read === 0 + && value.cache.write === 0 + }) + return provider === "opencode" + && reference.startsWith("opencode/") + && modelID.endsWith("-free") + && model.enabled !== false + && model.status !== "deprecated" + && hasNoUsageCost + && model.capabilities?.tools === true +} + +const readHostedModels = async (): Promise<{ models: HostedModel[]; detail: string }> => { + const url = new URL(`${hostUrl}/api/model`) + url.searchParams.set("location[directory]", projectDirectory) + try { + const response = await fetch(url, { headers: apiHeaders(), signal: AbortSignal.timeout(3_000) }) + const body = await response.text() + if (!response.ok) return { models: [], detail: `HTTP ${response.status}: ${body}` } + let payload: unknown + try { + payload = JSON.parse(body) + } catch { + return { models: [], detail: `invalid JSON: ${body}` } + } + if (typeof payload !== "object" || payload === null || !Array.isArray((payload as { data?: unknown }).data)) { + return { models: [], detail: `unexpected /api/model payload: ${body}` } + } + return { models: (payload as { data: unknown[] }).data.filter(model => typeof model === "object" && model !== null) as HostedModel[], detail: body } + } catch (error) { + return { models: [], detail: String(error) } + } +} + +const discoverLiveModel = async (): Promise => { + if (requestedLiveModel && (!requestedLiveModel.startsWith("opencode/") || !requestedLiveModel.endsWith("-free"))) { + throw new Error(`OPENCODE2_E2E_MODEL must be an opencode/*-free model; refusing paid or non-OpenCode override: ${requestedLiveModel}`) + } + + const preferred = ["opencode/mimo-v2.5-free", "opencode/north-mini-code-free"] + const deadline = Date.now() + 60_000 + let lastDetail = "no catalog response" + while (Date.now() < deadline) { + const catalog = await readHostedModels() + lastDetail = catalog.detail + const models = catalog.models + const selected = requestedLiveModel + ? models.find(model => isFreeOpenCodeModel(model) && modelReference(model) === requestedLiveModel) + : preferred.map(reference => models.find(model => isFreeOpenCodeModel(model) && modelReference(model) === reference)).find(Boolean) + ?? models.find(isFreeOpenCodeModel) + if (selected) return modelReference(selected) + await sleep(500) + } + + throw new Error( + `OpenCode V2 did not advertise a credential-free, tool-capable opencode/*-free model from /api/model` + + `${requestedLiveModel ? ` matching ${requestedLiveModel}` : ""}. Last response: ${lastDetail}`, + ) +} + +const listSessions = async (): Promise => { + try { + const query = new URLSearchParams({ directory: projectDirectory, limit: "100", order: "asc" }) + const response = await fetch(`${hostUrl}/api/session?${query}`, { + headers: apiHeaders(), + signal: AbortSignal.timeout(2_000), + }) + if (!response.ok) return [] + const payload = await response.json() as { data?: unknown } + if (!Array.isArray(payload.data)) return [] + return payload.data.map(session => { + const value = typeof session === "object" && session !== null ? session as Record : {} + const location = typeof value.location === "object" && value.location !== null + ? value.location as Record + : {} + return { + id: String(value.id ?? ""), + parentID: typeof value.parentID === "string" ? value.parentID : undefined, + directory: typeof location.directory === "string" ? location.directory : undefined, + } + }).filter(session => session.id !== "") + } catch { + return [] + } +} + +const sessionEvidence = async (sessionID: string): Promise => { + const urls = [ + `${hostUrl}/api/session/${encodeURIComponent(sessionID)}/inbox`, + `${hostUrl}/api/session/${encodeURIComponent(sessionID)}/message`, + ] + const responses = await Promise.all(urls.map(async url => { + try { + const response = await fetch(url, { headers: apiHeaders(), signal: AbortSignal.timeout(2_000) }) + return response.ok ? response.text() : "" + } catch { + return "" + } + })) + return responses.join("\n") +} + +const sessionMessageEvidence = async (sessionID: string): Promise => { + try { + const response = await fetch(`${hostUrl}/api/session/${encodeURIComponent(sessionID)}/message`, { + headers: apiHeaders(), + signal: AbortSignal.timeout(2_000), + }) + return response.ok ? response.text() : "" + } catch { + return "" + } +} + +const diagnostics = (): string => + `host stdout:\n${hostOutput}\nhost stderr:\n${hostError}\nprovider stdout:\n${providerOutput}\nprovider stderr:\n${providerError}` + +const startFixture = async (): Promise<{ process: Bun.ReadableSubprocess; url: string }> => { + const child = Bun.spawn([process.execPath, providerFixture, "0", join(projectDirectory, "provider.jsonl")], { + cwd: projectDirectory, + env: environment, + stdout: "pipe", + stderr: "pipe", + }) + const readOutput = consumeOutput(child.stdout, chunk => { providerOutput += chunk }) + const errorOutput = consumeOutput(child.stderr, chunk => { providerError += chunk }) + void readOutput + void errorOutput + const url = await waitFor( + async () => providerOutput.match(/READY (http:\/\/\S+)/)?.[1] ?? "", + value => value !== "", + 10_000, + ) + if (!url) { + await stopProcess(child) + await errorOutput + throw new Error(`Local provider did not become ready.\n${diagnostics()}`) + } + return { process: child, url } +} + +const startHost = async (): Promise<{ process: Bun.ReadableSubprocess; url: string }> => { + const child = Bun.spawn([binary, "serve", "--hostname", "127.0.0.1", "--port", "0"], { + cwd: projectDirectory, + env: environment, + stdout: "pipe", + stderr: "pipe", + }) + const readOutput = consumeOutput(child.stdout, chunk => { hostOutput += chunk }) + void readOutput + const errorOutput = consumeOutput(child.stderr, chunk => { hostError += chunk }) + void errorOutput + const url = await waitFor( + async () => hostOutput.match(/server listening on (http:\/\/\S+)/)?.[1] ?? "", + value => value !== "", + 20_000, + ) + if (!url) { + await stopProcess(child) + await errorOutput + throw new Error(`OpenCode server did not become ready.\n${diagnostics()}`) + } + return { process: child, url } +} + +describe.skipIf(!enabled)("OpenCode V2 deterministic and opt-in hosted/free-model real-host E2E", () => { + beforeAll(async () => { + // Canonical paths keep the host and CLI in one location when tmpdir is a symlink (macOS). + projectDirectory = await realpath(await mkdtemp(join(tmpdir(), "opencode-hooks-v2-e2e-"))) + configPath = join(projectDirectory, "opencode.jsonc") + hooksPath = join(projectDirectory, ".opencode", "command-hooks.jsonc") + homeDirectory = join(projectDirectory, "home") + const temporaryDirectory = join(projectDirectory, "tmp") + await mkdir(join(projectDirectory, ".opencode", "agents"), { recursive: true }) + await mkdir(homeDirectory, { recursive: true }) + await mkdir(temporaryDirectory, { recursive: true }) + + const inheritedKeys = ["PATH", "TMPDIR", "TMP", "TEMP", "LANG", "LC_ALL", "LC_CTYPE", "TERM", "COLORTERM", "TZ"] + const inherited = inheritedKeys + .map(key => [key, process.env[key]] as const) + .filter((entry): entry is readonly [string, string] => entry[1] !== undefined) + environment = Object.fromEntries(inherited) as Record + Object.assign(environment, { + PWD: projectDirectory, + HOME: homeDirectory, + TMPDIR: temporaryDirectory, + TMP: temporaryDirectory, + TEMP: temporaryDirectory, + XDG_CONFIG_HOME: join(homeDirectory, ".config"), + XDG_DATA_HOME: join(homeDirectory, ".local", "share"), + XDG_CACHE_HOME: join(homeDirectory, ".cache"), + OPENCODE_CONFIG: configPath, + OPENCODE_LOG_LEVEL: "error", + OPENCODE_PASSWORD: "v2-e2e-password", + OPENCODE_SERVER_PASSWORD: "v2-e2e-password", + CI: "1", + }) + + if (!live) { + const fixture = await startFixture() + providerProcess = fixture.process + providerUrl = fixture.url + } + + const archive = (await $`npm run build && npm pack --ignore-scripts --pack-destination ${projectDirectory}` + .cwd(repositoryRoot) + .text()) + .trim() + .split("\n") + .at(-1) + if (!archive) throw new Error("npm pack did not return an archive name") + + await writeFile(join(projectDirectory, "package.json"), JSON.stringify({ private: true, type: "module" })) + await $`npm install --no-audit --no-fund ${join(projectDirectory, archive)} ${`@opencode/cli@${cliVersion}`}` + .cwd(projectDirectory) + .quiet() + binary = resolve(projectDirectory, "node_modules", ".bin", "opencode") + + await writeFile(join(projectDirectory, ".opencode", "command-hooks.jsonc"), JSON.stringify({ tool: [], session: [] })) + await writeFile(join(projectDirectory, ".opencode", "agents", "e2e-worker.md"), await readFile(workerFixture, "utf8")) + await writeHostConfig([join(projectDirectory, "node_modules", "opencode-command-hooks")], !live) + + const host = await startHost() + hostProcess = host.process + hostUrl = host.url + const loaded = await waitFor(pluginIsLoaded, value => value === true, 20_000) + if (loaded !== true) throw new Error(`Packed V2 plugin was not loaded.\n${diagnostics()}`) + if (live) { + liveModel = await discoverLiveModel() + console.log(`[V2 live] host=${hostUrl} cli=${cliVersion} model=${liveModel}`) + } + }, 180_000) + + afterAll(async () => { + await stopProcess(hostProcess) + await stopProcess(providerProcess) + if (projectDirectory) await rm(projectDirectory, { recursive: true, force: true }) + }) + + it.skipIf(live)("executes real shell hooks and proves injection reached the provider context", async () => { + const shellFile = join(projectDirectory, "model-shell.txt") + const beforeFile = join(projectDirectory, "tool-before.txt") + const afterFile = join(projectDirectory, "tool-after.txt") + const startFile = join(projectDirectory, "session-start.txt") + const idleFile = join(projectDirectory, "session-idle.txt") + const injectedMarker = "V2_REAL_AFTER_INJECTION:V2_REAL_AFTER_OUTPUT" + + await writeHooks({ + tool: [ + { id: "real-before", when: { phase: "before", tool: "*" }, run: "printf before > tool-before.txt" }, + { + id: "real-after", + when: { phase: "after", tool: "*" }, + run: "printf after > tool-after.txt; printf V2_REAL_AFTER_OUTPUT", + inject: "V2_REAL_AFTER_INJECTION:{stdout}", + }, + ], + session: [ + { id: "real-start", when: { event: "session.start" }, run: "printf started > session-start.txt" }, + { id: "real-idle", when: { event: "session.idle" }, run: "printf idle > session-idle.txt" }, + ], + }) + + const result = await runCommand([ + "run", "--server", hostUrl, "--model", "local/deterministic", "--auto", + `V2_E2E_SHELL V2_E2E_SHELL_FILE=${shellFile} END. Use the shell tool to execute the requested command, then reply DONE.`, + ]) + expect(result.exitCode, `${result.stdout}\n${result.stderr}\n${diagnostics()}`).toBe(0) + + const evidence = [shellFile, beforeFile, afterFile, startFile, idleFile] + const missing = await waitForFiles(evidence) + expect(missing, `${result.stdout}\n${result.stderr}\n${diagnostics()}`).toEqual([]) + expect(await readFile(beforeFile, "utf8")).toBe("before") + expect(await readFile(afterFile, "utf8")).toBe("after") + expect(await readFile(startFile, "utf8")).toBe("started") + expect(await readFile(idleFile, "utf8")).toBe("idle") + expect(await readFile(shellFile, "utf8")).toBe("V2_MODEL_SHELL") + + const requests = await waitFor(readProviderLog, logs => logs.some(log => JSON.stringify(log).includes(injectedMarker))) + const shellRequest = requests.find(log => log.decision?.mode === "shell" && log.decision.tool) + expect(shellRequest, JSON.stringify(requests)).toBeDefined() + const shellSchema = shellRequest?.decision?.schema + expect(shellSchema && typeof shellSchema === "object", JSON.stringify(requests.map(log => log.decision))).toBe(true) + expect(Object.prototype.hasOwnProperty.call((shellSchema as Record).properties, "command"), JSON.stringify(shellRequest?.decision)).toBe(true) + expect(requests.some(log => JSON.stringify(log).includes(injectedMarker))).toBe(true) + }, 120_000) + + it.skipIf(live)("runs a real subagent, its frontmatter hooks, and root/child idle rules", async () => { + const childBeforeFile = join(projectDirectory, "child-before.txt") + const childAfterFile = join(projectDirectory, "child-after.txt") + const markerSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + const rootIdleMarker = `V2_ROOT_IDLE_${markerSuffix}` + const allIdleMarker = `V2_ALL_IDLE_${markerSuffix}` + + await writeHooks({ + session: [ + { id: "root-idle", when: { event: "session.idle" }, inject: rootIdleMarker }, + { id: "all-idle", when: { event: "session.idle", rootSessionOnly: false }, inject: allIdleMarker }, + ], + }) + + const sessionsBefore = await listSessions() + const sessionIdsBefore = new Set(sessionsBefore.map(session => session.id)) + const result = await runCommand([ + "run", "--server", hostUrl, "--model", "local/deterministic", "--auto", + "V2_E2E_SUBAGENT_ROOT: invoke the e2e-worker subagent and then reply PARENT_DONE.", + ]) + expect(result.exitCode, `${result.stdout}\n${result.stderr}\n${diagnostics()}`).toBe(0) + + expect(await waitForFiles([childBeforeFile, childAfterFile])).toEqual([]) + const sessionsAfter = await waitFor( + listSessions, + sessions => sessions.some(session => session.parentID && !sessionIdsBefore.has(session.id)), + 20_000, + ) + const childSession = sessionsAfter.find(session => session.parentID && !sessionIdsBefore.has(session.id)) + expect(childSession, JSON.stringify(sessionsAfter)).toMatchObject({ directory: projectDirectory }) + expect(childSession?.parentID).toBeTruthy() + const rootSessionID = childSession?.parentID + if (!rootSessionID) throw new Error(`Child session did not include parent ID: ${JSON.stringify(childSession)}`) + const idleEvidence = await waitFor( + async () => ({ + root: await sessionEvidence(rootSessionID), + child: await sessionEvidence(childSession.id), + }), + evidence => evidence.root.includes(rootIdleMarker) + && evidence.root.includes(allIdleMarker) + && evidence.child.includes(allIdleMarker) + && !evidence.child.includes(rootIdleMarker), + 20_000, + ) + expect(idleEvidence.root, JSON.stringify(idleEvidence)).toContain(rootIdleMarker) + expect(idleEvidence.root, JSON.stringify(idleEvidence)).toContain(allIdleMarker) + expect(idleEvidence.child, JSON.stringify(idleEvidence)).toContain(allIdleMarker) + expect(idleEvidence.child, JSON.stringify(idleEvidence)).not.toContain(rootIdleMarker) + + const requests = await readProviderLog() + const subagentRequest = requests.find(log => log.decision?.mode === "subagent" && log.decision.tool) + expect(subagentRequest, JSON.stringify(requests)).toBeDefined() + const subagentSchema = subagentRequest?.decision?.schema + expect(subagentSchema && typeof subagentSchema === "object", JSON.stringify(requests.map(log => log.decision))).toBe(true) + expect(Object.prototype.hasOwnProperty.call((subagentSchema as Record).properties, "agent"), JSON.stringify(subagentRequest?.decision)).toBe(true) + expect(requests.some(log => JSON.stringify(log).includes("V2_CHILD_FRONTMATTER:V2_CHILD_AFTER_OUTPUT"))).toBe(true) + }, 120_000) + + it.skipIf(live)("cleans registrations on disable/reload and never duplicates a hook", async () => { + const hitFile = join(projectDirectory, "reload-hit.txt") + const firstModelFile = join(projectDirectory, "reload-first.txt") + const secondModelFile = join(projectDirectory, "reload-second.txt") + const thirdModelFile = join(projectDirectory, "reload-third.txt") + const idleFile = join(projectDirectory, "reload-idle.txt") + await writeHooks({ + tool: [{ id: "reload-after", when: { phase: "after", tool: "*" }, run: "printf 'hit\n' >> reload-hit.txt" }], + session: [{ id: "reload-idle", when: { event: "session.idle" }, run: "printf 'idle\n' >> reload-idle.txt" }], + }) + + const runReloadProbe = async (file: string) => { + const result = await runCommand([ + "run", "--server", hostUrl, "--model", "local/deterministic", "--auto", + `V2_E2E_RELOAD V2_E2E_SHELL V2_E2E_SHELL_FILE=${file} END. Use the shell tool, then reply DONE.`, + ]) + expect(result.exitCode, `${result.stdout}\n${result.stderr}\n${diagnostics()}`).toBe(0) + expect(await waitForFiles([file]), `${file}\n${diagnostics()}`).toEqual([]) + expect(await readFile(file, "utf8")).toBe("V2_MODEL_SHELL") + } + + await runReloadProbe(firstModelFile) + expect(await waitFor(() => readLineCount(hitFile), count => count === 1)).toBe(1) + expect(await waitFor(() => readLineCount(idleFile), count => count === 1)).toBe(1) + + await writeHostConfig([]) + expect(await waitFor(pluginIsLoaded, value => value === false, 20_000)).toBe(false) + await runReloadProbe(secondModelFile) + expect(await readLineCount(hitFile)).toBe(1) + expect(await readLineCount(idleFile)).toBe(1) + + await writeHostConfig([join(projectDirectory, "node_modules", "opencode-command-hooks")]) + expect(await waitFor(pluginIsLoaded, value => value === true, 20_000)).toBe(true) + await runReloadProbe(thirdModelFile) + expect(await waitFor(() => readLineCount(hitFile), count => count === 2)).toBe(2) + expect(await waitFor(() => readLineCount(idleFile), count => count === 2)).toBe(2) + }, 150_000) + + it.skipIf(!live)("uses a hosted free model for shell hooks and persists synthetic injection evidence", async () => { + const markerSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + const shellFile = join(projectDirectory, "live-model-shell.txt") + const beforeFile = join(projectDirectory, "live-tool-before.txt") + const afterFile = join(projectDirectory, "live-tool-after.txt") + const startFile = join(projectDirectory, "live-session-start.txt") + const idleFile = join(projectDirectory, "live-session-idle.txt") + const shellContent = "V2_LIVE_MODEL_SHELL" + const hookStdout = `V2_LIVE_AFTER_OUTPUT_${markerSuffix}` + const injectedMarker = `V2_LIVE_AFTER_INJECTION_${markerSuffix}:${hookStdout}` + const prompt = `Use the shell tool to create ${shellFile} with exactly this content and no trailing newline: ${shellContent}. After the shell command succeeds, reply DONE.` + + await writeHooks({ + tool: [ + { id: "live-before-shell", when: { phase: "before", tool: "shell" }, run: "printf before > live-tool-before.txt" }, + { + id: "live-after-shell", + when: { phase: "after", tool: "shell" }, + run: `printf after > live-tool-after.txt; printf '%s' '${hookStdout}'`, + inject: `V2_LIVE_AFTER_INJECTION_${markerSuffix}:{stdout}`, + }, + ], + session: [ + { id: "live-session-start", when: { event: "session.start" }, run: "printf started > live-session-start.txt" }, + { id: "live-session-idle", when: { event: "session.idle" }, run: "printf idle > live-session-idle.txt" }, + ], + }) + + const sessionsBefore = await listSessions() + const sessionIdsBefore = new Set(sessionsBefore.map(session => session.id)) + expect(prompt).not.toContain(injectedMarker) + const result = await runCommand(["run", "--server", hostUrl, "--model", liveModel, "--auto", prompt], 150_000) + expect(result.exitCode, `${result.stdout}\n${result.stderr}\n${diagnostics()}`).toBe(0) + + const evidence = [shellFile, beforeFile, afterFile, startFile, idleFile] + expect(await waitForFiles(evidence, 30_000), `${result.stdout}\n${result.stderr}\n${diagnostics()}`).toEqual([]) + expect(await readFile(beforeFile, "utf8")).toBe("before") + expect(await readFile(afterFile, "utf8")).toBe("after") + expect(await readFile(startFile, "utf8")).toBe("started") + expect(await readFile(idleFile, "utf8")).toBe("idle") + expect(await readFile(shellFile, "utf8")).toBe(shellContent) + + const sessionsAfter = await waitFor( + listSessions, + sessions => sessions.some(session => !session.parentID && !sessionIdsBefore.has(session.id) && session.directory === projectDirectory), + 30_000, + ) + const session = sessionsAfter.find(session => !session.parentID && !sessionIdsBefore.has(session.id) && session.directory === projectDirectory) + expect(session, JSON.stringify(sessionsAfter)).toBeDefined() + if (!session) throw new Error(`Hosted model session was not persisted: ${JSON.stringify(sessionsAfter)}`) + + const persistedMessages = await waitFor( + () => sessionMessageEvidence(session.id), + messages => messages.includes(injectedMarker), + 30_000, + ) + expect(persistedMessages, `${persistedMessages}\n${diagnostics()}`).toContain(injectedMarker) + }, 210_000) +}) diff --git a/tests/execution.shell.test.ts b/tests/execution.shell.test.ts index 76b247a..3e3e4f0 100644 --- a/tests/execution.shell.test.ts +++ b/tests/execution.shell.test.ts @@ -68,6 +68,19 @@ describe("Shell command execution", () => { expect(result.stdout).toContain("hello") }) + it("executes in an explicit working directory", async () => { + const directory = mkdtempSync(join(tmpdir(), "opencode-hooks-cwd-")) + + try { + const result = await executeCommand("pwd", { cwd: directory }) + + expect(result.success).toBe(true) + expect(result.stdout?.trim()).toBe(directory) + } finally { + rmSync(directory, { recursive: true, force: true }) + } + }) + it("preserves inherited environment variables alongside per-command hook arguments", async () => { const inheritedKey = "OPENCODE_HOOKS_TEST_INHERITED_SENTINEL" const originalInherited = process.env[inheritedKey] diff --git a/tests/executor.host.test.ts b/tests/executor.host.test.ts new file mode 100644 index 0000000..818316c --- /dev/null +++ b/tests/executor.host.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "bun:test" +import { executeHooks, type HookHost } from "../src/executor.js" +import type { ToolHook } from "../src/types/hooks.js" + +describe("host-neutral hook execution", () => { + it("delegates injection and toast delivery to the host", async () => { + const injections: Array<{ sessionId: string; message: string; hookId: string }> = [] + const toasts: Array<{ title?: string; message: string }> = [] + const host: HookHost = { + cwd: process.cwd(), + inject: async (sessionId, message, hookId) => { + injections.push({ sessionId, message, hookId }) + }, + toast: async (toast) => { + toasts.push(toast) + }, + } + const hook: ToolHook = { + id: "host-port", + when: { phase: "after", tool: "bash" }, + inject: "hook {id} for {tool}", + toast: { title: "Hook", message: "finished {id}" }, + } + + await executeHooks( + [hook], + { sessionId: "session-1", agent: "build", tool: "bash" }, + host, + ) + + expect(injections).toEqual([ + { sessionId: "session-1", message: "hook host-port for bash", hookId: "host-port" }, + ]) + expect(toasts).toEqual([ + { title: "Hook", message: "finished host-port", variant: "info", duration: undefined }, + ]) + }) +}) diff --git a/tests/fixtures/agents/e2e-worker.md b/tests/fixtures/agents/e2e-worker.md new file mode 100644 index 0000000..867a0a3 --- /dev/null +++ b/tests/fixtures/agents/e2e-worker.md @@ -0,0 +1,15 @@ +--- +description: Deterministic real-host E2E worker. +mode: subagent +model: local/deterministic +hooks: + before: + - run: "printf child-before > child-before.txt" + after: + - run: "printf child-after > child-after.txt; printf V2_CHILD_AFTER_OUTPUT" + inject: "V2_CHILD_FRONTMATTER:{stdout}" +--- + +# Deterministic E2E worker + +Reply exactly WORKER_DONE. diff --git a/tests/fixtures/local-openai-server.ts b/tests/fixtures/local-openai-server.ts new file mode 100644 index 0000000..6ab2e3c --- /dev/null +++ b/tests/fixtures/local-openai-server.ts @@ -0,0 +1,165 @@ +import { appendFile } from "fs/promises" + +const port = Number(process.argv[2] ?? "0") +const logPath = process.argv[3] +let callNumber = 0 +type JsonObject = Record + +const asObject = (value: unknown): JsonObject => + typeof value === "object" && value !== null && !Array.isArray(value) + ? value as JsonObject + : {} + +const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, +}) + +const textOfMessages = (messages: unknown[]): string => JSON.stringify(messages) + +const toolDetails = (tool: unknown): { name: string; schema: JsonObject } => { + const wrapper = asObject(tool) + const functionDetails = asObject(wrapper.function) + const definition = Object.keys(functionDetails).length > 0 ? functionDetails : wrapper + return { + name: String(definition.name ?? ""), + schema: asObject(definition.parameters ?? definition.input_schema), + } +} + +type ToolMode = "shell" | "subagent" | "write" + +// OpenCode V2 names these tools `shell` and `subagent`; V1 names them `bash` and `task`. +const toolNames: Record = { + shell: ["shell", "bash"], + subagent: ["subagent", "task"], + write: ["write"], +} + +const chooseTool = (tools: unknown[], mode: ToolMode): { name: string; schema: JsonObject } | undefined => + tools.map(toolDetails).find(tool => toolNames[mode].includes(tool.name)) + +const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'` + +const shellArguments = (schema: JsonObject, target: string): Record => { + const properties = asObject(schema.properties) + const args: Record = {} + if ("command" in properties) args.command = `printf V2_MODEL_SHELL > ${shellQuote(target)}` + if ("description" in properties) args.description = "Write the deterministic E2E marker" + if ("timeout" in properties) args.timeout = 10_000 + return args +} + +const writeArguments = (schema: JsonObject, prompt: string): Record => { + const properties = asObject(schema.properties) + const args: Record = {} + const filePath = prompt.match(/E2E_WRITE_FILE=([^\s"\\]+)/)?.[1] ?? "e2e-write.txt" + const content = prompt.match(/E2E_WRITE_CONTENT=([^\s"\\]+)/)?.[1] ?? "E2E_WRITE" + if ("filePath" in properties) args.filePath = filePath + if ("path" in properties) args.path = filePath + if ("content" in properties) args.content = content + return args +} + +const subagentArguments = (schema: JsonObject): Record => { + const properties = asObject(schema.properties) + const args: Record = {} + const childPrompt = "V2_E2E_SUBAGENT_CHILD reply exactly WORKER_DONE." + for (const key of Object.keys(properties)) { + if (key === "agent" || key === "subagent_type") args[key] = "e2e-worker" + else if (key === "prompt" || key === "task") args[key] = childPrompt + else if (key === "description") args[key] = "Run the deterministic E2E worker" + else if (key === "background") args[key] = false + } + return args +} + +const response = (body: Record, stream: boolean): Response => { + if (!stream) return json(body) + const encoder = new TextEncoder() + const streamBody = new ReadableStream({ + start(controller) { + const choices = (body.choices as Array>) ?? [] + const choice = asObject(choices[0]) + const message = asObject(choice.message) + const delta: Record = { role: "assistant" } + if (message.tool_calls) { + delta.tool_calls = (message.tool_calls as Array).map((toolCall, index) => ({ + ...toolCall, + index, + })) + } + if (typeof message.content === "string") delta.content = message.content + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ ...body, object: "chat.completion.chunk", choices: [{ index: 0, delta, finish_reason: null }] })}\n\n`)) + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ ...body, object: "chat.completion.chunk", choices: [{ index: 0, delta: {}, finish_reason: choice.finish_reason }] })}\n\n`)) + controller.enqueue(encoder.encode("data: [DONE]\n\n")) + controller.close() + }, + }) + return new Response(streamBody, { headers: { "content-type": "text/event-stream" } }) +} + +const server = Bun.serve({ + hostname: "127.0.0.1", + port, + async fetch(request) { + const url = new URL(request.url) + if (url.pathname === "/v1/models") { + return json({ object: "list", data: [{ id: "deterministic", object: "model", owned_by: "e2e" }] }) + } + if (url.pathname !== "/v1/chat/completions" || request.method !== "POST") return new Response("not found", { status: 404 }) + + const body = await request.json() as { messages?: unknown[]; tools?: unknown[]; stream?: boolean } + const messages = body.messages ?? [] + const prompt = textOfMessages(messages) + const tools = body.tools ?? [] + const isChild = prompt.includes("V2_E2E_SUBAGENT_CHILD") + const hasToolMessage = messages.some(message => asObject(message).role === "tool") + const isSubagent = prompt.includes("V2_E2E_SUBAGENT_ROOT") && !hasToolMessage + const isShell = prompt.includes("V2_E2E_SHELL") && !hasToolMessage + const isWrite = prompt.includes("E2E_WRITE_FILE=") && !hasToolMessage + const mode: ToolMode | "text" = isSubagent ? "subagent" : isShell ? "shell" : isWrite ? "write" : "text" + const selected = mode === "text" ? undefined : chooseTool(tools, mode) + const target = prompt.match(/V2_E2E_SHELL_FILE=([^\s"\\]+)/)?.[1] ?? "v2-e2e-model.txt" + const id = `e2e-call-${++callNumber}` + const toolCall = selected && mode !== "text" + ? { + id, + type: "function", + function: { + name: selected.name, + arguments: JSON.stringify( + mode === "shell" + ? shellArguments(selected.schema, target) + : mode === "write" + ? writeArguments(selected.schema, prompt) + : subagentArguments(selected.schema), + ), + }, + } + : undefined + const content = isChild ? "WORKER_DONE" : "DONE" + const completion = { + id: `e2e-completion-${callNumber}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model: "deterministic", + choices: [{ + index: 0, + message: toolCall ? { role: "assistant", content: null, tool_calls: [toolCall] } : { role: "assistant", content }, + finish_reason: toolCall ? "tool_calls" : "stop", + }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + } + + if (logPath) { + await appendFile(logPath, `${JSON.stringify({ + body, + decision: { mode, tool: selected?.name, schema: selected?.schema }, + })}\n`) + } + return response(completion, body.stream === true) + }, +}) + +console.log(`READY http://127.0.0.1:${server.port}`) diff --git a/tests/session.subagent-wait.test.ts b/tests/session.subagent-wait.test.ts new file mode 100644 index 0000000..3bd34e7 --- /dev/null +++ b/tests/session.subagent-wait.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test" +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs" +import { tmpdir } from "os" +import { join } from "path" +import { parseSessionHook } from "../src/schemas" + +const originalCwd = process.cwd() + +describe("session idle subagent waits", () => { + let directory: string + + beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), "opencode-hooks-subagent-wait-")) + mkdirSync(join(directory, ".opencode"), { recursive: true }) + writeFileSync( + join(directory, ".opencode", "command-hooks.jsonc"), + JSON.stringify({ + session: [ + { id: "always", when: { event: "session.idle" }, inject: "always" }, + { + id: "after-subagents", + when: { event: "session.idle", excludeSubagentWait: true }, + inject: "after-subagents", + }, + ], + }), + ) + process.chdir(directory) + }) + + afterEach(() => { + process.chdir(originalCwd) + rmSync(directory, { recursive: true, force: true }) + }) + + it("accepts excludeSubagentWait as an opt-in session hook condition", () => { + const hook = parseSessionHook({ + id: "notify", + when: { event: "session.idle", excludeSubagentWait: true }, + inject: "notify", + }) + + expect(hook?.when.excludeSubagentWait).toBe(true) + }) + + it("suppresses only opted-in idle hooks until every task call finishes", async () => { + const injected: string[] = [] + const client = { + session: { + promptAsync: async ({ body }: { body: { parts: Array<{ text: string }> } }) => { + injected.push(body.parts[0].text) + return {} + }, + }, + tui: { showToast: async () => ({}) }, + } + const { CommandHooksPlugin } = await import("../src/index.js") + const plugin = await CommandHooksPlugin({ client } as never) + const idle = () => plugin.event?.({ + event: { type: "session.idle", properties: { sessionID: "parent" } }, + } as never) + + await plugin["tool.execute.before"]?.( + { tool: "task", sessionID: "parent", callID: "task-1" }, + { args: { subagent_type: "worker" } }, + ) + await plugin["tool.execute.before"]?.( + { tool: "task", sessionID: "parent", callID: "task-2" }, + { args: { subagent_type: "worker" } }, + ) + await idle() + + await plugin["tool.execute.after"]?.( + { tool: "task", sessionID: "parent", callID: "task-1" }, + undefined as never, + ) + await idle() + + await plugin["tool.execute.after"]?.( + { tool: "task", sessionID: "parent", callID: "task-2" }, + undefined as never, + ) + await idle() + + expect(injected).toEqual(["always", "always", "always", "after-subagents"]) + }) + + it("clears active subagent state when the session is deleted", async () => { + const injected: string[] = [] + const client = { + session: { + promptAsync: async ({ body }: { body: { parts: Array<{ text: string }> } }) => { + injected.push(body.parts[0].text) + return {} + }, + }, + tui: { showToast: async () => ({}) }, + } + const { CommandHooksPlugin } = await import("../src/index.js") + const plugin = await CommandHooksPlugin({ client } as never) + + await plugin["tool.execute.before"]?.( + { tool: "task", sessionID: "deleted", callID: "task-deleted" }, + { args: { subagent_type: "worker" } }, + ) + await plugin.event?.({ + event: { type: "session.deleted", properties: { info: { id: "deleted" } } }, + } as never) + await plugin.event?.({ + event: { type: "session.idle", properties: { sessionID: "deleted" } }, + } as never) + + expect(injected).toEqual(["always", "after-subagents"]) + }) +}) diff --git a/tests/tool.after-result.test.ts b/tests/tool.after-result.test.ts index 99a3b83..043259f 100644 --- a/tests/tool.after-result.test.ts +++ b/tests/tool.after-result.test.ts @@ -251,13 +251,8 @@ describe("tool after hooks", () => { const plugin = await CommandHooksPlugin({ client } as never); - await plugin["tool.execute.before"]?.( - { tool: "bash", sessionID: "s2", callID: "c2" }, - { args: { target: "prod" } }, - ); - await plugin["tool.execute.after"]?.( - { tool: "bash", sessionID: "s2", callID: "c2" }, + { tool: "bash", sessionID: "s2", callID: "c2", args: { target: "prod" } }, { title: "ok", output: "done", metadata: {} }, ); diff --git a/tests/v2.package.test.ts b/tests/v2.package.test.ts new file mode 100644 index 0000000..03d3128 --- /dev/null +++ b/tests/v2.package.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "bun:test" +import { $ } from "bun" + +// Each OpenCode host resolves the package differently; these assertions mirror +// what each loader requires so one npm package works everywhere by name. +describe("dual-host package artifact", () => { + it("serves every OpenCode host from one package", async () => { + await $`npm run build`.quiet() + const output = await $`npm pack --dry-run --json --ignore-scripts`.text() + const [pack] = JSON.parse(output) as Array<{ + id: string + files: Array<{ path: string }> + }> + const manifest = await Bun.file("package.json").json() as { + name: string + exports: Record + engines: Record + } + const files = pack.files.map(file => file.path) + + expect(pack.id).toStartWith("opencode-command-hooks@") + expect(manifest.name).toBe("opencode-command-hooks") + for (const file of ["dist/entry.js", "dist/server.js", "index.js", "server.js"]) { + expect(files).toContain(file) + } + // An engines range would make older V1 hosts skip the plugin. + expect(manifest.engines.opencode).toBeUndefined() + + // V1 1.3.4+ and V2 both resolve `./server` first: V1 reads `server`, and + // V2's schema requires a plain object with `setup`. + const server = await import("opencode-command-hooks/server") + expect(Object.keys(server)).toEqual(["default"]) + expect(typeof server.default).toBe("object") + expect(server.default.id).toBe("opencode-command-hooks") + expect(typeof server.default.server).toBe("function") + expect(typeof server.default.setup).toBe("function") + + // V1 before 1.3.4 imports the root and calls every export as a plugin. + const root = await import("opencode-command-hooks") + expect(Object.keys(root)).toEqual(["default"]) + expect(typeof root.default).toBe("function") + + // Local directory installs load `server.js`, falling back to `index.js`. + expect((await import("../server.js")).default).toBe(server.default) + expect((await import("../index.js")).default).toBe(root.default) + }, 30_000) +}) diff --git a/tests/v2.plugin.test.ts b/tests/v2.plugin.test.ts new file mode 100644 index 0000000..38bc20e --- /dev/null +++ b/tests/v2.plugin.test.ts @@ -0,0 +1,390 @@ +import { afterEach, describe, expect, it, spyOn } from "bun:test" +import { mkdir, mkdtemp, rm, writeFile } from "fs/promises" +import { tmpdir } from "os" +import { join } from "path" +import { createV2Plugin, type V2Context } from "../src/v2/plugin.js" + +type ToolCallback = (event: Record) => Promise | void + +const temporaryDirectories: string[] = [] + +const createProject = async (config: Record): Promise => { + const directory = await mkdtemp(join(tmpdir(), "opencode-hooks-v2-")) + temporaryDirectories.push(directory) + await mkdir(join(directory, ".opencode"), { recursive: true }) + await writeFile( + join(directory, ".opencode", "command-hooks.jsonc"), + JSON.stringify(config), + ) + return directory +} + +const createContext = ( + directory: string, + events: Record[] = [], + parentID?: string, +) => { + const callbacks = new Map() + const disposed: string[] = [] + const syntheticCalls: Array> = [] + const context: V2Context = { + location: { directory }, + tool: { + hook: async (name, callback) => { + callbacks.set(name, callback as ToolCallback) + return { dispose: async () => { disposed.push(name) } } + }, + }, + event: { + subscribe: () => (async function* () { + for (const event of events) yield event + })(), + }, + session: { + get: async ({ sessionID }) => ({ + id: sessionID, + agent: "build", + parentID, + location: { directory }, + }), + synthetic: async (input) => { + syntheticCalls.push(input) + }, + }, + } + + return { context, callbacks, disposed, syntheticCalls } +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map(directory => rm(directory, { recursive: true, force: true })), + ) +}) + +describe("OpenCode V2 plugin", () => { + it("aborts a pending event subscription during cleanup", async () => { + const directory = await createProject({}) + const { context, disposed } = createContext(directory) + let subscriptionSignal: AbortSignal | undefined + let closed = false + context.event.subscribe = ({ signal } = {}) => (async function* () { + subscriptionSignal = signal + try { + await new Promise((_resolve, reject) => { + signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true }) + }) + yield { type: "session.idle", data: { sessionID: "unexpected" } } + } finally { + closed = true + } + })() + const errorSpy = spyOn(console, "error").mockImplementation(() => {}) + try { + const cleanup = await createV2Plugin().setup(context) + expect(subscriptionSignal?.aborted).toBe(false) + await cleanup?.() + expect(subscriptionSignal?.aborted).toBe(true) + expect(closed).toBe(true) + expect(disposed).toEqual(["execute.before", "execute.after"]) + expect(errorSpy).not.toHaveBeenCalled() + } finally { + errorSpy.mockRestore() + } + }, 1_000) + + it("registers both tool phases and uses after-event input without a V1 cache", async () => { + const directory = await createProject({ + tool: [ + { + id: "v2-after", + when: { phase: "after", tool: "bash", toolArgs: { command: "pwd" } }, + run: "pwd", + inject: "cwd={stdout}", + }, + ], + }) + const { context, callbacks, disposed, syntheticCalls } = createContext(directory) + const plugin = createV2Plugin() + + const cleanup = await plugin.setup(context) + + expect(plugin.id).toBe("opencode-command-hooks.v2") + expect([...callbacks.keys()]).toEqual(["execute.before", "execute.after"]) + + await callbacks.get("execute.after")?.({ + tool: "bash", + sessionID: "session-1", + id: "call-1", + agent: "build", + input: { command: "pwd" }, + result: {}, + }) + + expect(syntheticCalls).toHaveLength(1) + expect(syntheticCalls[0]).toMatchObject({ + sessionID: "session-1", + text: `cwd=${directory}\n`, + description: "opencode-command-hooks", + metadata: { hookId: "v2-after" }, + resume: false, + }) + + await cleanup?.() + expect(disposed).toEqual(["execute.before", "execute.after"]) + }) + + it("maps created and idle events once and keeps callback failures non-blocking", async () => { + const directory = await createProject({ + session: [ + { id: "start", when: { event: "session.start" }, inject: "started" }, + { id: "idle", when: { event: "session.idle" }, inject: "idle" }, + ], + }) + const events = [ + { + id: "event-created", + type: "session.created", + location: { directory }, + data: { sessionID: "session-2" }, + }, + { + id: "event-idle", + type: "session.idle", + location: { directory }, + data: { sessionID: "session-2" }, + }, + { + id: "event-idle", + type: "session.idle", + location: { directory }, + data: { sessionID: "session-2" }, + }, + ] + const { context, syntheticCalls } = createContext(directory, events) + const cleanup = await createV2Plugin().setup(context) + + await new Promise(resolve => setTimeout(resolve, 20)) + + expect(syntheticCalls.map(call => call.text)).toEqual(["started", "idle"]) + expect(syntheticCalls.every(call => call.resume === false)).toBe(true) + await cleanup?.() + + const failing = createContext(directory) + failing.context.location.directory = "" + failing.context.session.get = async () => { + throw new Error("session lookup failed") + } + const errorSpy = spyOn(console, "error").mockImplementation(() => {}) + const failingCleanup = await createV2Plugin().setup(failing.context) + + await expect( + failing.callbacks.get("execute.before")?.({ + tool: "bash", + sessionID: "missing", + id: "call-2", + agent: "build", + input: {}, + }), + ).resolves.toBeUndefined() + expect(errorSpy).toHaveBeenCalled() + + errorSpy.mockRestore() + await failingCleanup?.() + }) + + it("maps durable execution lifecycle events to start and idle hooks", async () => { + const directory = await createProject({ + session: [ + { id: "durable-start", when: { event: "session.start" }, inject: "durable started" }, + { id: "durable-idle", when: { event: "session.idle" }, inject: "durable idle" }, + ], + }) + const events = [ + { + id: "execution-started", + type: "session.execution.started", + location: { directory }, + data: { sessionID: "session-durable" }, + }, + { + id: "execution-succeeded", + type: "session.execution.succeeded", + location: { directory }, + data: { sessionID: "session-durable" }, + }, + ] + const { context, syntheticCalls } = createContext(directory, events) + const cleanup = await createV2Plugin().setup(context) + + await new Promise(resolve => setTimeout(resolve, 20)) + + expect(syntheticCalls.map(call => call.text)).toEqual(["durable started", "durable idle"]) + await cleanup?.() + }) + + it("keeps default idle hooks on root sessions unless child sessions are enabled", async () => { + const directory = await createProject({ + session: [ + { id: "root-only", when: { event: "session.idle" }, inject: "root only" }, + { + id: "all-sessions", + when: { event: "session.idle", rootSessionOnly: false }, + inject: "all sessions", + }, + ], + }) + const events = [{ + id: "child-idle", + type: "session.idle", + location: { directory }, + data: { sessionID: "child" }, + }] + const { context, syntheticCalls } = createContext(directory, events, "parent") + const cleanup = await createV2Plugin().setup(context) + + await new Promise(resolve => setTimeout(resolve, 20)) + + expect(syntheticCalls.map(call => call.text)).toEqual(["all sessions"]) + await cleanup?.() + }) + + it("suppresses opted-in idle hooks while a V2 subagent tool is active", async () => { + const directory = await createProject({ + session: [ + { id: "always", when: { event: "session.idle" }, inject: "always" }, + { + id: "after-subagents", + when: { event: "session.idle", excludeSubagentWait: true }, + inject: "after-subagents", + }, + ], + }) + const { context, callbacks, syntheticCalls } = createContext(directory) + let releaseFirst!: () => void + let releaseSecond!: () => void + let waitingForSecond!: () => void + let eventsFinished!: () => void + const first = new Promise(resolve => { releaseFirst = resolve }) + const second = new Promise(resolve => { releaseSecond = resolve }) + const firstProcessed = new Promise(resolve => { waitingForSecond = resolve }) + const complete = new Promise(resolve => { eventsFinished = resolve }) + context.event.subscribe = () => (async function* () { + await first + yield { + id: "idle-active", + type: "session.idle", + location: { directory }, + data: { sessionID: "parent" }, + } + waitingForSecond() + await second + yield { + id: "idle-complete", + type: "session.idle", + location: { directory }, + data: { sessionID: "parent" }, + } + eventsFinished() + })() + + const cleanup = await createV2Plugin().setup(context) + const subagent = { + tool: "subagent", + sessionID: "parent", + id: "subagent-call", + agent: "build", + input: { agent: "worker" }, + } + await callbacks.get("execute.before")?.(subagent) + releaseFirst() + await firstProcessed + + expect(syntheticCalls.map(call => call.text)).toEqual(["always"]) + + await callbacks.get("execute.after")?.(subagent) + releaseSecond() + await complete + + expect(syntheticCalls.map(call => call.text)).toEqual([ + "always", + "always", + "after-subagents", + ]) + await cleanup?.() + }) + + it("clears active V2 subagent state when the session is deleted", async () => { + const directory = await createProject({ + session: [ + { + id: "after-subagents", + when: { event: "session.idle", excludeSubagentWait: true }, + inject: "after-subagents", + }, + ], + }) + const { context, callbacks, syntheticCalls } = createContext(directory) + let releaseEvents!: () => void + let eventsFinished!: () => void + const release = new Promise(resolve => { releaseEvents = resolve }) + const complete = new Promise(resolve => { eventsFinished = resolve }) + context.event.subscribe = () => (async function* () { + await release + yield { id: "deleted", type: "session.deleted", data: { sessionID: "parent" } } + yield { + id: "idle-after-delete", + type: "session.idle", + location: { directory }, + data: { sessionID: "parent" }, + } + eventsFinished() + })() + + const cleanup = await createV2Plugin().setup(context) + await callbacks.get("execute.before")?.({ + tool: "subagent", + sessionID: "parent", + id: "subagent-call", + agent: "build", + input: { agent: "worker" }, + }) + releaseEvents() + await complete + + expect(syntheticCalls.map(call => call.text)).toEqual(["after-subagents"]) + await cleanup?.() + }) + + it("reports V2 toast degradation once without blocking injection", async () => { + const directory = await createProject({ + tool: [ + { + id: "toast-gap", + when: { phase: "before", tool: "bash" }, + inject: "still injected", + toast: { message: "not available" }, + }, + ], + }) + const { context, callbacks, syntheticCalls } = createContext(directory) + const warningSpy = spyOn(console, "warn").mockImplementation(() => {}) + const cleanup = await createV2Plugin().setup(context) + const event = { + tool: "bash", + sessionID: "session-3", + id: "call-3", + agent: "build", + input: {}, + } + + await callbacks.get("execute.before")?.(event) + await callbacks.get("execute.before")?.(event) + + expect(syntheticCalls).toHaveLength(2) + expect(warningSpy).toHaveBeenCalledTimes(1) + + warningSpy.mockRestore() + await cleanup?.() + }) +}) diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..0919244 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +}