Browse Source

Merge pull request #846 from mhenke/omos/upgrade-sdk-118

chore(deps): upgrade @opencode-ai/sdk + plugin to 1.18.3, migrate to v2 client
Alvin 5 days ago
parent
commit
4a5c1a5367

+ 48 - 4
bun.lock

@@ -7,8 +7,8 @@
         "@ast-grep/cli": "^0.44.1",
         "@modelcontextprotocol/sdk": "^1.29.0",
         "@mozilla/readability": "^0.6.0",
-        "@opencode-ai/plugin": "^1.3.17",
-        "@opencode-ai/sdk": "^1.3.17",
+        "@opencode-ai/plugin": "1.18.3",
+        "@opencode-ai/sdk": "1.18.3",
         "jsdom": "^29.0.0",
         "lru-cache": "^11.5.2",
         "turndown": "^7.2.4",
@@ -35,6 +35,8 @@
     "@ast-grep/cli",
   ],
   "packages": {
+    "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
+
     "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
 
     "@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="],
@@ -227,9 +229,21 @@
 
     "@mozilla/readability": ["@mozilla/readability@0.6.0", "", {}, "sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ=="],
 
-    "@opencode-ai/plugin": ["@opencode-ai/plugin@1.4.3", "", { "dependencies": { "@opencode-ai/sdk": "1.4.3", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.1.97", "@opentui/solid": ">=0.1.97" }, "optionalPeers": ["@opentui/core", "@opentui/solid"] }, "sha512-Ob/3tVSIeuMRJBr2O23RtrnC5djRe01Lglx+TwGEmjrH9yDBJ2tftegYLnNEjRoMuzITgq9LD8168p4pzv+U/A=="],
+    "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="],
+
+    "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="],
+
+    "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="],
+
+    "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="],
+
+    "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="],
+
+    "@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/sdk": ["@opencode-ai/sdk@1.4.3", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-X0CAVbwoGAjTY2iecpWkx2B+GAa2jSaQKYpJ+xILopeF/OGKZUN15mjqci+L7cEuwLHV5wk3x2TStUOVCa5p0A=="],
+    "@opencode-ai/plugin": ["@opencode-ai/plugin@1.18.3", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "1.18.3", "effect": "4.0.0-beta.83", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.4.3", "@opentui/keymap": ">=0.4.3", "@opentui/solid": ">=0.4.3" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-hAm/hZkSCsMSNHVy9DKmFtZAve/qYoIWpduNPcvXnnKK7NMlJEOQitA6CQC67Ym5DFKypDW1TC9YO6S1WdGtpg=="],
+
+    "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.3", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-Mevo4e6kQwbvto9E+42KSIVMhp+JBu+SwQhC5AomAvrV6Xkio3U249T+xDILDCXhl5Z/Hi/DlAuVLzpGnuh0gg=="],
 
     "@opentui/core": ["@opentui/core@0.1.97", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.97", "@opentui/core-darwin-x64": "0.1.97", "@opentui/core-linux-arm64": "0.1.97", "@opentui/core-linux-x64": "0.1.97", "@opentui/core-win32-arm64": "0.1.97", "@opentui/core-win32-x64": "0.1.97", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-2ENH0Dc4NUAeHeeQCQhF1lg68RuyntOUP68UvortvDqTz/hqLG0tIwF+DboCKtWi8Nmao4SAQEJ7lfmyQNEDOQ=="],
 
@@ -247,6 +261,8 @@
 
     "@opentui/solid": ["@opentui/solid@0.1.97", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.97", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-ma/uihG38F+6oLJVD8yR7z82FWmR8QhfesNV5SBXbN74riMCRyy6kyQ6SI4xs4ykt9BbZOjrKLq+Xt/0Pd0SJQ=="],
 
+    "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+
     "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
 
     "@types/jsdom": ["@types/jsdom@28.0.3", "", { "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", "parse5": "^8.0.0", "undici-types": "^7.21.0" } }, "sha512-/HQ2uFoetFTXuye8vzIcHw2z6Fwi7Hi/qcgC+RoS9NCyewiqxhVGqlG+ViGB6lkax481R6dmhf1I7lIGlzJStQ=="],
@@ -421,6 +437,8 @@
 
     "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
 
+    "effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="],
+
     "electron-to-chromium": ["electron-to-chromium@1.5.344", "", {}, "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg=="],
 
     "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -459,6 +477,8 @@
 
     "external-editor": ["external-editor@3.1.0", "", { "dependencies": { "chardet": "^0.7.0", "iconv-lite": "^0.4.24", "tmp": "^0.0.33" } }, "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew=="],
 
+    "fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="],
+
     "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
 
     "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
@@ -471,6 +491,8 @@
 
     "find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="],
 
+    "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="],
+
     "find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
 
     "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
@@ -517,6 +539,8 @@
 
     "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
 
+    "ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="],
+
     "inquirer": ["inquirer@7.3.3", "", { "dependencies": { "ansi-escapes": "^4.2.1", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-width": "^3.0.0", "external-editor": "^3.0.3", "figures": "^3.0.0", "lodash": "^4.17.19", "mute-stream": "0.0.8", "run-async": "^2.4.0", "rxjs": "^6.6.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0", "through": "^2.3.6" } }, "sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA=="],
 
     "ip-address": ["ip-address@10.0.1", "", {}, "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="],
@@ -547,12 +571,16 @@
 
     "json-fixer": ["json-fixer@1.6.15", "", { "dependencies": { "@babel/runtime": "^7.18.9", "chalk": "^4.1.2", "pegjs": "^0.10.0" } }, "sha512-TuDuZ5KrgyjoCIppdPXBMqiGfota55+odM+j2cQ5rt/XKyKmqGB3Whz1F8SN8+60yYGy/Nu5lbRZ+rx8kBIvBw=="],
 
+    "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
+
     "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
 
     "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
 
     "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
 
+    "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="],
+
     "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
 
     "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
@@ -583,12 +611,20 @@
 
     "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
 
+    "msgpackr": ["msgpackr@2.0.4", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA=="],
+
+    "msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="],
+
+    "multipasta": ["multipasta@0.2.8", "", {}, "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q=="],
+
     "mute-stream": ["mute-stream@0.0.8", "", {}, "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA=="],
 
     "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
 
     "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
 
+    "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="],
+
     "node-releases": ["node-releases@2.0.38", "", {}, "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="],
 
     "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
@@ -659,6 +695,8 @@
 
     "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
 
+    "pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="],
+
     "qs": ["qs@6.14.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="],
 
     "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
@@ -765,6 +803,8 @@
 
     "token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="],
 
+    "toml": ["toml@4.3.0", "", {}, "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A=="],
+
     "tough-cookie": ["tough-cookie@6.0.2", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA=="],
 
     "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="],
@@ -789,6 +829,8 @@
 
     "utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="],
 
+    "uuid": ["uuid@14.0.1", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="],
+
     "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
 
     "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
@@ -823,6 +865,8 @@
 
     "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
 
+    "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
+
     "yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
 
     "yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],

+ 2 - 2
package.json

@@ -81,8 +81,8 @@
     "@ast-grep/cli": "^0.44.1",
     "@modelcontextprotocol/sdk": "^1.29.0",
     "@mozilla/readability": "^0.6.0",
-    "@opencode-ai/plugin": "^1.3.17",
-    "@opencode-ai/sdk": "^1.3.17",
+    "@opencode-ai/plugin": "1.18.3",
+    "@opencode-ai/sdk": "1.18.3",
     "jsdom": "^29.0.0",
     "lru-cache": "^11.5.2",
     "turndown": "^7.2.4"

+ 30 - 8
src/hooks/chat-headers.test.ts

@@ -6,18 +6,34 @@ import {
   createChatHeadersHook,
 } from './chat-headers';
 
+// Mock getClient so internal calls use our mock
+let mockV2Client: Record<string, unknown>;
+let mockSession: { message: ReturnType<typeof mock> };
+
+mock.module('../utils/opencode-client', () => ({
+  getClient: () => mockV2Client,
+}));
+
 function createMockContext(parts: unknown[] = []) {
+  mockSession = {
+    message: mock(async () => ({
+      data: {
+        info: { role: 'user' },
+        parts,
+      },
+    })),
+  };
+  mockV2Client = {
+    session: mockSession,
+  } as unknown as Record<string, unknown>;
+
   return {
     client: {
       session: {
-        message: mock(async () => ({
-          data: {
-            info: { role: 'user' },
-            parts,
-          },
-        })),
+        message: mockSession.message,
       },
     },
+    directory: '/tmp/test',
   } as unknown as PluginInput;
 }
 
@@ -192,7 +208,7 @@ describe('createChatHeadersHook', () => {
 
     expect(firstOutput.headers['x-initiator']).toBe('agent');
     expect(secondOutput.headers['x-initiator']).toBe('agent');
-    expect(ctx.client.session.message).toHaveBeenCalledTimes(1);
+    expect(mockSession.message).toHaveBeenCalledTimes(1);
   });
 
   test('does not cache transient message lookup failures', async () => {
@@ -209,6 +225,12 @@ describe('createChatHeadersHook', () => {
         },
       };
     });
+    mockSession = {
+      message: messageMock,
+    };
+    mockV2Client = {
+      session: mockSession,
+    } as unknown as Record<string, unknown>;
     const ctx = {
       client: {
         session: {
@@ -249,6 +271,6 @@ describe('createChatHeadersHook', () => {
       { headers: {} },
     );
 
-    expect(ctx.client.session.message).toHaveBeenCalledTimes(2);
+    expect(mockSession.message).toHaveBeenCalledTimes(2);
   });
 });

+ 7 - 10
src/hooks/chat-headers.ts

@@ -1,6 +1,7 @@
 import type { PluginInput, ProviderContext } from '@opencode-ai/plugin';
 import type { Model, UserMessage } from '@opencode-ai/sdk';
 import { isInternalInitiatorPart } from '../utils';
+import { getClient } from '../utils/opencode-client';
 
 interface ChatHeadersInput {
   sessionID: string;
@@ -32,7 +33,7 @@ function isCopilotProvider(providerID: string): boolean {
 }
 
 async function hasInternalMarker(
-  client: PluginInput['client'],
+  input: PluginInput,
   sessionID: string,
   messageID: string,
 ): Promise<boolean> {
@@ -43,8 +44,10 @@ async function hasInternalMarker(
   }
 
   try {
-    const response = await client.session.message({
-      path: { id: sessionID, messageID },
+    const response = await getClient(input).session.message({
+      sessionID,
+      messageID,
+      directory: input.directory,
     });
     const hasMarker = (response.data?.parts ?? []).some(
       isInternalInitiatorPart,
@@ -81,13 +84,7 @@ export function createChatHeadersHook(ctx: PluginInput) {
         return;
       }
 
-      if (
-        !(await hasInternalMarker(
-          ctx.client,
-          input.sessionID,
-          input.message.id,
-        ))
-      ) {
+      if (!(await hasInternalMarker(ctx, input.sessionID, input.message.id))) {
         return;
       }
 

+ 109 - 92
src/hooks/foreground-fallback/index.test.ts

@@ -9,9 +9,26 @@ import {
 
 // ACCEPTANCE GAP: config() hook behaviour is not covered by CI — verify live.
 
-type ForegroundFallbackClient = ConstructorParameters<
-  typeof ForegroundFallbackManager
->[0];
+// Shared session reference so our mock.module for getClient returns the
+// current test's mock session without relying on this.input (which is
+// undefined in tests — always set in production).
+let currentMockSession: Record<string, unknown> | null = null;
+
+// Override manager.test.ts's global mock.module for getClient. Called
+// at module load AND from createMockClient so it takes effect regardless of
+// test file load order.
+function installGetClientMock(): void {
+  mock.module('../../utils/opencode-client', () => ({
+    getClient: () => ({
+      session: currentMockSession ?? {
+        abort: mock(() => Promise.resolve()),
+        messages: mock(() => Promise.resolve({ data: [] })),
+        promptAsync: mock(() => Promise.resolve()),
+      },
+    }),
+  }));
+}
+installGetClientMock();
 
 // ---------------------------------------------------------------------------
 // Helpers
@@ -44,10 +61,16 @@ function createMockClient(overrides?: {
     session.promptAsync = promptAsync;
   }
 
+  // Store for getClient mock
+  currentMockSession = session;
+  // Re-register the mock.module at test time so it survives any
+  // overwrite from other test files loaded in the same process.
+  installGetClientMock();
+
   return {
     client: {
       session,
-    } as unknown as ForegroundFallbackClient,
+    } as never,
     mocks: { promptAsync, abort, messages },
   };
 }
@@ -180,7 +203,7 @@ describe('isFailoverError', () => {
 describe('ForegroundFallbackManager (disabled)', () => {
   test('does nothing when enabled=false', async () => {
     const { client, mocks } = createMockClient();
-    const mgr = new ForegroundFallbackManager(client, makeChains(), false);
+    const mgr = new ForegroundFallbackManager(makeChains(), false, { directory: '/test' } as any);
 
     await mgr.handleEvent({
       type: 'session.error',
@@ -205,7 +228,7 @@ describe('ForegroundFallbackManager session.error', () => {
 
   beforeEach(() => {
     ({ client, mocks } = createMockClient());
-    mgr = new ForegroundFallbackManager(client, makeChains(), true);
+    mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any);
   });
 
   test('triggers fallback on rate-limit session.error', async () => {
@@ -236,14 +259,14 @@ describe('ForegroundFallbackManager session.error', () => {
 
     const call = mocks.promptAsync.mock.calls[0] as [
       {
-        path: { id: string };
-        body: { model: { providerID: string; modelID: string } };
+        sessionID: string;
+        model: { providerID: string; modelID: string };
       },
     ];
-    expect(call[0].path.id).toBe('sess-1');
+    expect(call[0].sessionID).toBe('sess-1');
     // Should have picked the next model after anthropic/claude-opus-4-5
-    expect(call[0].body.model.providerID).toBe('openai');
-    expect(call[0].body.model.modelID).toBe('gpt-4o');
+    expect(call[0].model.providerID).toBe('openai');
+    expect(call[0].model.modelID).toBe('gpt-4o');
   });
 
   test('marks the replayed user prompt as an internal initiator', async () => {
@@ -268,9 +291,9 @@ describe('ForegroundFallbackManager session.error', () => {
     });
 
     const call = mocks.promptAsync.mock.calls[0] as [
-      { body: { parts: unknown[] } },
+      { parts: unknown[] },
     ];
-    expect(call[0].body.parts.some(isInternalInitiatorPart)).toBe(true);
+    expect(call[0].parts.some(isInternalInitiatorPart)).toBe(true);
   });
 
   test('skips malformed messages without info when locating the last user message', async () => {
@@ -288,7 +311,7 @@ describe('ForegroundFallbackManager session.error', () => {
         },
       ],
     }));
-    mgr = new ForegroundFallbackManager(client, makeChains(), true);
+    mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any);
 
     await mgr.handleEvent({
       type: 'message.updated',
@@ -312,9 +335,9 @@ describe('ForegroundFallbackManager session.error', () => {
 
     expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
     const call = mocks.promptAsync.mock.calls[0] as [
-      { body: { parts: Array<{ text?: string }> } },
+      { parts: Array<{ text?: string }> },
     ];
-    expect(call[0].body.parts[0]?.text).toBe('real prompt');
+    expect(call[0].parts[0]?.text).toBe('real prompt');
   });
 
   test('does nothing when error is not a rate limit', async () => {
@@ -330,7 +353,7 @@ describe('ForegroundFallbackManager session.error', () => {
   });
 
   test('does nothing when no chain configured for session', async () => {
-    const emptyMgr = new ForegroundFallbackManager(client, {}, true);
+    const emptyMgr = new ForegroundFallbackManager({}, true, { directory: '/test' } as any);
     await emptyMgr.handleEvent({
       type: 'session.error',
       properties: {
@@ -345,7 +368,7 @@ describe('ForegroundFallbackManager session.error', () => {
 
   test('does not abort when promptAsync is unavailable', async () => {
     const { client, mocks } = createMockClient({ includePromptAsync: false });
-    const mgr = new ForegroundFallbackManager(client, makeChains(), true);
+    const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any);
 
     await mgr.handleEvent({
       type: 'session.error',
@@ -368,7 +391,7 @@ describe('ForegroundFallbackManager session.error', () => {
         // abort succeeds on first call
       },
     });
-    const mgr = new ForegroundFallbackManager(client, makeChains(), true);
+    const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any);
 
     await mgr.handleEvent({
       type: 'session.error',
@@ -391,7 +414,7 @@ describe('ForegroundFallbackManager session.error', () => {
 describe('ForegroundFallbackManager message.updated', () => {
   test('tracks model from message.updated and falls back on error', async () => {
     const { client, mocks } = createMockClient();
-    const mgr = new ForegroundFallbackManager(client, makeChains(), true);
+    const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any);
 
     await mgr.handleEvent({
       type: 'message.updated',
@@ -408,16 +431,16 @@ describe('ForegroundFallbackManager message.updated', () => {
     expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
     const call = mocks.promptAsync.mock.calls[0] as [
       {
-        body: { model: { providerID: string; modelID: string } };
+        model: { providerID: string; modelID: string };
       },
     ];
-    expect(call[0].body.model.providerID).toBe('openai');
-    expect(call[0].body.model.modelID).toBe('gpt-4o');
+    expect(call[0].model.providerID).toBe('openai');
+    expect(call[0].model.modelID).toBe('gpt-4o');
   });
 
   test('uses agent name from message.updated to select correct chain', async () => {
     const { client, mocks } = createMockClient();
-    const mgr = new ForegroundFallbackManager(client, makeChains(), true);
+    const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any);
 
     // explorer message with its model
     await mgr.handleEvent({
@@ -436,13 +459,13 @@ describe('ForegroundFallbackManager message.updated', () => {
     expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
     const call = mocks.promptAsync.mock.calls[0] as [
       {
-        body: { model: { providerID: string; modelID: string } };
+        model: { providerID: string; modelID: string };
       },
     ];
     // explorer chain: ['openai/gpt-4o-mini', 'anthropic/claude-haiku']
     // current=gpt-4o-mini is tried → next = claude-haiku
-    expect(call[0].body.model.providerID).toBe('anthropic');
-    expect(call[0].body.model.modelID).toBe('claude-haiku');
+    expect(call[0].model.providerID).toBe('anthropic');
+    expect(call[0].model.modelID).toBe('claude-haiku');
   });
 });
 
@@ -462,7 +485,7 @@ describe('ForegroundFallbackManager session.status', () => {
         return {};
       },
     });
-    const mgr = new ForegroundFallbackManager(client, makeChains(), true, 3);
+    const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any, 3);
 
     await mgr.handleEvent({
       type: 'message.updated',
@@ -495,11 +518,11 @@ describe('ForegroundFallbackManager session.status', () => {
   test('keeps registered child agent identity sticky for retry fallback chain', async () => {
     const { client, mocks } = createMockClient();
     const mgr = new ForegroundFallbackManager(
-      client,
       makeChains({
         oracle: ['anthropic/claude-sonnet-4-5', 'openai/o3'],
       }),
       true,
+      { directory: '/test' } as any,
       1,
     );
 
@@ -526,19 +549,19 @@ describe('ForegroundFallbackManager session.status', () => {
 
     expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
     const call = mocks.promptAsync.mock.calls[0] as [
-      { body: { model: { providerID: string; modelID: string } } },
+      { model: { providerID: string; modelID: string } },
     ];
-    expect(call[0].body.model).toEqual({ providerID: 'openai', modelID: 'o3' });
+    expect(call[0].model).toEqual({ providerID: 'openai', modelID: 'o3' });
   });
 
   test('includes the sticky child agent in fallback promptAsync body', async () => {
     const { client, mocks } = createMockClient();
     const mgr = new ForegroundFallbackManager(
-      client,
       makeChains({
         oracle: ['anthropic/claude-sonnet-4-5', 'openai/o3'],
       }),
       true,
+      { directory: '/test' } as any,
       1,
     );
 
@@ -565,19 +588,17 @@ describe('ForegroundFallbackManager session.status', () => {
     expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
     const call = mocks.promptAsync.mock.calls[0] as [
       {
-        body: {
-          agent?: string;
-          model: { providerID: string; modelID: string };
-        };
+        agent?: string;
+        model: { providerID: string; modelID: string };
       },
     ];
-    expect(call[0].body.agent).toBe('oracle');
-    expect(call[0].body.model).toEqual({ providerID: 'openai', modelID: 'o3' });
+    expect(call[0].agent).toBe('oracle');
+    expect(call[0].model).toEqual({ providerID: 'openai', modelID: 'o3' });
   });
 
   test('triggers fallback on retry status with rate limit message', async () => {
     const { client, mocks } = createMockClient();
-    const mgr = new ForegroundFallbackManager(client, makeChains(), true, 1);
+    const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any, 1);
 
     await mgr.handleEvent({
       type: 'message.updated',
@@ -603,7 +624,7 @@ describe('ForegroundFallbackManager session.status', () => {
 
   test('triggers fallback on retry status with insufficient balance message', async () => {
     const { client, mocks } = createMockClient();
-    const mgr = new ForegroundFallbackManager(client, makeChains(), true, 1);
+    const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any, 1);
 
     await mgr.handleEvent({
       type: 'message.updated',
@@ -629,7 +650,7 @@ describe('ForegroundFallbackManager session.status', () => {
 
   test('ignores session.status with non-rate-limit retry message', async () => {
     const { client, mocks } = createMockClient();
-    const mgr = new ForegroundFallbackManager(client, makeChains(), true);
+    const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any);
 
     await mgr.handleEvent({
       type: 'session.status',
@@ -644,7 +665,7 @@ describe('ForegroundFallbackManager session.status', () => {
 
   test('does not abort or switch after retries without a failover reason', async () => {
     const { client, mocks } = createMockClient();
-    const mgr = new ForegroundFallbackManager(client, makeChains(), true, 3);
+    const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any, 3);
 
     await mgr.handleEvent({
       type: 'message.updated',
@@ -673,7 +694,7 @@ describe('ForegroundFallbackManager session.status', () => {
 
   test('triggers immediate fallback on first failover retry', async () => {
     const { client, mocks } = createMockClient();
-    const mgr = new ForegroundFallbackManager(client, makeChains(), true, 3);
+    const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any, 3);
 
     await mgr.handleEvent({
       type: 'message.updated',
@@ -702,7 +723,7 @@ describe('ForegroundFallbackManager session.status', () => {
 
   test('switches to fallback model on first failover retry', async () => {
     const { client, mocks } = createMockClient();
-    const mgr = new ForegroundFallbackManager(client, makeChains(), true, 3);
+    const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any, 3);
 
     await mgr.handleEvent({
       type: 'message.updated',
@@ -731,7 +752,7 @@ describe('ForegroundFallbackManager session.status', () => {
 
   test('triggers fallback when rate-limit text is in props.error instead of status.message', async () => {
     const { client, mocks } = createMockClient();
-    const mgr = new ForegroundFallbackManager(client, makeChains(), true, 1);
+    const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any, 1);
 
     await mgr.handleEvent({
       type: 'message.updated',
@@ -758,7 +779,7 @@ describe('ForegroundFallbackManager session.status', () => {
 
   test('triggers fallback when props.error is a plain string', async () => {
     const { client, mocks } = createMockClient();
-    const mgr = new ForegroundFallbackManager(client, makeChains(), true, 1);
+    const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any, 1);
 
     await mgr.handleEvent({
       type: 'message.updated',
@@ -785,7 +806,7 @@ describe('ForegroundFallbackManager session.status', () => {
 
   test('non-rate-limit retry does not trigger fallback but rate-limit does', async () => {
     const { client, mocks } = createMockClient();
-    const mgr = new ForegroundFallbackManager(client, makeChains(), true, 3);
+    const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any, 3);
 
     await mgr.handleEvent({
       type: 'message.updated',
@@ -838,7 +859,7 @@ describe('ForegroundFallbackManager session.status', () => {
         return {};
       },
     });
-    const mgr = new ForegroundFallbackManager(client, makeChains(), true, 3);
+    const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any, 3);
 
     // Seed session with model A (anthropic/claude-opus-4-5)
     await mgr.handleEvent({
@@ -868,9 +889,9 @@ describe('ForegroundFallbackManager session.status', () => {
     expect(mocks.abort).toHaveBeenCalledTimes(1);
     expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
     const firstCall = mocks.promptAsync.mock.calls[0] as [
-      { body: { model: { providerID: string; modelID: string } } },
+      { model: { providerID: string; modelID: string } },
     ];
-    expect(firstCall[0].body.model).toEqual({
+    expect(firstCall[0].model).toEqual({
       providerID: 'openai',
       modelID: 'gpt-4o',
     });
@@ -909,7 +930,7 @@ describe('ForegroundFallbackManager session.status', () => {
         return {};
       },
     });
-    const mgr = new ForegroundFallbackManager(client, makeChains(), true, 1); // maxRetries=1 for immediate fallback
+    const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any, 1); // maxRetries=1 for immediate fallback
 
     // Seed session with model A
     await mgr.handleEvent({
@@ -939,9 +960,9 @@ describe('ForegroundFallbackManager session.status', () => {
     expect(mocks.abort).toHaveBeenCalledTimes(1);
     expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
     const firstCall = mocks.promptAsync.mock.calls[0] as [
-      { body: { model: { providerID: string; modelID: string } } },
+      { model: { providerID: string; modelID: string } },
     ];
-    expect(firstCall[0].body.model).toEqual({
+    expect(firstCall[0].model).toEqual({
       providerID: 'openai',
       modelID: 'gpt-4o',
     });
@@ -965,9 +986,9 @@ describe('ForegroundFallbackManager session.status', () => {
     expect(mocks.abort).toHaveBeenCalledTimes(2);
     expect(mocks.promptAsync).toHaveBeenCalledTimes(2);
     const secondCall = mocks.promptAsync.mock.calls[1] as [
-      { body: { model: { providerID: string; modelID: string } } },
+      { model: { providerID: string; modelID: string } },
     ];
-    expect(secondCall[0].body.model).toEqual({
+    expect(secondCall[0].model).toEqual({
       providerID: 'google',
       modelID: 'gemini-2.5-pro',
     });
@@ -984,9 +1005,9 @@ describe('ForegroundFallbackManager chain exhaustion', () => {
     // tryFallback adds 'openai/gpt-b' to tried → chain.find() returns undefined → exhausted.
     const { client, mocks } = createMockClient();
     const mgr = new ForegroundFallbackManager(
-      client,
       { orchestrator: ['openai/gpt-b'] },
       true,
+      { directory: '/test' } as any,
     );
 
     // Seed current model as the only chain entry
@@ -1021,9 +1042,9 @@ describe('ForegroundFallbackManager chain exhaustion', () => {
     const { client, mocks } = createMockClient();
     const chain = ['openai/model-x', 'openai/model-y'];
     const mgr = new ForegroundFallbackManager(
-      client,
       { orchestrator: chain },
       true,
+      { directory: '/test' } as any,
     );
 
     // Session A: current model is model-x, which IS in the chain → picks model-y ✓
@@ -1046,9 +1067,9 @@ describe('ForegroundFallbackManager chain exhaustion', () => {
     // → abort called to stop the freeze
     const { client: client2, mocks: mocks2 } = createMockClient();
     const mgr2 = new ForegroundFallbackManager(
-      client2,
       { orchestrator: ['openai/model-y'] }, // single-entry chain already in use
       true,
+      { directory: '/test' } as any,
     );
     await mgr2.handleEvent({
       type: 'message.updated',
@@ -1074,7 +1095,7 @@ describe('ForegroundFallbackManager chain exhaustion', () => {
 describe('ForegroundFallbackManager deduplication', () => {
   test('ignores a second trigger within dedup window for same session', async () => {
     const { client, mocks } = createMockClient();
-    const mgr = new ForegroundFallbackManager(client, makeChains(), true);
+    const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any);
 
     const event = {
       type: 'session.error',
@@ -1092,7 +1113,7 @@ describe('ForegroundFallbackManager deduplication', () => {
 
   test('different sessions are not deduplicated against each other', async () => {
     const { client, mocks } = createMockClient();
-    const mgr = new ForegroundFallbackManager(client, makeChains(), true);
+    const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any);
 
     await mgr.handleEvent({
       type: 'session.error',
@@ -1108,7 +1129,7 @@ describe('ForegroundFallbackManager deduplication', () => {
 
   test('cascade continues when second error arrives within dedup window after model switch', async () => {
     const { client, mocks } = createMockClient();
-    const mgr = new ForegroundFallbackManager(client, makeChains(), true);
+    const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any);
 
     // Seed session: current model is first entry in orchestrator chain
     await mgr.handleEvent({
@@ -1134,9 +1155,7 @@ describe('ForegroundFallbackManager deduplication', () => {
     expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
     expect(mocks.promptAsync.mock.calls[0][0]).toEqual(
       expect.objectContaining({
-        body: expect.objectContaining({
-          model: { providerID: 'openai', modelID: 'gpt-4o' },
-        }),
+        model: { providerID: 'openai', modelID: 'gpt-4o' },
       }),
     );
 
@@ -1156,9 +1175,7 @@ describe('ForegroundFallbackManager deduplication', () => {
     expect(mocks.promptAsync).toHaveBeenCalledTimes(2);
     expect(mocks.promptAsync.mock.calls[1][0]).toEqual(
       expect.objectContaining({
-        body: expect.objectContaining({
-          model: { providerID: 'google', modelID: 'gemini-2.5-pro' },
-        }),
+        model: { providerID: 'google', modelID: 'gemini-2.5-pro' },
       }),
     );
   });
@@ -1171,7 +1188,7 @@ describe('ForegroundFallbackManager deduplication', () => {
 describe('ForegroundFallbackManager subagent.session.created', () => {
   test('records agent name from subagent.session.created and falls back correctly', async () => {
     const { client, mocks } = createMockClient();
-    const mgr = new ForegroundFallbackManager(client, makeChains(), true);
+    const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any);
 
     // Register the session as 'explorer' via subagent creation event
     await mgr.handleEvent({
@@ -1188,14 +1205,14 @@ describe('ForegroundFallbackManager subagent.session.created', () => {
     expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
     const call = mocks.promptAsync.mock.calls[0] as [
       {
-        body: { model: { providerID: string; modelID: string } };
+        model: { providerID: string; modelID: string };
       },
     ];
     // explorer chain: ['openai/gpt-4o-mini', 'anthropic/claude-haiku']
     // agentName known → currentModel inferred as chain[0] (primary)
     // primary is tried → fallback picks claude-haiku
-    expect(call[0].body.model.providerID).toBe('anthropic');
-    expect(call[0].body.model.modelID).toBe('claude-haiku');
+    expect(call[0].model.providerID).toBe('anthropic');
+    expect(call[0].model.modelID).toBe('claude-haiku');
   });
 });
 
@@ -1208,9 +1225,9 @@ describe('ForegroundFallbackManager session.deleted', () => {
     const coordinator = new SessionLifecycle(() => {});
     const { client, mocks } = createMockClient();
     const mgr = new ForegroundFallbackManager(
-      client,
       makeChains(),
       true,
+      { directory: '/test' } as any,
       3,
       coordinator,
     );
@@ -1245,17 +1262,17 @@ describe('ForegroundFallbackManager session.deleted', () => {
     // and should pick the first chain model (no current model seed after deletion)
     expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
     const call = mocks.promptAsync.mock.calls[0] as [
-      { body: { model: { providerID: string; modelID: string } } },
+      { model: { providerID: string; modelID: string } },
     ];
     // orchestrator chain: ['anthropic/claude-opus-4-5', 'openai/gpt-4o', 'google/gemini-2.5-pro']
     // no current model → first untried = anthropic/claude-opus-4-5
-    expect(call[0].body.model.providerID).toBe('anthropic');
-    expect(call[0].body.model.modelID).toBe('claude-opus-4-5');
+    expect(call[0].model.providerID).toBe('anthropic');
+    expect(call[0].model.modelID).toBe('claude-opus-4-5');
   });
 
   test('ignores session.deleted with no sessionID', async () => {
     const { client } = createMockClient();
-    const mgr = new ForegroundFallbackManager(client, makeChains(), true);
+    const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any);
     // Should not throw
     await expect(
       mgr.handleEvent({ type: 'session.deleted', properties: {} }),
@@ -1266,9 +1283,9 @@ describe('ForegroundFallbackManager session.deleted', () => {
     const coordinator = new SessionLifecycle(() => {});
     const { client, mocks } = createMockClient();
     const mgr = new ForegroundFallbackManager(
-      client,
       makeChains(),
       true,
+      { directory: '/test' } as any,
       3,
       coordinator,
     );
@@ -1306,9 +1323,9 @@ describe('ForegroundFallbackManager session.deleted', () => {
     const coordinator = new SessionLifecycle(() => {});
     const { client } = createMockClient();
     const mgr = new ForegroundFallbackManager(
-      client,
       makeChains(),
       true,
+      { directory: '/test' } as any,
       3,
       coordinator,
     );
@@ -1357,12 +1374,12 @@ describe('ForegroundFallbackManager resolveChain cross-agent isolation', () => {
     // orchestrator's chain - re-prompting oracle with an orchestrator model.
     const { client, mocks } = createMockClient();
     const mgr = new ForegroundFallbackManager(
-      client,
       {
         // oracle intentionally absent - no chain configured
         orchestrator: ['openai/gpt-4o', 'google/gemini-2.5-pro'],
       },
       true,
+      { directory: '/test' } as any,
     );
 
     await mgr.handleEvent({
@@ -1387,9 +1404,9 @@ describe('ForegroundFallbackManager resolveChain cross-agent isolation', () => {
     // chain, the last-resort flattened chain is acceptable.
     const { client, mocks } = createMockClient();
     const mgr = new ForegroundFallbackManager(
-      client,
       { orchestrator: ['openai/gpt-4o'] },
       true,
+      { directory: '/test' } as any,
     );
 
     // No agent name tracked, no model tracked - triggers session.error
@@ -1404,10 +1421,10 @@ describe('ForegroundFallbackManager resolveChain cross-agent isolation', () => {
     // Falls through to last-resort → picks first model from any chain
     expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
     const call = mocks.promptAsync.mock.calls[0] as [
-      { body: { model: { providerID: string; modelID: string } } },
+      { model: { providerID: string; modelID: string } },
     ];
-    expect(call[0].body.model.providerID).toBe('openai');
-    expect(call[0].body.model.modelID).toBe('gpt-4o');
+    expect(call[0].model.providerID).toBe('openai');
+    expect(call[0].model.modelID).toBe('gpt-4o');
   });
 
   test('does NOT bleed into other agent chains for non-omos agents without a chain', async () => {
@@ -1416,9 +1433,9 @@ describe('ForegroundFallbackManager resolveChain cross-agent isolation', () => {
     // chain — that would switch the session from Build to Orchestrator.
     const { client, mocks } = createMockClient();
     const mgr = new ForegroundFallbackManager(
-      client,
       { orchestrator: ['openai/gpt-5.6', 'new-api/glm-5.2'] },
       true,
+      { directory: '/test' } as any,
     );
 
     await mgr.handleEvent({
@@ -1449,7 +1466,7 @@ describe('ForegroundFallbackManager no-chain sessions', () => {
     // FG must not abort or re-prompt — that races the council lifecycle and
     // previously produced "[foreground-fallback] no chain configured" noise.
     const { client, mocks } = createMockClient();
-    const mgr = new ForegroundFallbackManager(client, makeChains(), true, 3);
+    const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any, 3);
 
     await mgr.handleEvent({
       type: 'message.updated',
@@ -1481,7 +1498,7 @@ describe('ForegroundFallbackManager no-chain sessions', () => {
 
   test('councillor session.error: no abort and no re-prompt', async () => {
     const { client, mocks } = createMockClient();
-    const mgr = new ForegroundFallbackManager(client, makeChains(), true);
+    const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any);
 
     await mgr.handleEvent({
       type: 'message.updated',
@@ -1509,7 +1526,7 @@ describe('ForegroundFallbackManager no-chain sessions', () => {
 
   test('disableChain agent on session.status: no abort (not just no re-prompt)', async () => {
     const { client, mocks } = createMockClient();
-    const mgr = new ForegroundFallbackManager(client, makeChains(), true, 3);
+    const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any, 3);
     mgr.disableChain('orchestrator');
 
     await mgr.handleEvent({
@@ -1548,7 +1565,7 @@ describe('ForegroundFallbackManager no-chain sessions', () => {
 describe('ForegroundFallbackManager disableChain', () => {
   test('after disableChain, rate-limit error surfaces instead of falling back', async () => {
     const { client, mocks } = createMockClient();
-    const mgr = new ForegroundFallbackManager(client, makeChains(), true);
+    const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any);
 
     mgr.disableChain('orchestrator');
 
@@ -1573,7 +1590,7 @@ describe('ForegroundFallbackManager disableChain', () => {
 
   test('other agents chains are unaffected by disableChain', async () => {
     const { client, mocks } = createMockClient();
-    const mgr = new ForegroundFallbackManager(client, makeChains(), true);
+    const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any);
 
     mgr.disableChain('orchestrator');
 
@@ -1593,11 +1610,11 @@ describe('ForegroundFallbackManager disableChain', () => {
 
     expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
     const call = mocks.promptAsync.mock.calls[0] as [
-      { body: { model: { providerID: string; modelID: string } } },
+      { model: { providerID: string; modelID: string } },
     ];
     // explorer chain: ['openai/gpt-4o-mini', 'anthropic/claude-haiku']
     // current = gpt-4o-mini is tried → next = claude-haiku
-    expect(call[0].body.model.providerID).toBe('anthropic');
-    expect(call[0].body.model.modelID).toBe('claude-haiku');
+    expect(call[0].model.providerID).toBe('anthropic');
+    expect(call[0].model.modelID).toBe('claude-haiku');
   });
 });

+ 14 - 30
src/hooks/foreground-fallback/index.ts

@@ -20,6 +20,7 @@
 import type { PluginInput } from '@opencode-ai/plugin';
 import { createInternalAgentTextPart } from '../../utils/internal-initiator';
 import { log } from '../../utils/logger';
+import { getClient } from '../../utils/opencode-client';
 import {
   abortSessionWithTimeout,
   parseModelReference,
@@ -27,8 +28,6 @@ import {
 import type { SessionLifecycle } from '../session-lifecycle';
 import { isUserMessageWithParts } from '../types';
 
-type OpencodeClient = PluginInput['client'];
-
 // ---------------------------------------------------------------------------
 // Retryable error detection
 // ---------------------------------------------------------------------------
@@ -260,7 +259,6 @@ export class ForegroundFallbackManager {
   }
 
   constructor(
-    private readonly client: OpencodeClient,
     /**
      * Ordered fallback chains per agent.
      * e.g. { orchestrator: ['anthropic/claude-opus-4-5', 'openai/gpt-4o'] }
@@ -268,6 +266,7 @@ export class ForegroundFallbackManager {
      */
     private chains: Record<string, string[]>,
     private readonly enabled: boolean,
+    private readonly input: PluginInput,
     /** Consecutive 429s tolerated on the same model before swap/abort. */
     private readonly maxRetries: number = 3,
     coordinator?: SessionLifecycle,
@@ -522,7 +521,7 @@ export class ForegroundFallbackManager {
 
     this.inProgress.add(sessionID);
     try {
-      await abortSessionWithTimeout(this.client, sessionID);
+      await abortSessionWithTimeout(getClient(this.input!), sessionID);
       await this.execFallback(sessionID);
     } finally {
       this.inProgress.delete(sessionID);
@@ -597,7 +596,7 @@ export class ForegroundFallbackManager {
             agentName,
             tried: [...tried],
           });
-          await abortSessionWithTimeout(this.client, sessionID);
+          await abortSessionWithTimeout(getClient(this.input!), sessionID);
           return;
         }
       }
@@ -615,8 +614,8 @@ export class ForegroundFallbackManager {
       }
 
       // Retrieve the last user message to re-submit with the fallback model.
-      const result = await this.client.session.messages({
-        path: { id: sessionID },
+      const result = await getClient(this.input!).session.messages({
+        sessionID,
       });
       // result.data may contain partial/streaming messages whose `info` is
       // undefined at runtime (OpenCode violates its own declared type), so
@@ -630,27 +629,18 @@ export class ForegroundFallbackManager {
 
       // promptAsync queues the prompt and returns immediately - this avoids
       // blocking the event handler while waiting for a full LLM response.
-      // Cast required: promptAsync is not in the plugin TypeScript types for
-      // oh-my-opencode-slim but IS present on the real OpenCode client at
-      // runtime (verified by opencode-rate-limit-fallback reference impl).
-      const sessionClient = this.client.session as unknown as {
-        promptAsync?: (args: {
-          path: { id: string };
-          body: {
-            agent?: string;
-            parts: unknown[];
-            model: { providerID: string; modelID: string };
-          };
-        }) => Promise<unknown>;
-      };
+      const sessionClient = getClient(this.input!).session;
       if (typeof sessionClient.promptAsync !== 'function') {
         log('[foreground-fallback] promptAsync unavailable', { sessionID });
         return;
       }
 
       const promptBody = {
+        // ponytail: lastUser.parts are MessagePart[] from API, but v2
+        // promptAsync expects TextPartInput[] — runtime-compatible, TS
+        // doesn't know the `type` field is already 'text'.
         parts: [
-          ...lastUser.parts,
+          ...(lastUser.parts as Array<{ type: 'text'; text: string }>),
           createInternalAgentTextPart('Foreground fallback replay.'),
         ],
         model: ref,
@@ -662,20 +652,14 @@ export class ForegroundFallbackManager {
       // transparently — no dialog, no session error shown to the user.
       // If promptAsync throws (e.g. session busy), fall back to abort+retry.
       try {
-        await sessionClient.promptAsync({
-          path: { id: sessionID },
-          body: promptBody,
-        });
+        await sessionClient.promptAsync({ sessionID, ...promptBody });
       } catch (_promptErr) {
         log('[foreground-fallback] promptAsync on busy session, aborting', {
           sessionID,
         });
-        await abortSessionWithTimeout(this.client, sessionID);
+        await abortSessionWithTimeout(getClient(this.input!), sessionID);
         await new Promise((r) => setTimeout(r, REPROMPT_DELAY_MS));
-        await sessionClient.promptAsync({
-          path: { id: sessionID },
-          body: promptBody,
-        });
+        await sessionClient.promptAsync({ sessionID, ...promptBody });
       }
 
       this.sessionModel.set(sessionID, nextModel);

+ 6 - 8
src/hooks/task-session-manager/continuation-evaluator.ts

@@ -242,7 +242,7 @@ export async function evaluateContinuation(
     if (deps.sessionSdk.get) {
       try {
         const sessionResponse = await deps.sessionSdk.get({
-          path: { id: parentSessionID },
+          sessionID: parentSessionID,
           throwOnError: true,
         });
         const session = isObjectRecord(sessionResponse?.data)
@@ -272,13 +272,11 @@ export async function evaluateContinuation(
     }
     committed = true;
     await deps.sessionSdk.promptAsync({
-      path: { id: parentSessionID },
-      body: {
-        agent: 'orchestrator',
-        ...(modelSelection ? { model: modelSelection.model } : {}),
-        ...(modelSelection?.variant ? { variant: modelSelection.variant } : {}),
-        parts: [createInternalAgentTextPart(CONTINUATION_NUDGE)],
-      },
+      sessionID: parentSessionID,
+      agent: 'orchestrator',
+      ...(modelSelection ? { model: modelSelection.model } : {}),
+      ...(modelSelection?.variant ? { variant: modelSelection.variant } : {}),
+      parts: [createInternalAgentTextPart(CONTINUATION_NUDGE)],
       throwOnError: true,
     });
   } catch (error) {

+ 29 - 27
src/hooks/task-session-manager/index.test.ts

@@ -21,6 +21,12 @@ import {
   createTaskSessionManagerHook,
 } from './index';
 
+// Route getClient back to _ctx.client so existing _ctx.client.session
+// mocks continue to work through the new v2 lookup path.
+mock.module('../../utils/opencode-client', () => ({
+  getClient: (input: { client: unknown }) => input.client as never,
+}));
+
 /** Wait for the idle reconciliation delay (2s + margin) to flush. */
 function flushIdleReconcileDelay() {
   return new Promise((resolve) => setTimeout(resolve, 2100));
@@ -5197,9 +5203,7 @@ describe('task-session-manager hook', () => {
     expect(promptAsync).toHaveBeenCalledTimes(1);
     expect(promptAsync).toHaveBeenCalledWith(
       expect.objectContaining({
-        body: expect.objectContaining({
-          parts: [expect.objectContaining({ synthetic: true })],
-        }),
+        parts: [expect.objectContaining({ synthetic: true })],
       }),
     );
   });
@@ -5225,9 +5229,9 @@ describe('task-session-manager hook', () => {
     expect(promptAsync).toHaveBeenCalledTimes(1);
     expect(promptAsync).toHaveBeenCalledWith(
       expect.objectContaining({
-        body: expect.objectContaining({
-          parts: [expect.objectContaining({ synthetic: true })],
-        }),
+        sessionID: 'parent-1',
+        agent: 'orchestrator',
+        parts: [expect.objectContaining({ synthetic: true })],
       }),
     );
   });
@@ -5256,19 +5260,17 @@ describe('task-session-manager hook', () => {
     await flushContinuation();
 
     expect(get).toHaveBeenCalledWith({
-      path: { id: 'parent-1' },
+      sessionID: 'parent-1',
       throwOnError: true,
     });
     expect(promptAsync).toHaveBeenCalledTimes(1);
     expect(promptAsync).toHaveBeenCalledWith(
       expect.objectContaining({
-        body: expect.objectContaining({
-          model: {
-            providerID: 'runtime-provider',
-            modelID: 'selected-model',
-          },
-          variant: 'selected-variant',
-        }),
+        model: {
+          providerID: 'runtime-provider',
+          modelID: 'selected-model',
+        },
+        variant: 'selected-variant',
       }),
     );
   });
@@ -5306,13 +5308,11 @@ describe('task-session-manager hook', () => {
     expect(promptAsync).toHaveBeenCalledTimes(1);
     expect(promptAsync).toHaveBeenCalledWith(
       expect.objectContaining({
-        body: expect.objectContaining({
-          model: {
-            providerID: 'runtime-provider',
-            modelID: 'selected-model',
-          },
-          variant: 'selected-variant',
-        }),
+        model: {
+          providerID: 'runtime-provider',
+          modelID: 'selected-model',
+        },
+        variant: 'selected-variant',
       }),
     );
   });
@@ -5346,13 +5346,14 @@ describe('task-session-manager hook', () => {
     await flushContinuation();
 
     const request = promptAsync.mock.calls[0]?.[0] as {
-      body: Record<string, unknown>;
+      model?: Record<string, unknown>;
+      variant?: string;
     };
-    expect(request.body.model).toEqual({
+    expect(request.model).toEqual({
       providerID: 'current-provider',
       modelID: 'current-model',
     });
-    expect(request.body).not.toHaveProperty('variant');
+    expect(request).not.toHaveProperty('variant');
   });
 
   test('only external messages replace the model fallback and clear its variant', async () => {
@@ -5403,13 +5404,14 @@ describe('task-session-manager hook', () => {
     await flushContinuation();
 
     const request = promptAsync.mock.calls[0]?.[0] as {
-      body: Record<string, unknown>;
+      model?: Record<string, unknown>;
+      variant?: string;
     };
-    expect(request.body.model).toEqual({
+    expect(request.model).toEqual({
       providerID: 'new-provider',
       modelID: 'new-model',
     });
-    expect(request.body).not.toHaveProperty('variant');
+    expect(request).not.toHaveProperty('variant');
   });
 
   test('a user message invalidates continuation while current model lookup is pending', async () => {

+ 2 - 2
src/index.ts

@@ -356,9 +356,9 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     // Agents without a chain (e.g. councillor, owned by CouncilManager) are
     // left alone — FG only aborts/re-prompts when it has a model to switch to.
     foregroundFallback = new ForegroundFallbackManager(
-      ctx.client,
       runtimeChains,
       config.fallback?.enabled !== false,
+      ctx,
       config.fallback?.maxRetries ?? 3,
       sessionLifecycle,
     );
@@ -464,7 +464,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       config.companion,
     );
     cancelTaskTools = createCancelTaskTool({
-      client: ctx.client,
+      input: ctx,
       backgroundJobBoard: backgroundJobCoordinator,
       shouldManageSession: (sessionID) =>
         sessionMetadata.getAgent(sessionID) === 'orchestrator',

+ 2 - 1
src/interview/dashboard-manager.ts

@@ -2,6 +2,7 @@ import path from 'node:path';
 import type { PluginInput } from '@opencode-ai/plugin';
 import type { PluginConfig } from '../config';
 import { log } from '../utils';
+import { getClient } from '../utils/opencode-client';
 import {
   probeDashboard,
   readDashboardAuthFile,
@@ -70,7 +71,7 @@ export function createDashboardManager(
       dashboard = await tryBecomeDashboard({
         port: dashboardPort,
         outputFolder,
-        sessionClient: ctx.client.session,
+        sessionClient: getClient(ctx).session,
       });
 
       if (dashboard) {

+ 41 - 26
src/interview/interview.test.ts

@@ -12,6 +12,14 @@ import {
 import type { InterviewAnswer } from './types';
 import { renderInterviewPage } from './ui';
 
+// Intercept getClient calls so service code uses the same session mocks
+// that test assertions inspect.
+mock.module('../utils/opencode-client', () => ({
+  getClient: (ctx: any) => ({
+    session: ctx._sessionMock ?? ctx.client.session,
+  }),
+}));
+
 // Mock the plugin context with mutable message array
 function createMockContext(overrides?: {
   directory?: string;
@@ -24,48 +32,55 @@ function createMockContext(overrides?: {
   // Use a mutable array that can be updated after creation
   const messagesData = overrides?.messagesData ?? [];
 
+  const sessionMock = {
+    messages: mock(async () => ({ data: messagesData })),
+    prompt: mock(async (args: any) => {
+      if (overrides?.promptImpl) {
+        return await overrides.promptImpl(args);
+      }
+      return {};
+    }),
+    promptAsync: mock(async (args: any) => {
+      if (overrides?.promptImpl) {
+        return await overrides.promptImpl(args);
+      }
+      return {};
+    }),
+    update: mock(async () => ({})),
+  };
+
   return {
     client: {
-      session: {
-        messages: mock(async () => ({ data: messagesData })),
-        prompt: mock(async (args: any) => {
-          if (overrides?.promptImpl) {
-            return await overrides.promptImpl(args);
-          }
-          return {};
-        }),
-        promptAsync: mock(async (args: any) => {
-          if (overrides?.promptImpl) {
-            return await overrides.promptImpl(args);
-          }
-          return {};
-        }),
-        update: mock(async () => ({})),
-      },
+      session: sessionMock,
     },
     directory: overrides?.directory ?? '/test/directory',
+    _sessionMock: sessionMock,
   } as any;
 }
 
-// Helper to extract text from prompt calls
+// Helper to extract text from prompt calls (v2 flat params: parts is top-level)
 function getPromptTexts(promptMock: {
-  mock: { calls: Array<[{ body?: { parts?: Array<{ text?: string }> } }]> };
+  mock: {
+    calls: Array<[{ parts?: Array<{ text?: string }> }]>;
+  };
 }): string[] {
   return promptMock.mock.calls
-    .map((call) => call[0].body?.parts?.[0]?.text ?? '')
+    .map((call) => call[0].parts?.[0]?.text ?? '')
     .filter(Boolean);
 }
 
 // Helper to extract interview ID from the last prompt call
 function extractInterviewIdFromLastPrompt(promptMock: {
-  mock: { calls: Array<[{ body?: { parts?: Array<{ text?: string }> } }]> };
+  mock: {
+    calls: Array<[{ parts?: Array<{ text?: string }> }]>;
+  };
 }): string | null {
   const calls = promptMock.mock.calls;
   if (calls.length === 0) return null;
 
   // Get the last call
   const lastCall = calls[calls.length - 1];
-  const text = lastCall[0].body?.parts?.[0]?.text ?? '';
+  const text = lastCall[0].parts?.[0]?.text ?? '';
   const match = text.match(/interview\/([^\s]+)/);
   return match ? match[1] : null;
 }
@@ -192,8 +207,8 @@ describe('interview service', () => {
 
       expect(ctx.client.session.update).toHaveBeenCalledTimes(1);
       expect(ctx.client.session.update.mock.calls[0][0]).toEqual({
-        path: { id: 'session-rename' },
-        body: { title: 'Interview: build a task manager' },
+        sessionID: 'session-rename',
+        title: 'Interview: build a task manager',
       });
 
       await fs.rm(tempDir, { recursive: true, force: true });
@@ -216,7 +231,7 @@ describe('interview service', () => {
         output,
       );
 
-      const title = ctx.client.session.update.mock.calls[0][0].body.title;
+      const title = ctx.client.session.update.mock.calls[0][0].title;
       expect(title.length).toBe(50);
       expect(title.endsWith('…')).toBe(true);
 
@@ -923,7 +938,7 @@ describe('interview service', () => {
       await service.handleNudgeAction(interviewId, 'more-questions');
 
       const call = ctx.client.session.promptAsync.mock.calls[0]?.[0];
-      expect(call.body.model).toEqual({
+      expect(call.model).toEqual({
         providerID: 'openai',
         modelID: 'gpt-5.6-luna',
       });
@@ -1406,7 +1421,7 @@ describe('interview service', () => {
       ]);
 
       const call = ctx.client.session.promptAsync.mock.calls[0]?.[0];
-      expect(call.body.model).toEqual({
+      expect(call.model).toEqual({
         providerID: 'anthropic',
         modelID: 'claude-sonnet-4-6',
       });

+ 31 - 22
src/interview/manager.test.ts

@@ -6,6 +6,13 @@ import type { PluginConfig } from '../config';
 import { readDashboardAuthFile } from './dashboard';
 import { createInterviewManager } from './manager';
 
+// Intercept getClient so the manager's service uses the same session mocks.
+mock.module('../utils/opencode-client', () => ({
+  getClient: (ctx: any) => ({
+    session: ctx._sessionMock ?? ctx.client.session,
+  }),
+}));
+
 // Helper to find a free port (matches interview.test.ts pattern)
 async function findFreePort(): Promise<number> {
   return new Promise((resolve, reject) => {
@@ -32,26 +39,28 @@ function createMockContext(overrides?: {
   promptImpl?: (args: any) => Promise<unknown>;
 }) {
   const messagesData = overrides?.messagesData ?? [];
+  const sessionMock = {
+    messages: mock(async () => ({ data: messagesData })),
+    prompt: mock(async (args: any) => {
+      if (overrides?.promptImpl) {
+        return await overrides.promptImpl(args);
+      }
+      return {};
+    }),
+    promptAsync: mock(async (args: any) => {
+      if (overrides?.promptImpl) {
+        return await overrides.promptImpl(args);
+      }
+      return {};
+    }),
+    update: mock(async () => ({})),
+  };
   return {
     client: {
-      session: {
-        messages: mock(async () => ({ data: messagesData })),
-        prompt: mock(async (args: any) => {
-          if (overrides?.promptImpl) {
-            return await overrides.promptImpl(args);
-          }
-          return {};
-        }),
-        promptAsync: mock(async (args: any) => {
-          if (overrides?.promptImpl) {
-            return await overrides.promptImpl(args);
-          }
-          return {};
-        }),
-        update: mock(async () => ({})),
-      },
+      session: sessionMock,
     },
     directory: overrides?.directory ?? '/test/directory',
+    _sessionMock: sessionMock,
   } as any;
 }
 
@@ -185,7 +194,7 @@ describe('interview manager - per-session mode', () => {
         const promptCalls = ctx.client.session.prompt.mock.calls;
         expect(promptCalls.length).toBeGreaterThan(0);
         const text =
-          promptCalls[promptCalls.length - 1][0].body?.parts?.[0]?.text ?? '';
+          promptCalls[promptCalls.length - 1][0].parts?.[0]?.text ?? '';
         const match = text.match(/interview\/([^\s]+)/);
         expect(match).not.toBeNull();
         const interviewId = match?.[1];
@@ -276,7 +285,7 @@ describe('interview manager - state push callback wiring', () => {
       const promptCalls = ctx.client.session.prompt.mock.calls;
       expect(promptCalls.length).toBeGreaterThan(0);
       const text =
-        promptCalls[promptCalls.length - 1][0].body?.parts?.[0]?.text ?? '';
+        promptCalls[promptCalls.length - 1][0].parts?.[0]?.text ?? '';
       const match = text.match(/interview\/([^\s]+)/);
       expect(match).not.toBeNull();
       const interviewId = match?.[1];
@@ -365,7 +374,7 @@ describe('interview manager - session registration', () => {
       const promptCalls = ctx.client.session.prompt.mock.calls;
       expect(promptCalls.length).toBeGreaterThan(0);
       const text =
-        promptCalls[promptCalls.length - 1][0].body?.parts?.[0]?.text ?? '';
+        promptCalls[promptCalls.length - 1][0].parts?.[0]?.text ?? '';
       const match = text.match(/interview\/([^\s]+)/);
       expect(match).not.toBeNull();
       const interviewId = match?.[1];
@@ -418,7 +427,7 @@ describe('interview manager - session registration', () => {
       const promptCalls = ctx.client.session.prompt.mock.calls;
       expect(promptCalls.length).toBeGreaterThan(0);
       const text =
-        promptCalls[promptCalls.length - 1][0].body?.parts?.[0]?.text ?? '';
+        promptCalls[promptCalls.length - 1][0].parts?.[0]?.text ?? '';
       const match = text.match(/interview\/([^\s]+)/);
       expect(match).not.toBeNull();
       const _interviewId = match?.[1];
@@ -687,14 +696,14 @@ describe('interview manager - integration with real dashboard', () => {
       // Extract interview IDs
       const promptCalls1 = ctx1.client.session.prompt.mock.calls;
       const text1 =
-        promptCalls1[promptCalls1.length - 1][0].body?.parts?.[0]?.text ?? '';
+        promptCalls1[promptCalls1.length - 1][0].parts?.[0]?.text ?? '';
       const match1 = text1.match(/interview\/([^\s]+)/);
       expect(match1).not.toBeNull();
       const interviewId1 = match1?.[1];
 
       const promptCalls2 = ctx2.client.session.prompt.mock.calls;
       const text2 =
-        promptCalls2[promptCalls2.length - 1][0].body?.parts?.[0]?.text ?? '';
+        promptCalls2[promptCalls2.length - 1][0].parts?.[0]?.text ?? '';
       const match2 = text2.match(/interview\/([^\s]+)/);
       expect(match2).not.toBeNull();
       const interviewId2 = match2?.[1];

+ 44 - 53
src/interview/service.ts

@@ -8,6 +8,7 @@ import {
   isInternalInitiatorPart,
   log,
 } from '../utils';
+import { getClient } from '../utils/opencode-client';
 import { parseModelReference } from '../utils/session';
 import {
   appendInterviewAnswers,
@@ -271,8 +272,8 @@ export function createInterviewService(
   }
 
   async function loadMessages(sessionID: string): Promise<InterviewMessage[]> {
-    const result = await ctx.client.session.messages({
-      path: { id: sessionID },
+    const result = await getClient(ctx).session.messages({
+      sessionID,
     });
     return result.data as InterviewMessage[];
   }
@@ -501,24 +502,22 @@ export function createInterviewService(
     // Auto-open browser on initial creation (not on every poll/refresh)
     maybeOpenBrowser(interview.id, url);
 
-    await ctx.client.session.prompt({
-      path: { id: sessionID },
-      body: {
-        noReply: true,
-        parts: [
-          {
-            type: 'text',
-            text: [
-              '⎔ Interview UI ready',
-              '',
-              `Open: ${url}`,
-              `Document: ${relativeInterviewPath(ctx.directory, interview.markdownPath)}`,
-              '',
-              '[system status: continue without acknowledging this notification]',
-            ].join('\n'),
-          },
-        ],
-      },
+    await getClient(ctx).session.prompt({
+      sessionID,
+      noReply: true,
+      parts: [
+        {
+          type: 'text',
+          text: [
+            '⎔ Interview UI ready',
+            '',
+            `Open: ${url}`,
+            `Document: ${relativeInterviewPath(ctx.directory, interview.markdownPath)}`,
+            '',
+            '[system status: continue without acknowledging this notification]',
+          ].join('\n'),
+        },
+      ],
     });
   }
 
@@ -619,13 +618,11 @@ export function createInterviewService(
       // Use promptAsync for non-blocking - returns immediately, LLM
       // processes in background. State push updates dashboard when done.
       const model = sessionModel.get(interview.sessionID);
-      await ctx.client.session.promptAsync({
-        path: { id: interview.sessionID },
-        body: {
-          agent: 'orchestrator',
-          parts: [createInternalAgentTextPart(prompt)],
-          ...(model ? { model: parseModelReference(model) ?? undefined } : {}),
-        },
+      await getClient(ctx).session.promptAsync({
+        sessionID: interview.sessionID,
+        agent: 'orchestrator',
+        parts: [createInternalAgentTextPart(prompt)],
+        ...(model ? { model: parseModelReference(model) ?? undefined } : {}),
       });
       promptSent = true;
     } finally {
@@ -694,12 +691,12 @@ export function createInterviewService(
     if (sessionTitle.length > 50) {
       sessionTitle = `${sessionTitle.slice(0, 49)}…`;
     }
-    ctx.client.session
-      .update?.({
-        path: { id: input.sessionID },
-        body: { title: sessionTitle },
+    getClient(ctx)
+      .session.update({
+        sessionID: input.sessionID,
+        title: sessionTitle,
       })
-      ?.catch(() => {});
+      .catch(() => {});
   }
 
   async function handleEvent(input: {
@@ -869,13 +866,11 @@ export function createInterviewService(
       ].join('\n');
 
       const model = sessionModel.get(interview.sessionID);
-      await ctx.client.session.promptAsync({
-        path: { id: interview.sessionID },
-        body: {
-          agent: 'orchestrator',
-          parts: [createInternalAgentTextPart(prompt)],
-          ...(model ? { model: parseModelReference(model) ?? undefined } : {}),
-        },
+      await getClient(ctx).session.promptAsync({
+        sessionID: interview.sessionID,
+        agent: 'orchestrator',
+        parts: [createInternalAgentTextPart(prompt)],
+        ...(model ? { model: parseModelReference(model) ?? undefined } : {}),
       });
       promptSent = true;
     } finally {
@@ -932,13 +927,11 @@ export function createInterviewService(
       ].join('\n');
 
       const model = sessionModel.get(interview.sessionID);
-      await ctx.client.session.promptAsync({
-        path: { id: interview.sessionID },
-        body: {
-          agent: 'orchestrator',
-          parts: [createInternalAgentTextPart(prompt)],
-          ...(model ? { model: parseModelReference(model) ?? undefined } : {}),
-        },
+      await getClient(ctx).session.promptAsync({
+        sessionID: interview.sessionID,
+        agent: 'orchestrator',
+        parts: [createInternalAgentTextPart(prompt)],
+        ...(model ? { model: parseModelReference(model) ?? undefined } : {}),
       });
       promptSent = true;
     } finally {
@@ -1007,13 +1000,11 @@ export function createInterviewService(
       }
 
       const model = sessionModel.get(interview.sessionID);
-      await ctx.client.session.promptAsync({
-        path: { id: interview.sessionID },
-        body: {
-          agent: 'orchestrator',
-          parts: [createInternalAgentTextPart(prompt)],
-          ...(model ? { model: parseModelReference(model) ?? undefined } : {}),
-        },
+      await getClient(ctx).session.promptAsync({
+        sessionID: interview.sessionID,
+        agent: 'orchestrator',
+        parts: [createInternalAgentTextPart(prompt)],
+        ...(model ? { model: parseModelReference(model) ?? undefined } : {}),
       });
       promptSent = true;
     } finally {

+ 56 - 21
src/tools/cancel-task.test.ts

@@ -1,14 +1,19 @@
-import { describe, expect, mock, test } from 'bun:test';
+import { afterEach, describe, expect, mock, test } from 'bun:test';
 import { parseTaskStatusOutput } from '../utils';
 import { BackgroundJobBoard } from '../utils/background-job-board';
 import { createCancelTaskTool } from './cancel-task';
 
+let mockV2Client: Record<string, unknown>;
+
+mock.module('../utils/opencode-client', () => ({
+  getClient: () => mockV2Client,
+}));
+
 function createTool(overrides?: {
   abort?: () => Promise<unknown>;
   delete?: () => Promise<unknown>;
   get?: () => Promise<unknown>;
   status?: () => Promise<unknown>;
-  includeDelete?: boolean;
   shouldManageSession?: (sessionID: string) => boolean;
   abortTimeoutMs?: number;
   verifyAbortMs?: number;
@@ -24,10 +29,9 @@ function createTool(overrides?: {
     overrides?.get ?? (async () => ({ data: { parentID: 'parent-1' } })),
   );
   const status = mock(overrides?.status ?? (async () => ({ data: {} })));
-  const session: Record<string, unknown> = { abort, get, status };
-  if (overrides?.includeDelete !== false) session.delete = deleteSession;
+  mockV2Client = { session: { abort, delete: deleteSession, get, status } };
   const tools = createCancelTaskTool({
-    client: { session } as any,
+    input: { directory: '/test/project' } as any,
     backgroundJobBoard: board,
     shouldManageSession: overrides?.shouldManageSession ?? (() => true),
     abortTimeoutMs: overrides?.abortTimeoutMs,
@@ -50,6 +54,10 @@ function createTool(overrides?: {
 
 const context = { sessionID: 'parent-1', agent: 'orchestrator' } as any;
 
+afterEach(() => {
+  mock.restore();
+});
+
 describe('cancel_task tool', () => {
   test('cancels a tracked running task by task ID', async () => {
     const { board, abort, cancelTask } = createTool();
@@ -64,7 +72,7 @@ describe('cancel_task tool', () => {
       context,
     );
 
-    expect(abort).toHaveBeenCalledWith({ path: { id: 'ses_1' } });
+    expect(abort).toHaveBeenCalledWith({ sessionID: 'ses_1' });
     expect(String(output)).toContain('state: cancelled');
     expect(String(output)).toContain('cancelled: obsolete');
     expect(parseTaskStatusOutput(String(output))).toMatchObject({
@@ -85,7 +93,7 @@ describe('cancel_task tool', () => {
 
     await cancelTask.execute({ task_id: 'ora-1' }, context);
 
-    expect(abort).toHaveBeenCalledWith({ path: { id: 'ses_1' } });
+    expect(abort).toHaveBeenCalledWith({ sessionID: 'ses_1' });
   });
 
   test('does not abort raw session IDs tracked by a different parent', async () => {
@@ -122,7 +130,7 @@ describe('cancel_task tool', () => {
 
     const output = await cancelTask.execute({ task_id: 'ses_1' }, context);
 
-    expect(abort).toHaveBeenCalledWith({ path: { id: 'ses_1' } });
+    expect(abort).toHaveBeenCalledWith({ sessionID: 'ses_1' });
     expect(String(output)).toContain('state: cancelled');
     expect(board.get('ses_1')).toMatchObject({ state: 'cancelled' });
   });
@@ -135,7 +143,7 @@ describe('cancel_task tool', () => {
       context,
     );
 
-    expect(abort).toHaveBeenCalledWith({ path: { id: 'ses_lost' } });
+    expect(abort).toHaveBeenCalledWith({ sessionID: 'ses_lost' });
     expect(String(output)).toContain('state: cancelled');
     expect(String(output)).toContain('cancelled: stop ghost worker');
   });
@@ -177,7 +185,7 @@ describe('cancel_task tool', () => {
       context,
     );
 
-    expect(abort).toHaveBeenCalledWith({ path: { id: 'ses_1' } });
+    expect(abort).toHaveBeenCalledWith({ sessionID: 'ses_1' });
     expect(String(output)).toContain('state: cancelled');
   });
 
@@ -196,16 +204,19 @@ describe('cancel_task tool', () => {
       context,
     );
 
-    expect(abort).toHaveBeenCalledWith({ path: { id: 'ses_1' } });
+    expect(abort).toHaveBeenCalledWith({ sessionID: 'ses_1' });
     expect(String(output)).toContain('state: cancelled');
   });
 
   test('does not terminalize board when abort fails without delete', async () => {
     const { board, abort, cancelTask } = createTool({
-      includeDelete: false,
       abort: async () => {
         throw new Error('abort failed');
       },
+      delete: async () => {
+        throw new Error('delete failed');
+      },
+      status: async () => ({ data: { ses_1: { type: 'busy' } } }),
     });
     board.registerLaunch({
       taskID: 'ses_1',
@@ -239,7 +250,10 @@ describe('cancel_task tool', () => {
     const output = await cancelTask.execute({ task_id: 'ses_1' }, context);
 
     expect(abort).toHaveBeenCalled();
-    expect(deleteSession).toHaveBeenCalledWith({ path: { id: 'ses_1' } });
+    expect(deleteSession).toHaveBeenCalledWith({
+      sessionID: 'ses_1',
+      directory: '/test/project',
+    });
     expect(String(output)).toContain('state: cancelled');
     expect(board.get('ses_1')).toMatchObject({ state: 'cancelled' });
   });
@@ -259,7 +273,10 @@ describe('cancel_task tool', () => {
 
     const output = await cancelTask.execute({ task_id: 'ses_1' }, context);
 
-    expect(deleteSession).toHaveBeenCalledWith({ path: { id: 'ses_1' } });
+    expect(deleteSession).toHaveBeenCalledWith({
+      sessionID: 'ses_1',
+      directory: '/test/project',
+    });
     expect(String(output)).toContain('state: cancelled');
     expect(board.get('ses_1')).toMatchObject({ state: 'cancelled' });
   });
@@ -279,7 +296,10 @@ describe('cancel_task tool', () => {
 
     const output = await cancelTask.execute({ task_id: 'ses_1' }, context);
 
-    expect(deleteSession).toHaveBeenCalledWith({ path: { id: 'ses_1' } });
+    expect(deleteSession).toHaveBeenCalledWith({
+      sessionID: 'ses_1',
+      directory: '/test/project',
+    });
     expect(String(output)).toContain('state: running');
     expect(board.get('ses_1')).toMatchObject({
       state: 'running',
@@ -290,9 +310,12 @@ describe('cancel_task tool', () => {
 
   test('keeps running/status uncertain when abort times out without delete', async () => {
     const { board, cancelTask } = createTool({
-      includeDelete: false,
       abort: () => new Promise(() => {}),
       abortTimeoutMs: 1,
+      delete: async () => {
+        throw new Error('delete failed');
+      },
+      status: async () => ({ data: { ses_1: { type: 'busy' } } }),
     });
     board.registerLaunch({
       taskID: 'ses_1',
@@ -334,7 +357,10 @@ describe('cancel_task tool', () => {
     const output = await cancelTask.execute({ task_id: 'ses_1' }, context);
 
     expect(abort).toHaveBeenCalled();
-    expect(deleteSession).toHaveBeenCalledWith({ path: { id: 'ses_1' } });
+    expect(deleteSession).toHaveBeenCalledWith({
+      sessionID: 'ses_1',
+      directory: '/test/project',
+    });
     expect(String(output)).toContain('state: cancelled');
     expect(board.get('ses_1')).toMatchObject({
       state: 'cancelled',
@@ -366,7 +392,10 @@ describe('cancel_task tool', () => {
     const output = await cancelTask.execute({ task_id: 'ses_1' }, context);
 
     expect(abort).toHaveBeenCalled();
-    expect(deleteSession).toHaveBeenCalledWith({ path: { id: 'ses_1' } });
+    expect(deleteSession).toHaveBeenCalledWith({
+      sessionID: 'ses_1',
+      directory: '/test/project',
+    });
     expect(String(output)).toContain('state: cancelled');
     expect(board.get('ses_1')).toMatchObject({
       state: 'cancelled',
@@ -398,7 +427,10 @@ describe('cancel_task tool', () => {
 
     const output = await cancelTask.execute({ task_id: 'ses_1' }, context);
 
-    expect(deleteSession).toHaveBeenCalledWith({ path: { id: 'ses_1' } });
+    expect(deleteSession).toHaveBeenCalledWith({
+      sessionID: 'ses_1',
+      directory: '/test/project',
+    });
     expect(String(output)).toContain('state: cancelled');
     expect(board.get('ses_1')).toMatchObject({
       state: 'cancelled',
@@ -428,10 +460,13 @@ describe('cancel_task tool', () => {
 
   test('cancelSessionByID returns state: error when abort throws non-SessionStillRunningError, even if board shows running', async () => {
     const { board, abort, cancelTask } = createTool({
-      includeDelete: false,
       abort: async () => {
         throw new Error('network timeout');
       },
+      delete: async () => {
+        throw new Error('delete failed');
+      },
+      status: async () => ({ data: { ses_running: 'active' } }),
     });
     // Register a running job so that isRunning(taskID) would be true
     // if the function incorrectly checks it.
@@ -449,7 +484,7 @@ describe('cancel_task tool', () => {
       context,
     );
 
-    expect(abort).toHaveBeenCalledWith({ path: { id: 'ses_running' } });
+    expect(abort).toHaveBeenCalledWith({ sessionID: 'ses_running' });
     // cancelSessionByID must return state: error for non-SessionStillRunningError,
     // NOT state: running (which would happen if || isRunning() were present).
     expect(String(output)).toContain('state: error');

+ 30 - 140
src/tools/cancel-task.ts

@@ -6,12 +6,13 @@ import {
 import type { BackgroundJobStore } from '../utils/background-job-store';
 import { isRecord as isObjectRecord } from '../utils/guards';
 import { log } from '../utils/logger';
+import { getClient } from '../utils/opencode-client';
 import { abortSessionWithTimeout, withTimeout } from '../utils/session';
 
 const z = tool.schema;
 
 interface CancelTaskToolOptions {
-  client: PluginInput['client'];
+  input: PluginInput;
   backgroundJobBoard: BackgroundJobStore;
   shouldManageSession: (sessionID: string) => boolean;
   abortTimeoutMs?: number;
@@ -97,7 +98,7 @@ Use only for obsolete, wrong, conflicting, or user-requested cancellation. Accep
             );
           }
 
-          const parentID = await getSessionParentID(options.client, requested);
+          const parentID = await getSessionParentID(options.input, requested);
           if (parentID !== parentSessionID) {
             log('[cancel-task] rejected raw session without parent ownership', {
               parentSessionID,
@@ -221,10 +222,10 @@ async function abortAndVerifySession(
   taskID: string,
 ): Promise<void> {
   log('[cancel-task] abort attempt starting', { taskID });
-  const abortStartedAt = Date.now();
   try {
+    // ponytail: abortSessionWithTimeout now takes v2 OpencodeClient
     await abortSessionWithTimeout(
-      options.client,
+      getClient(options.input),
       taskID,
       options.abortTimeoutMs ?? 10_000,
     );
@@ -233,112 +234,13 @@ async function abortAndVerifySession(
     log('[cancel-task] abort call failed', {
       taskID,
       error: error instanceof Error ? error.message : String(error),
-      canDelete: canDeleteSession(options.client),
     });
-    if (!canDeleteSession(options.client)) throw error;
   }
 
-  if (canDeleteSession(options.client)) {
-    await deleteAndVerifySession(options, taskID, 'cancel-task-after-abort');
-    return;
-  }
-
-  const verifyAbortMs = options.verifyAbortMs ?? 8_000;
-  const stableStoppedMs = options.stableStoppedMs ?? 3_000;
-  const retryIntervalMs = options.abortRetryIntervalMs ?? 150;
-  const deadline = Date.now() + verifyAbortMs;
-  log('[cancel-task] abort verification starting', {
-    taskID,
-    verifyAbortMs,
-    stableStoppedMs,
-    retryIntervalMs,
-  });
-  let attempts = 0;
-  let stableStoppedSince: number | undefined;
-  let lastStatus: string | undefined;
-  while (Date.now() <= deadline) {
-    attempts += 1;
-    const statusSnapshot = await getSessionStatus(options.client, taskID);
-    lastStatus = statusSnapshot.status;
-    log('[cancel-task] abort verification status', {
-      taskID,
-      attempts,
-      status: statusSnapshot.status,
-      statusSource: statusSnapshot.source,
-      statusKeys: statusSnapshot.keys,
-      stableStoppedSince,
-      stableStoppedForMs: stableStoppedSince
-        ? Date.now() - stableStoppedSince
-        : 0,
-      boardState: options.backgroundJobBoard.getState(taskID),
-      boardLastLiveBusyAt: options.backgroundJobBoard.getLastLiveBusyAt(taskID),
-    });
-    const boardLastLiveBusyAt =
-      options.backgroundJobBoard.getLastLiveBusyAt(taskID);
-    if (boardLastLiveBusyAt && boardLastLiveBusyAt >= abortStartedAt) {
-      log('[cancel-task] abort verification saw board busy after abort', {
-        taskID,
-        attempts,
-        abortStartedAt,
-        boardLastLiveBusyAt,
-        status: statusSnapshot.status,
-        statusSource: statusSnapshot.source,
-      });
-      await deleteAndVerifySession(options, taskID, 'board-busy-after-abort');
-      return;
-    }
-    if (statusSnapshot.status === 'busy' || statusSnapshot.status === 'retry') {
-      if (stableStoppedSince !== undefined) {
-        log('[cancel-task] abort verification saw busy after idle', {
-          taskID,
-          attempts,
-          stableStoppedForMs: Date.now() - stableStoppedSince,
-        });
-        await deleteAndVerifySession(options, taskID, 'busy-after-idle');
-        return;
-      }
-      stableStoppedSince = undefined;
-      await abortSessionWithTimeout(
-        options.client,
-        taskID,
-        options.abortTimeoutMs ?? 10_000,
-      );
-      log('[cancel-task] abort retry returned', {
-        taskID,
-        attempts,
-        status: statusSnapshot.status,
-      });
-      await delay(retryIntervalMs);
-      continue;
-    }
-
-    stableStoppedSince ??= Date.now();
-    if (Date.now() - stableStoppedSince >= stableStoppedMs) {
-      log('[cancel-task] abort verified stopped', {
-        taskID,
-        attempts,
-        status: statusSnapshot.status,
-        stableStoppedMs,
-      });
-      return;
-    }
-
-    await delay(retryIntervalMs);
-  }
-
-  log('[cancel-task] abort verification timed out', {
-    taskID,
-    attempts,
-    lastStatus,
-    stableStoppedSince,
-  });
-  if (lastStatus === 'busy' || lastStatus === 'retry') {
-    await deleteAndVerifySession(options, taskID, 'still-busy-after-abort');
-    return;
-  }
-  throw new SessionStillRunningError(
-    `Session abort returned but task did not stay stopped: ${taskID}`,
-  );
+  // ponytail: v1 had a polling loop here that verified abort succeeded before
+  // proceeding to delete. v2 abort is server-side and synchronous — the delete
+  // verification loop below catches any remaining running state.
+  await deleteAndVerifySession(options, taskID, 'cancel-task-after-abort');
 }
 
 async function deleteAndVerifySession(
@@ -346,23 +248,18 @@ async function deleteAndVerifySession(
   taskID: string,
   reason: string,
 ): Promise<void> {
-  const session = options.client.session as unknown as {
-    delete?: (args: { path: { id: string } }) => Promise<unknown>;
-  };
-  if (!session.delete) {
-    log('[cancel-task] session delete unavailable', { taskID, reason });
-    throw new SessionStillRunningError(
-      `Session resumed after abort and delete is unavailable: ${taskID}`,
-    );
-  }
+  const v2 = getClient(options.input);
 
-  log('[cancel-task] deleting session after unstable abort', {
+  log('[cancel-task] deleting session after abort attempt', {
     taskID,
     reason,
   });
   try {
     await withTimeout(
-      session.delete({ path: { id: taskID } }),
+      v2.session.delete({
+        sessionID: taskID,
+        directory: options.input.directory,
+      }),
       options.deleteTimeoutMs ?? 10_000,
       `Session delete timed out after ${options.deleteTimeoutMs ?? 10_000}ms`,
     );
@@ -373,7 +270,7 @@ async function deleteAndVerifySession(
       reason,
       error: error instanceof Error ? error.message : String(error),
     });
-    const status = await getSessionStatus(options.client, taskID);
+    const status = await getSessionStatus(options.input, taskID);
     log('[cancel-task] delete failure verification status', {
       taskID,
       reason,
@@ -397,7 +294,7 @@ async function deleteAndVerifySession(
   let lastStatus: string | undefined;
   while (Date.now() <= deadline) {
     attempts += 1;
-    const status = await getSessionStatus(options.client, taskID);
+    const status = await getSessionStatus(options.input, taskID);
     lastStatus = status.status;
     log('[cancel-task] delete verification status', {
       taskID,
@@ -423,13 +320,8 @@ async function deleteAndVerifySession(
   );
 }
 
-function canDeleteSession(client: PluginInput['client']): boolean {
-  const session = client.session as unknown as { delete?: unknown };
-  return typeof session.delete === 'function';
-}
-
 async function getSessionStatus(
-  client: PluginInput['client'],
+  input: PluginInput,
   taskID: string,
 ): Promise<{
   status: string | undefined;
@@ -437,10 +329,10 @@ async function getSessionStatus(
   keys: string[];
 }> {
   try {
-    const result = await (
-      client.session.status as unknown as () => Promise<unknown>
-    )();
-    const data = (result as { data?: unknown }).data;
+    const result = await getClient(input).session.status({
+      directory: input.directory,
+    });
+    const data = result.data;
     if (!isObjectRecord(data)) {
       return { status: undefined, source: 'invalid-data', keys: [] };
     }
@@ -483,19 +375,17 @@ function normalizeCancelReason(reason?: string): string {
 }
 
 async function getSessionParentID(
-  client: PluginInput['client'],
+  input: PluginInput,
   taskID: string,
 ): Promise<string | undefined> {
-  const session = client.session as unknown as {
-    get?: (args: { path: { id: string } }) => Promise<unknown>;
-  };
-  if (!session.get) return undefined;
   try {
-    const response = await session.get({ path: { id: taskID } });
-    const data = (response as { data?: unknown }).data;
-    if (!isObjectRecord(data)) return undefined;
-    const parentID = data.parentID;
-    return typeof parentID === 'string' ? parentID : undefined;
+    const response = await getClient(input).session.get({
+      sessionID: taskID,
+      directory: input.directory,
+    });
+    const session = response.data;
+    if (!session) return undefined;
+    return session.parentID;
   } catch (error) {
     log('[cancel-task] session metadata lookup failed', {
       taskID,

+ 87 - 119
src/tools/smartfetch/secondary-model.test.ts

@@ -1,12 +1,5 @@
-import { afterEach, describe, expect, mock, test } from 'bun:test';
-import * as fs from 'node:fs';
-import * as os from 'node:os';
-import * as path from 'node:path';
-import {
-  _testConfig,
-  readSecondaryModelFromConfig,
-  runSecondaryModelWithFallback,
-} from './secondary-model';
+import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
+import { _testConfig, runSecondaryModelWithFallback } from './secondary-model';
 import type { SecondaryModel } from './types';
 
 type PromptStep = {
@@ -14,7 +7,23 @@ type PromptStep = {
   error?: Error;
 };
 
-function createMockClient(
+// Mock getClient so internal calls use our mock v2 client.
+// The variable is reassigned per-test to control behavior.
+let mockV2Client: Record<string, unknown>;
+let mockV2Session: {
+  create: ReturnType<typeof mock>;
+  prompt: ReturnType<typeof mock>;
+  delete: ReturnType<typeof mock>;
+};
+let mockV2Tool: {
+  ids: ReturnType<typeof mock>;
+};
+
+mock.module('../../utils/opencode-client', () => ({
+  getClient: () => mockV2Client,
+}));
+
+function createV2ClientMock(
   steps: PromptStep[],
   deleteBehavior?: {
     failTimes?: number;
@@ -25,32 +34,35 @@ function createMockClient(
   let deleteCallCount = 0;
   const failTimes = deleteBehavior?.failTimes ?? 0;
 
+  mockV2Session = {
+    create: mock(async () => ({ data: { id: `session-${createCount++}` } })),
+    prompt: mock(async () => {
+      const step = steps[promptCount++] ?? {};
+      if (step.error) {
+        throw step.error;
+      }
+      return {
+        data: {
+          parts: [{ type: 'text', text: step.text ?? '' }],
+        },
+      };
+    }),
+    delete: mock(async () => {
+      deleteCallCount++;
+      if (deleteCallCount <= failTimes) {
+        throw new Error('delete failed');
+      }
+      return { data: true };
+    }),
+  };
+  mockV2Tool = {
+    ids: mock(async () => ({ data: ['read', 'bash'] })),
+  };
+
   return {
-    session: {
-      create: mock(async () => ({ id: `session-${createCount++}` })),
-      prompt: mock(async () => {
-        const step = steps[promptCount++] ?? {};
-        if (step.error) {
-          throw step.error;
-        }
-        return {
-          data: {
-            parts: [{ type: 'text', text: step.text ?? '' }],
-          },
-        };
-      }),
-      delete: mock(async () => {
-        deleteCallCount++;
-        if (deleteCallCount <= failTimes) {
-          throw new Error('delete failed');
-        }
-        return {};
-      }),
-    },
-    tool: {
-      ids: mock(async () => ({ data: ['read', 'bash'] })),
-    },
-  } as any;
+    session: mockV2Session,
+    tool: mockV2Tool,
+  };
 }
 
 describe('smartfetch/secondary-model', () => {
@@ -59,64 +71,20 @@ describe('smartfetch/secondary-model', () => {
     { providerID: 'provider-b', modelID: 'fallback' },
   ];
 
+  const testInput = { directory: '/tmp/project' } as never;
+
   afterEach(() => {
     mock.restore();
   });
 
-  test('gives dedicated webfetch models precedence over fallback sources', async () => {
-    const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'smartfetch-test-'));
-    const projectConfigDir = path.join(tempDir, '.opencode');
-    const userConfigDir = path.join(tempDir, 'user-config');
-    const originalEnv = { ...process.env };
-
-    try {
-      fs.mkdirSync(projectConfigDir, { recursive: true });
-      fs.mkdirSync(path.join(userConfigDir, 'opencode'), { recursive: true });
-      fs.writeFileSync(
-        path.join(projectConfigDir, 'opencode.json'),
-        JSON.stringify({ small_model: 'small/provider-model' }),
-      );
-      fs.writeFileSync(
-        path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
-        JSON.stringify({
-          agents: {
-            explorer: { model: 'explorer/provider-model' },
-            librarian: { model: 'librarian/provider-model' },
-          },
-        }),
-      );
-      delete process.env.OPENCODE_CONFIG_DIR;
-      process.env.XDG_CONFIG_HOME = userConfigDir;
-
-      await expect(
-        readSecondaryModelFromConfig(tempDir, [
-          { id: 'dedicated/provider-model', variant: 'fast' },
-        ]),
-      ).resolves.toEqual([
-        {
-          providerID: 'dedicated',
-          modelID: 'provider-model',
-          variant: 'fast',
-        },
-        { providerID: 'small', modelID: 'provider-model' },
-        { providerID: 'explorer', modelID: 'provider-model' },
-        { providerID: 'librarian', modelID: 'provider-model' },
-      ]);
-    } finally {
-      process.env = originalEnv;
-      fs.rmSync(tempDir, { recursive: true, force: true });
-    }
-  });
-
   test('falls back when the first model returns empty text', async () => {
-    const client = createMockClient([
+    mockV2Client = createV2ClientMock([
       { text: '   ' },
       { text: 'Useful answer' },
     ]);
 
     const result = await runSecondaryModelWithFallback(
-      client,
-      '/tmp/project',
+      testInput,
       models,
       'Summarize the page',
       'This is enough fetched content to clear the short-content guard.',
@@ -124,19 +92,18 @@ describe('smartfetch/secondary-model', () => {
 
     expect(result.text).toBe('Useful answer');
     expect(result.model).toEqual(models[1]);
-    expect(client.session.prompt).toHaveBeenCalledTimes(2);
-    expect(client.session.delete).toHaveBeenCalledTimes(2);
+    expect(mockV2Session.prompt).toHaveBeenCalledTimes(2);
+    expect(mockV2Session.delete).toHaveBeenCalledTimes(2);
   });
 
   test('falls back when the first model throws', async () => {
-    const client = createMockClient([
+    mockV2Client = createV2ClientMock([
       { error: new Error('primary failed') },
       { text: 'Recovered answer' },
     ]);
 
     const result = await runSecondaryModelWithFallback(
-      client,
-      '/tmp/project',
+      testInput,
       models,
       'Extract the answer',
       'This is enough fetched content to clear the short-content guard.',
@@ -144,8 +111,8 @@ describe('smartfetch/secondary-model', () => {
 
     expect(result.text).toBe('Recovered answer');
     expect(result.model).toEqual(models[1]);
-    expect(client.session.prompt).toHaveBeenCalledTimes(2);
-    expect(client.session.delete).toHaveBeenCalledTimes(2);
+    expect(mockV2Session.prompt).toHaveBeenCalledTimes(2);
+    expect(mockV2Session.delete).toHaveBeenCalledTimes(2);
   });
 
   test('retries session delete on transient failure', async () => {
@@ -155,11 +122,10 @@ describe('smartfetch/secondary-model', () => {
     const originalDelay = _testConfig.deleteRetryDelayMs;
     _testConfig.deleteRetryDelayMs = 0;
     try {
-      const client = createMockClient([{ text: 'Answer' }], { failTimes: 1 });
+      mockV2Client = createV2ClientMock([{ text: 'Answer' }], { failTimes: 1 });
 
       const result = await runSecondaryModelWithFallback(
-        client,
-        '/tmp/project',
+        testInput,
         [models[0]],
         'Summarize',
         'This is enough fetched content to clear the short-content guard.',
@@ -167,7 +133,7 @@ describe('smartfetch/secondary-model', () => {
 
       expect(result.text).toBe('Answer');
       // First attempt failed, second succeeded → 2 calls for one session
-      expect(client.session.delete).toHaveBeenCalledTimes(2);
+      expect(mockV2Session.delete).toHaveBeenCalledTimes(2);
       expect(warnCalls.length).toBe(0);
     } finally {
       console.warn = originalWarn;
@@ -182,11 +148,12 @@ describe('smartfetch/secondary-model', () => {
     const originalDelay = _testConfig.deleteRetryDelayMs;
     _testConfig.deleteRetryDelayMs = 0;
     try {
-      const client = createMockClient([{ text: 'Answer' }], { failTimes: 99 });
+      mockV2Client = createV2ClientMock([{ text: 'Answer' }], {
+        failTimes: 99,
+      });
 
       const result = await runSecondaryModelWithFallback(
-        client,
-        '/tmp/project',
+        testInput,
         [models[0]],
         'Summarize',
         'This is enough fetched content to clear the short-content guard.',
@@ -203,30 +170,31 @@ describe('smartfetch/secondary-model', () => {
   });
 
   test('falls back to next model when prompt times out', async () => {
-    const client = {
-      session: {
-        create: mock(async () => ({ id: 'session-timeout' })),
-        prompt: mock(async (opts: any) => {
-          const model = opts.body.model;
-          if (model.modelID === 'small') {
-            throw new Error('Secondary model timed out');
-          }
-          return {
-            data: {
-              parts: [{ type: 'text', text: 'Fallback answer' }],
-            },
-          };
-        }),
-        delete: mock(async () => ({})),
-      },
-      tool: {
-        ids: mock(async () => ({ data: ['read'] })),
-      },
-    } as any;
+    mockV2Session = {
+      create: mock(async () => ({ data: { id: 'session-timeout' } })),
+      prompt: mock(async (opts: any) => {
+        const model = opts.model;
+        if (model.modelID === 'small') {
+          throw new Error('Secondary model timed out');
+        }
+        return {
+          data: {
+            parts: [{ type: 'text', text: 'Fallback answer' }],
+          },
+        };
+      }),
+      delete: mock(async () => ({ data: true })),
+    };
+    mockV2Tool = {
+      ids: mock(async () => ({ data: ['read'] })),
+    };
+    mockV2Client = {
+      session: mockV2Session,
+      tool: mockV2Tool,
+    };
 
     const result = await runSecondaryModelWithFallback(
-      client,
-      '/tmp/project',
+      testInput,
       models,
       'Summarize',
       'This is enough fetched content to clear the short-content guard.',

+ 47 - 60
src/tools/smartfetch/secondary-model.ts

@@ -5,11 +5,10 @@ import type { PluginInput } from '@opencode-ai/plugin';
 import { stripJsonComments } from '../../cli/config-io';
 import { getConfigSearchDirs } from '../../cli/paths';
 import { loadPluginConfig } from '../../config/loader';
+import { getClient } from '../../utils/opencode-client';
 import { MAX_MODEL_CONTENT_CHARS } from './constants';
 import type { CachedFetch, SecondaryModel } from './types';
 
-type OpenCodeClient = PluginInput['client'];
-
 function parseModelRef(value: string | undefined) {
   if (!value) return undefined;
   const [providerID, ...rest] = value.split('/');
@@ -195,15 +194,15 @@ export const _testConfig = {
  * issue is visible instead of silently leaking sessions.
  */
 async function deleteSessionSafely(
-  client: OpenCodeClient,
+  input: PluginInput,
   sessionId: string,
-  directory: string,
 ): Promise<void> {
+  const v2 = getClient(input);
   for (let attempt = 1; attempt <= SESSION_DELETE_RETRIES; attempt++) {
     try {
-      await client.session.delete({
-        path: { id: sessionId },
-        query: { directory },
+      await v2.session.delete({
+        sessionID: sessionId,
+        directory: input.directory,
       });
       return;
     } catch (error) {
@@ -223,24 +222,32 @@ async function deleteSessionSafely(
 }
 
 async function runSecondaryModel(
-  client: OpenCodeClient,
-  directory: string,
+  input: PluginInput,
   model: SecondaryModel,
   prompt: string,
   content: string,
 ) {
-  const session = await client.session.create({
-    responseStyle: 'data',
-    throwOnError: true,
-    query: { directory },
-    body: { title: 'smartfetch-secondary' },
-  });
+  const v2 = getClient(input);
+  const directory = input.directory;
 
-  const sessionId =
-    (session as { data?: { id?: string }; id?: string })?.data?.id ??
-    (session as { data?: { id?: string }; id?: string })?.id;
+  const sessionResponse = await v2.session.create(
+    {
+      directory,
+      title: 'smartfetch-secondary',
+    },
+    { throwOnError: true },
+  );
+
+  const session = sessionResponse.data;
+  const sessionId = session?.id;
   if (!sessionId) {
-    throw new Error('Secondary model session did not return an id');
+    const errorDetail =
+      sessionResponse && 'error' in sessionResponse
+        ? `: ${JSON.stringify(sessionResponse.error)}`
+        : '';
+    throw new Error(
+      `Secondary model session did not return an id${errorDetail}`,
+    );
   }
 
   const sourceChars = content.length;
@@ -251,39 +258,28 @@ async function runSecondaryModel(
     ? `${prompt}\n\nNote: only the first ${inputChars} characters of a longer fetched document were provided.`
     : prompt;
   try {
-    const toolIDsResponse = await client.tool.ids({
-      responseStyle: 'data',
-      throwOnError: true,
-    });
-    const toolIDsData = toolIDsResponse as { data?: unknown };
-    const toolIDs = Array.isArray(toolIDsData.data)
-      ? (toolIDsData.data as string[])
-      : Array.isArray(toolIDsResponse)
-        ? toolIDsResponse
-        : [];
+    const toolIDsResponse = await v2.tool.ids({ directory });
+    const toolIDs = toolIDsResponse.data ?? [];
     const disabledTools = Object.fromEntries(
       (toolIDs || []).map((id: string) => [id, false]),
     );
 
+    const { variant, ...modelOnly } = model;
     const result = await Promise.race([
-      client.session.prompt({
-        responseStyle: 'data',
-        throwOnError: true,
-        path: { id: sessionId },
-        query: { directory },
-        body: {
-          model: { providerID: model.providerID, modelID: model.modelID },
-          ...(model.variant ? { variant: model.variant } : {}),
-          system:
-            'Answer only from the supplied content. Do not use tools or outside knowledge.',
-          tools: disabledTools,
-          parts: [
-            {
-              type: 'text',
-              text: buildPrompt(truncatedContent, effectivePrompt),
-            },
-          ],
-        },
+      v2.session.prompt({
+        sessionID: sessionId,
+        directory,
+        model: modelOnly,
+        ...(variant ? { variant } : {}),
+        system:
+          'Answer only from the supplied content. Do not use tools or outside knowledge.',
+        tools: disabledTools,
+        parts: [
+          {
+            type: 'text',
+            text: buildPrompt(truncatedContent, effectivePrompt),
+          },
+        ],
       }),
       new Promise<never>((_, reject) =>
         setTimeout(
@@ -295,9 +291,7 @@ async function runSecondaryModel(
 
     const parts =
       (result as { data?: { parts?: Array<{ type?: string; text?: string }> } })
-        ?.data?.parts ??
-      (result as { parts?: Array<{ type?: string; text?: string }> })?.parts ??
-      [];
+        ?.data?.parts ?? [];
     const text = parts
       .map((part) => (part?.type === 'text' ? part.text || '' : ''))
       .join('')
@@ -310,13 +304,12 @@ async function runSecondaryModel(
       sourceChars,
     };
   } finally {
-    await deleteSessionSafely(client, sessionId, directory);
+    await deleteSessionSafely(input, sessionId);
   }
 }
 
 export async function runSecondaryModelWithFallback(
-  client: OpenCodeClient,
-  directory: string,
+  input: PluginInput,
   models: SecondaryModel[],
   prompt: string,
   content: string,
@@ -324,13 +317,7 @@ export async function runSecondaryModelWithFallback(
   let lastError: unknown;
   for (const model of models) {
     try {
-      const result = await runSecondaryModel(
-        client,
-        directory,
-        model,
-        prompt,
-        content,
-      );
+      const result = await runSecondaryModel(input, model, prompt, content);
       if (!isUsableSecondaryText(result.text)) {
         lastError = new Error('Secondary model returned no usable text');
         continue;

+ 1 - 2
src/tools/smartfetch/tool.ts

@@ -713,8 +713,7 @@ export function createWebfetchTool(
         let secondaryModelError: string | undefined;
         try {
           secondaryRun = await runSecondaryModelWithFallback(
-            pluginCtx.client,
-            ctx.directory || process.cwd(),
+            pluginCtx,
             secondaryModels,
             args.prompt || '',
             fetchResult.markdown,

+ 24 - 0
src/utils/opencode-client.ts

@@ -0,0 +1,24 @@
+import type { PluginInput } from '@opencode-ai/plugin';
+import { createOpencodeClient, type OpencodeClient } from '@opencode-ai/sdk/v2';
+
+// process-scoped; cleared on process exit. Keyed by directory; the
+// OpenCode server holds session state, so a cached client stays valid
+// for the lifetime of the plugin process.
+const v2Clients = new Map<string, OpencodeClient>();
+
+/**
+ * Returns a memoized v2 OpenCode SDK client for the given plugin directory.
+ * The v2 client exposes session methods (switchModel, switchAgent) not
+ * available through PluginInput.client. Keyed by directory; the server
+ * holds session state so a cached client is valid for the process lifetime.
+ */
+export function getClient(input: PluginInput): OpencodeClient {
+  const cached = v2Clients.get(input.directory);
+  if (cached) return cached;
+  const client = createOpencodeClient({
+    baseUrl: input.serverUrl.origin,
+    directory: input.directory,
+  });
+  v2Clients.set(input.directory, client);
+  return client;
+}

+ 9 - 9
src/utils/session.test.ts

@@ -34,10 +34,10 @@ describe('session utilities', () => {
     } as any;
 
     await expect(
-      promptWithTimeout(client, { path: { id: 's1' }, body: { parts: [] } }, 5),
+      promptWithTimeout(client, { sessionID: 's1', parts: [] }, 5),
     ).rejects.toThrow('Prompt timed out after 5ms');
 
-    expect(abort).toHaveBeenCalledWith({ path: { id: 's1' } });
+    expect(abort).toHaveBeenCalledWith({ sessionID: 's1' });
   });
 
   test('promptWithTimeout preserves timeout error when abort fails', async () => {
@@ -53,12 +53,12 @@ describe('session utilities', () => {
     } as any;
 
     await expect(
-      promptWithTimeout(client, { path: { id: 's1' }, body: { parts: [] } }, 5),
+      promptWithTimeout(client, { sessionID: 's1', parts: [] }, 5),
     ).rejects.toThrow('Prompt timed out after 5ms');
 
     // Abort must still be attempted even though it threw; the original
     // timeout error is preserved.
-    expect(abort).toHaveBeenCalledWith({ path: { id: 's1' } });
+    expect(abort).toHaveBeenCalledWith({ sessionID: 's1' });
   });
 
   test('promptWithTimeout honors abort signal when timeout is disabled', async () => {
@@ -77,7 +77,7 @@ describe('session utilities', () => {
     await expect(
       promptWithTimeout(
         client,
-        { path: { id: 's1' }, body: { parts: [] } },
+        { sessionID: 's1', parts: [] },
         0,
         controller.signal,
       ),
@@ -86,7 +86,7 @@ describe('session utilities', () => {
     // Signal cancel must abort the server-side session, same as timeout.
     // Without this, the parent tool returns cancelled while the child keeps
     // running (orphan session).
-    expect(abort).toHaveBeenCalledWith({ path: { id: 's1' } });
+    expect(abort).toHaveBeenCalledWith({ sessionID: 's1' });
   });
 
   test('promptWithTimeout returns when prompt resolves with no timeout', async () => {
@@ -100,7 +100,7 @@ describe('session utilities', () => {
     } as any;
 
     await expect(
-      promptWithTimeout(client, { path: { id: 's1' }, body: { parts: [] } }, 0),
+      promptWithTimeout(client, { sessionID: 's1', parts: [] }, 0),
     ).resolves.toBeUndefined();
   });
 
@@ -138,13 +138,13 @@ describe('session utilities', () => {
       await expect(
         promptWithTimeout(
           client,
-          { path: { id: 's1' }, body: { parts: [] } },
+          { sessionID: 's1', parts: [] },
           5,
         ),
       ).rejects.toThrow('Prompt timed out after 5ms');
 
       // Timeout behavior is unchanged — abort is called
-      expect(abort).toHaveBeenCalledWith({ path: { id: 's1' } });
+      expect(abort).toHaveBeenCalledWith({ sessionID: 's1' });
 
       // Simulate a late provider response arriving after timeout
       if (deferredReject) {

+ 5 - 7
src/utils/session.ts

@@ -2,11 +2,9 @@
  * Shared session utilities for council and background managers.
  */
 
-import type { PluginInput } from '@opencode-ai/plugin';
+import { type OpencodeClient } from '@opencode-ai/sdk/v2';
 import { log } from './logger';
 
-type OpencodeClient = PluginInput['client'];
-
 export const SESSION_ABORT_TIMEOUT_MS = 1_000;
 
 export class OperationTimeoutError extends Error {
@@ -44,7 +42,7 @@ export async function abortSessionWithTimeout(
   timeoutMs = SESSION_ABORT_TIMEOUT_MS,
 ): Promise<void> {
   await withTimeout(
-    client.session.abort({ path: { id: sessionId } }),
+    client.session.abort({ sessionID: sessionId }),
     timeoutMs,
     `Session abort timed out after ${timeoutMs}ms`,
   );
@@ -103,7 +101,7 @@ export async function promptWithTimeout(
 ): Promise<void> {
   if (signal?.aborted) throw new Error('Prompt cancelled');
 
-  const sessionId = args.path.id;
+  const sessionId = args.sessionID;
   const hasTimeout = timeoutMs > 0;
   let timer: ReturnType<typeof setTimeout> | undefined;
   let onAbort: (() => void) | undefined;
@@ -201,8 +199,8 @@ export async function extractSessionResult(
   const includeReasoning = options?.includeReasoning ?? true;
 
   const messagesResult = await client.session.messages({
-    path: { id: sessionId },
-    ...(options?.directory ? { query: { directory: options.directory } } : {}),
+    sessionID: sessionId,
+    ...(options?.directory ? { directory: options.directory } : {}),
   });
   const messages = (messagesResult.data ?? []) as Array<{
     info?: { role: string };