From 5b5aaa4555858b80bca4542d0448ad9ea2b16483 Mon Sep 17 00:00:00 2001 From: Arjun Pramanik Date: Mon, 2 Nov 2026 00:38:45 -0500 Subject: [PATCH 1/7] build(deps-dev): pin @opencode/plugin 2.2.5 and OpenTUI 0.8.0 in every package @opencode/plugin 2.2.0 peers OpenTUI at exactly 0.7.0 and 2.2.5 peers >=0.8.0, so the two move together. 2.2.5 ships OpenTUI 0.8.0 (0.8.1 came after it). Dev pins stay identical across packages so Dependabot keeps one target; peer ranges are unchanged. --- bun.lock | 90 +++++++++---------- .../opencode-adversarial-review/package.json | 2 +- packages/opencode-agents-loader/package.json | 2 +- packages/opencode-auto-advisor/package.json | 6 +- .../opencode-double-tap-timeline/package.json | 6 +- packages/opencode-output-styles/package.json | 6 +- packages/opencode-ram-monitor/package.json | 6 +- 7 files changed, 56 insertions(+), 62 deletions(-) diff --git a/bun.lock b/bun.lock index e281a50..c6cd595 100644 --- a/bun.lock +++ b/bun.lock @@ -20,7 +20,7 @@ "name": "@capybearista/opencode-adversarial-review", "version": "2.0.2", "devDependencies": { - "@opencode/plugin": "2.2.0", + "@opencode/plugin": "2.2.5", "@tsconfig/node22": "^22.0.6", "@types/bun": "latest", "typescript": "^7.0.2", @@ -46,7 +46,7 @@ "gray-matter": "^4.0.3", }, "devDependencies": { - "@opencode/plugin": "2.2.0", + "@opencode/plugin": "2.2.5", }, "peerDependencies": { "@opencode/plugin": ">=2.0.2 <3", @@ -56,9 +56,9 @@ "name": "@capybearista/opencode-auto-advisor", "version": "2.0.0", "devDependencies": { - "@opencode/plugin": "2.2.0", - "@opentui/core": "0.7.0", - "@opentui/solid": "0.7.0", + "@opencode/plugin": "2.2.5", + "@opentui/core": "0.8.0", + "@opentui/solid": "0.8.0", "@types/bun": "latest", "@types/node": "^26.6.5", "solid-js": "1.9.17", @@ -80,9 +80,9 @@ "name": "@capybearista/opencode-double-tap-timeline", "version": "2.0.1", "devDependencies": { - "@opencode/plugin": "2.2.0", - "@opentui/core": "0.7.0", - "@opentui/solid": "0.7.0", + "@opencode/plugin": "2.2.5", + "@opentui/core": "0.8.0", + "@opentui/solid": "0.8.0", "solid-js": "1.9.17", }, "peerDependencies": { @@ -102,9 +102,9 @@ "yaml": "^2.9.1", }, "devDependencies": { - "@opencode/plugin": "2.2.0", - "@opentui/core": "0.7.0", - "@opentui/solid": "0.7.0", + "@opencode/plugin": "2.2.5", + "@opentui/core": "0.8.0", + "@opentui/solid": "0.8.0", "@types/bun": "latest", "@types/node": "^26.6.5", "solid-js": "1.9.17", @@ -126,9 +126,9 @@ "name": "@capybearista/opencode-ram-monitor", "version": "2.1.1", "devDependencies": { - "@opencode/plugin": "2.2.0", - "@opentui/core": "0.7.0", - "@opentui/solid": "0.7.0", + "@opencode/plugin": "2.2.5", + "@opentui/core": "0.8.0", + "@opentui/solid": "0.8.0", "@types/bun": "latest", "@types/node": "^26.6.5", "solid-js": "1.9.17", @@ -356,11 +356,11 @@ "@conventional-changelog/template": ["@conventional-changelog/template@1.4.0", "", {}, "sha512-aalGyl7dbB5PArRebDIX43ZvBlXrYm9uWzGJ26t+4SzJVPsOuvfILGGbw5X4yX7i50YEmJ8zvbiWnqH/AAnZqg=="], - "@effect/opentelemetry": ["@effect/opentelemetry@4.0.1", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.1" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-MZ7M1a1dxMqTqeNwIGSECjO7RrReEgeEwHo8BnbOqP+8IfVpdiJi/iPCmkFVWlAxanMl4qhsq69e0Hkul9t+bA=="], + "@effect/opentelemetry": ["@effect/opentelemetry@4.0.6", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.6" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-xhLR1EN4ZNaKM7Bx59SCFiRNduFzgH8LZXDrCpPN8MpQ93V0GQcFHTBah5T3EXO2v+5HTEacdTgetIA0Creqvw=="], - "@effect/platform-node": ["@effect/platform-node@4.0.1", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.1", "undici": "^8.11.2" }, "peerDependencies": { "effect": "^4.0.1", "redis": ">=5.0.0 <7.0.0" } }, "sha512-FT0Fu5LO0/XP9zBpj0HqrtJNLluK4StNKqf6TSulUsjp8FPBsDVXGJiDuyoN5J2yVMT7tgvhxSwaDcObWVRIaQ=="], + "@effect/platform-node": ["@effect/platform-node@4.0.6", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.6", "undici": "^8.12.2" }, "peerDependencies": { "effect": "^4.0.6", "redis": ">=5.0.0 <7.0.0" } }, "sha512-gXvGHAy0/8PZbWvMMguloDUyeSP+rzYEo3FSEqUuGduxo9ORcGioDxPI+GOLCIFfam7VuNkmSUxBOiRMIrMrBw=="], - "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.1", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.22.0" }, "peerDependencies": { "effect": "^4.0.1" } }, "sha512-VanaejudEhKJ4mpmqnEzdncVUhN6sGSO9ZDND54mRxAlZxj+4dYAWjDTYAy9o4tJo9aALeP2U8cVcOFzEdSeLg=="], + "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.6", "", { "dependencies": { "@types/ws": "^8.18.2", "ws": "^8.22.0" }, "peerDependencies": { "effect": "^4.0.6" } }, "sha512-0PhyC4uG30xGtsajnxjkcBWfTjNRzUh4ZKl1Kvo/MNyCTb+W2O+fBzf7wCRCoqt+nLudiq4V7vX40Q8XIBuMaQ=="], "@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="], @@ -432,17 +432,17 @@ "@opencode-ai/sdk": ["@opencode-ai/sdk@1.19.4", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-3Wuc4t8gEuWKHbX1Na9oDK6PgOveid70l4Ks5RCjRIo2RcP8FQN3NS6Mg3XWc2+WiUfpfrmvBWlh5u1zeQfDQw=="], - "@opencode/ai": ["@opencode/ai@2.2.0", "", { "dependencies": { "@aws-sdk/credential-providers": "3.1057.0", "@opencode/schema": "2.2.0", "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", "aws4fetch": "1.0.20", "effect": "4.0.1", "google-auth-library": "10.5.0" } }, "sha512-Jn8gKsJfC/BGhStq07uWqgk3D7ue77GE8bFDdTLPQSkdtVBbbJB2/HVmxHDCLjbmE53MqSynu4aDEBeX5PHfHQ=="], + "@opencode/ai": ["@opencode/ai@2.2.5", "", { "dependencies": { "@aws-sdk/credential-providers": "3.1057.0", "@opencode/schema": "2.2.5", "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", "aws4fetch": "1.0.20", "effect": "4.0.6", "google-auth-library": "10.5.0" } }, "sha512-TolUvNR615vpkc6FPf2np5PbAbO7k1gaVlrtZ5Wx5tN7Y8K2uSer0BZEiajJ2uBsF6RXJFfjjX1GZbcf85GtGQ=="], - "@opencode/client": ["@opencode/client@2.2.0", "", { "dependencies": { "@opencode/protocol": "2.2.0", "@opencode/schema": "2.2.0" }, "peerDependencies": { "effect": "4.0.1", "solid-js": ">=1.9.0" }, "optionalPeers": ["effect", "solid-js"] }, "sha512-gus2XLaUcGh3ERH+OnOqEO6K379cS9DFNPOgWr8OTM2VM+zn8b4Qe+dgfyysTayv1BpMMlILsRG4eKlDSuyYWQ=="], + "@opencode/client": ["@opencode/client@2.2.5", "", { "dependencies": { "@opencode/protocol": "2.2.5", "@opencode/schema": "2.2.5" }, "peerDependencies": { "effect": "4.0.6", "solid-js": ">=1.9.0" }, "optionalPeers": ["effect", "solid-js"] }, "sha512-EjYrFGWHURsIbjtmQdIsGSPmYZGaulxqwEfYxQH5t9838508BCIvDX9f7/ZsgNGHYXM0punA3W8hTt6GjxIEaw=="], - "@opencode/plugin": ["@opencode/plugin@2.2.0", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode/ai": "2.2.0", "@opencode/client": "2.2.0", "@opencode/protocol": "2.2.0", "@opencode/schema": "2.2.0", "@opencode/util": "2.2.0", "effect": "4.0.1" }, "peerDependencies": { "@opencode/theme": "2.2.0", "@opentui/core": "0.7.0", "@opentui/solid": "0.7.0", "electron": "*", "solid-js": ">=1.9.0", "vite": ">=6", "vite-plugin-solid": ">=2.11" }, "optionalPeers": ["@opencode/theme", "@opentui/core", "@opentui/solid", "electron", "solid-js", "vite", "vite-plugin-solid"] }, "sha512-1h1OM9B1RnUu2HEAYnUGcT21ib7aPXuIBUE0E1XYBhfmLvgMCmHWxtFUnU4sPyNHuY+EZ3kV501YGffAdxyI4A=="], + "@opencode/plugin": ["@opencode/plugin@2.2.5", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode/ai": "2.2.5", "@opencode/client": "2.2.5", "@opencode/protocol": "2.2.5", "@opencode/schema": "2.2.5", "@opencode/util": "2.2.5", "effect": "4.0.6" }, "peerDependencies": { "@opencode/theme": "2.2.5", "@opentui/core": ">=0.8.0", "@opentui/solid": ">=0.8.0", "electron": "*", "solid-js": ">=1.9.0", "vite": ">=6", "vite-plugin-solid": ">=2.11" }, "optionalPeers": ["@opencode/theme", "@opentui/core", "@opentui/solid", "electron", "solid-js", "vite", "vite-plugin-solid"] }, "sha512-DHtmC55UM5NbtZwyayLxI/ygTMt3n+6xXYYBkOdNxmZ+rLcIbpjmeJvySqdxFmT3K7Pk/2pHsuocxhquu6sysA=="], - "@opencode/protocol": ["@opencode/protocol@2.2.0", "", { "dependencies": { "@opencode/schema": "2.2.0", "effect": "4.0.1" } }, "sha512-bvZGwEU0WVgHXzDOsbmUluMWSCQZw6NVKNI+dGjNynxVWRvvpFJ+GA1wDiBkaAwx7dJ6FOH3SO3kNCJlPu2kOA=="], + "@opencode/protocol": ["@opencode/protocol@2.2.5", "", { "dependencies": { "@opencode/schema": "2.2.5", "effect": "4.0.6" } }, "sha512-T3ET0kS1kj8sv7/d+c1T2he7TXoZ4Rki7zTqHuv8b0kWhg3Vk6o0t+T9KsEQdEat7PFksgH5haC2iSSdJOB7/w=="], - "@opencode/schema": ["@opencode/schema@2.2.0", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "effect": "4.0.1" } }, "sha512-oyj1xiSnGA/Gwmqyk3MUVOwleozuT1Lt/x7eErhvNO0lyq2xhONnu7mK+riV98uMbD6+GxGWQV369gmpO9fAkQ=="], + "@opencode/schema": ["@opencode/schema@2.2.5", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "effect": "4.0.6" } }, "sha512-hlKk6cX28BwvOMOVG3Mtt4tEHxwY68Z6Gz5/cL32N8qfZexykCKsZai49Jms4/2gD8BzvVuBERnnlLDbdLstUA=="], - "@opencode/util": ["@opencode/util@2.2.0", "", { "dependencies": { "@effect/opentelemetry": "4.0.1", "@effect/platform-node": "4.0.1", "@effect/platform-node-shared": "4.0.1", "@npmcli/arborist": "9.4.0", "@npmcli/config": "10.8.1", "@opentelemetry/api": "1.9.0", "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/context-async-hooks": "2.8.0", "@opentelemetry/exporter-trace-otlp-http": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0", "@opentelemetry/sdk-trace-node": "2.8.0", "cross-spawn": "7.0.6", "effect": "4.0.1", "glob": "13.0.5", "mime-types": "3.0.2", "minimatch": "10.2.5", "npm-package-arg": "13.0.2", "open": "11.0.4", "pacote": "21.5.1" } }, "sha512-krxUN1w9Lwb9mk4WLCOJ4ki/RmqURN8bZ3evDBNgnOZYWI7at44ztDOX1+BAHJcfHFNTJER+2/j7MHudcnYhtQ=="], + "@opencode/util": ["@opencode/util@2.2.5", "", { "dependencies": { "@effect/opentelemetry": "4.0.6", "@effect/platform-node": "4.0.6", "@effect/platform-node-shared": "4.0.6", "@npmcli/arborist": "9.4.0", "@npmcli/config": "10.8.1", "@opentelemetry/api": "1.9.0", "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/context-async-hooks": "2.8.0", "@opentelemetry/exporter-trace-otlp-http": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0", "@opentelemetry/sdk-trace-node": "2.8.0", "cross-spawn": "7.0.6", "effect": "4.0.6", "glob": "13.0.5", "mime-types": "3.0.2", "minimatch": "10.2.5", "npm-package-arg": "13.0.2", "open": "11.0.4", "pacote": "21.5.1" } }, "sha512-RqbJ2EwxOs6oQMH1P4Yenkejit0oiAyxBTPXhzx4n6j4SOV0+Duz6lhftlJkAjTbq7Rw2b0Mzx4duOJUdw6s5A=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], @@ -470,25 +470,25 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], - "@opentui/core": ["@opentui/core@0.7.0", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.7.0", "@opentui/core-darwin-x64": "0.7.0", "@opentui/core-linux-arm64": "0.7.0", "@opentui/core-linux-arm64-musl": "0.7.0", "@opentui/core-linux-x64": "0.7.0", "@opentui/core-linux-x64-musl": "0.7.0", "@opentui/core-win32-arm64": "0.7.0", "@opentui/core-win32-x64": "0.7.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-2iWBw9oo+ZJfPbOWeTLXfDV63yTDRfclxltI4IgTa6n7OB90x5xplLMH3y9DXhnWibzTui00GbK3ZCwKfUU1SQ=="], + "@opentui/core": ["@opentui/core@0.8.0", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.8.0", "@opentui/core-darwin-x64": "0.8.0", "@opentui/core-linux-arm64": "0.8.0", "@opentui/core-linux-arm64-musl": "0.8.0", "@opentui/core-linux-x64": "0.8.0", "@opentui/core-linux-x64-musl": "0.8.0", "@opentui/core-win32-arm64": "0.8.0", "@opentui/core-win32-x64": "0.8.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-LwnIT7k+rGelridv7390enJXZpNuQYKIS3NdttJyoLPjbRFPyHnAKkCHk6X8MRS64vcSjOeVq9sl7XLWII16jQ=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.7.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-meEyZVUeCIauqHLWo/IsSlKzZPTnGIEjRLigefSZbrSAcOiMryd3OcUWvSByIXpYb9UPOpW0YxKKfVwepb9u2A=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.8.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Cp+BL1DsRZhUG56AuyXlH8DA4dsKfZ3Qgh1XMYDGokkC4p9uv8kdNNhyzmURAFa2LK77XqVv1Zhwr/6DqqpMlw=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.7.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-hZQ4OfAua5CtUv3hpZ81zhNTTLULbg0leQ/3eupjefityGP6163eS56IzymTWuiM/mVD/7Ad5bicH0I2JXhr1g=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.8.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jp4pzdIuKsS3XYq9xuWuAO52tTj9gnWeeoI3bg456CF6XnY2yQMasWOd/WmjfvYfAaJzGDFa4DNXhByBMPVuTw=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.7.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-/u+Kalzkz8NntUW5zjypgt9fnzeKohLG9YMEas7n98KvyaYScrtO4Mdj5iv/STNB3IfocJOChzYAbO6C8TmlPQ=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.8.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-b6D/VP9y0Z18tO86GSTp9BBykgR6r5CQM1ol9UMJfUt+PjN5HGksbOCDTMIQufXvt4WIUFHrSBProysJRFzbYw=="], - "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.7.0", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-BkTn+WLf9OPR4KhBnuOki+R/cgk2qwbL49eQn5fEYPeiJD4HPalGbEZDea59UjwNEJ7rqKq2yuPAXQjkDZ6y3w=="], + "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.8.0", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-8ZPl/2DChcS7XIVRPIazigXaMNyxa2bwvuzPprNbzT0+cVJLIoGC19pjiV3bI3oVXBPVMNqgIcppOrEHBhlouw=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.7.0", "", { "os": "linux", "cpu": "x64" }, "sha512-OD2tGl1r27zcgCsuc+TYU5e+qhrbsRSI0YUBEb0ebQo8P6Ps2FL9LFenH9om4fddCh06G9Zd45ekJMhlDrGD3Q=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.8.0", "", { "os": "linux", "cpu": "x64" }, "sha512-aXBNIdE6iMf58/Tdnd6UzXeFjYfaHzQ6E0nukjLe2ir41/meJJYEAHUQ62EBr/gxvuCNZHTBQB4T9BzF7PdENQ=="], - "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.7.0", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-HrJbcAIuayzQmWVzv+cjez37fNHL9FDoHmjwxAb4iK5GTjl89FZCxeKopyua14RA6WpP8/zUJQi8U16mm6XptA=="], + "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.8.0", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-xGEYw6wkX0sPwPx6RosQZanuWF0+5cIup2alhHHQQfKJkcLS8r8NBWlcy2LK9CoEWH6SBtNXC+9Y5IrryCOvJQ=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.7.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8yqBoRsDbvKl2kGX0O8t5kpnVEnbzs+2qUZlJ8sbeA3Z+73WFj36G+clUIMVxpspOu78iKXlD84NIwTxjZUPKw=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.8.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-XtK0QMyIiqeDmrIyyAXqqp7TKaRGaWnBlbnEsNrkRLrgP+aLwRhRSHdafxi+RMqj3h9qOlb1MgnPal1kx7wP4g=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.7.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OfNAX7fNFHhOiiojUkIjhVOZf+IDjpbpT5G7HrxGqhlZ+n4jITrrJzACHctteWbMUYcibieJzIIoKuLdmD5I+w=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.8.0", "", { "os": "win32", "cpu": "x64" }, "sha512-P5X2RcDDntIJ4q1WvQawGWCae5BzHrm50PMy7+PZ3Su8GGwnaCQ5xRHvDIQfhki8pZMo2SoTOkpStR0Wj1QoXQ=="], - "@opentui/solid": ["@opentui/solid@0.7.0", "", { "dependencies": { "@babel/core": "7.29.7", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.7.0", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-S6SNZGRykV02UjNbM+f/6lSeAPN3dfEbjcK1jAAdFCfa+EVhJqcJVyaJgtwHLU9fI6c/O1VKXPzbbFYj2RhNVQ=="], + "@opentui/solid": ["@opentui/solid@0.8.0", "", { "dependencies": { "@babel/core": "7.29.7", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.8.0", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.16", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "^1.9.12" } }, "sha512-fk035Q9iGXFgpkXSbpdyWYnT1jFuY0gOhzxaqBXDVtuWrQPB5MRxUxXHgESIMXJPsMTROIjOcKuW+PPhsakpnw=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], @@ -566,7 +566,7 @@ "@types/node": ["@types/node@26.6.5", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="], - "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@types/ws": ["@types/ws@8.18.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w=="], "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], @@ -624,11 +624,11 @@ "aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], - "babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.6", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-v3P1MW46Lm7VMpAkq0QfyzLWWkC8fh+0aE5Km4msIgDx5kjenHU0pF2s+4/NH8CQn/kla6+Hvws+2AF7bfV5qQ=="], + "babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.11", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-k7TkOv7uVYnrq/bYyVvG5cWOC9IObAGfmyW3aMkG8l2sq2qt/5THkUBja69PMLEGGi8G+fXB2Fh7eS4OwZRBoQ=="], "babel-plugin-module-resolver": ["babel-plugin-module-resolver@5.0.2", "", { "dependencies": { "find-babel-config": "^2.1.1", "glob": "^9.3.3", "pkg-up": "^3.1.0", "reselect": "^4.1.7", "resolve": "^1.22.8" } }, "sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg=="], - "babel-preset-solid": ["babel-preset-solid@1.9.12", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.6" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.12" }, "optionalPeers": ["solid-js"] }, "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg=="], + "babel-preset-solid": ["babel-preset-solid@1.9.16", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.11" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.16" }, "optionalPeers": ["solid-js"] }, "sha512-+0h40Qk2oBfkQ4OGDBHIDtvq/XQiOnCfzjNHmMxriAr8MmdsaIbyJgqYglDs4d70sF8O08sSpx61TKdpUt3oRw=="], "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], @@ -714,7 +714,7 @@ "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], - "effect": ["effect@4.0.1", "", {}, "sha512-b1VlQG9g8fwxE5QnIZuPoOP/0MsqHJSRKxUijjoM80E5q95FXrX5yWtY5XI8G+GJsBQAVSDbLS458z9a++uVvw=="], + "effect": ["effect@4.0.6", "", {}, "sha512-Tc3mkzuLR6t6P9dhLt0D2GrxYtmU5td4XEJEiXCmILz53IRIfJyHsKZWjTA3xEjiwpNTtXBgI4iqYPxMztBDrw=="], "electron-to-chromium": ["electron-to-chromium@1.5.349", "", {}, "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A=="], @@ -1208,12 +1208,8 @@ "@opencode-ai/plugin/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=="], - "@opentui/solid/solid-js": ["solid-js@1.9.12", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw=="], - "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], - "babel-plugin-jsx-dom-expressions/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - "babel-plugin-module-resolver/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], "cosmiconfig/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -1348,13 +1344,7 @@ "@npmcli/promise-spawn/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], - "@opentui/solid/solid-js/seroval": ["seroval@1.5.2", "", {}, "sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q=="], - - "@opentui/solid/solid-js/seroval-plugins": ["seroval-plugins@1.5.2", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg=="], - - "babel-plugin-jsx-dom-expressions/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], - - "babel-plugin-jsx-dom-expressions/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], "babel-plugin-module-resolver/glob/minimatch": ["minimatch@8.0.7", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="], @@ -1412,6 +1402,10 @@ "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/traverse/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "babel-plugin-module-resolver/glob/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], "babel-plugin-module-resolver/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], diff --git a/packages/opencode-adversarial-review/package.json b/packages/opencode-adversarial-review/package.json index 0160454..7c45458 100644 --- a/packages/opencode-adversarial-review/package.json +++ b/packages/opencode-adversarial-review/package.json @@ -36,7 +36,7 @@ "@opencode/plugin": ">=2.0.2 <3" }, "devDependencies": { - "@opencode/plugin": "2.2.0", + "@opencode/plugin": "2.2.5", "@tsconfig/node22": "^22.0.6", "@types/bun": "latest", "typescript": "^7.0.2" diff --git a/packages/opencode-agents-loader/package.json b/packages/opencode-agents-loader/package.json index f245160..f1ddd8d 100644 --- a/packages/opencode-agents-loader/package.json +++ b/packages/opencode-agents-loader/package.json @@ -37,6 +37,6 @@ "@opencode/plugin": ">=2.0.2 <3" }, "devDependencies": { - "@opencode/plugin": "2.2.0" + "@opencode/plugin": "2.2.5" } } diff --git a/packages/opencode-auto-advisor/package.json b/packages/opencode-auto-advisor/package.json index df0df09..702d29e 100644 --- a/packages/opencode-auto-advisor/package.json +++ b/packages/opencode-auto-advisor/package.json @@ -54,9 +54,9 @@ } }, "devDependencies": { - "@opencode/plugin": "2.2.0", - "@opentui/core": "0.7.0", - "@opentui/solid": "0.7.0", + "@opencode/plugin": "2.2.5", + "@opentui/core": "0.8.0", + "@opentui/solid": "0.8.0", "@types/bun": "latest", "@types/node": "^26.6.5", "solid-js": "1.9.17", diff --git a/packages/opencode-double-tap-timeline/package.json b/packages/opencode-double-tap-timeline/package.json index b64258a..68d49db 100644 --- a/packages/opencode-double-tap-timeline/package.json +++ b/packages/opencode-double-tap-timeline/package.json @@ -40,9 +40,9 @@ } }, "devDependencies": { - "@opencode/plugin": "2.2.0", - "@opentui/solid": "0.7.0", - "@opentui/core": "0.7.0", + "@opencode/plugin": "2.2.5", + "@opentui/solid": "0.8.0", + "@opentui/core": "0.8.0", "solid-js": "1.9.17" } } diff --git a/packages/opencode-output-styles/package.json b/packages/opencode-output-styles/package.json index 7f3220e..3cb17ae 100644 --- a/packages/opencode-output-styles/package.json +++ b/packages/opencode-output-styles/package.json @@ -56,9 +56,9 @@ } }, "devDependencies": { - "@opencode/plugin": "2.2.0", - "@opentui/core": "0.7.0", - "@opentui/solid": "0.7.0", + "@opencode/plugin": "2.2.5", + "@opentui/core": "0.8.0", + "@opentui/solid": "0.8.0", "@types/bun": "latest", "@types/node": "^26.6.5", "solid-js": "1.9.17", diff --git a/packages/opencode-ram-monitor/package.json b/packages/opencode-ram-monitor/package.json index 4773de6..04a1d01 100644 --- a/packages/opencode-ram-monitor/package.json +++ b/packages/opencode-ram-monitor/package.json @@ -41,9 +41,9 @@ } }, "devDependencies": { - "@opencode/plugin": "2.2.0", - "@opentui/core": "0.7.0", - "@opentui/solid": "0.7.0", + "@opencode/plugin": "2.2.5", + "@opentui/core": "0.8.0", + "@opentui/solid": "0.8.0", "@types/bun": "latest", "@types/node": "^26.6.5", "solid-js": "1.9.17", From a3f6b646de61f46488309c2b5b0093cdd4e25d82 Mon Sep 17 00:00:00 2001 From: Arjun Pramanik Date: Mon, 2 Nov 2026 00:40:35 -0500 Subject: [PATCH 2/7] fix(auto-advisor): re-subscribe to run events after a failed read Context: the OpenCode adapter closes an event stream on a failed read, so the listener that drops stale consults ended for good. It now re-subscribes after RESUBSCRIBE_MS and stops only on done (plugin unload). The fake stream in the server tests closes on error like the real adapter; AGENTS.md gotcha updated. --- .changeset/auto-advisor-event-resubscribe.md | 5 ++ packages/opencode-auto-advisor/AGENTS.md | 2 +- .../opencode-auto-advisor/src/server.test.ts | 71 ++++++++++++++--- packages/opencode-auto-advisor/src/server.ts | 76 ++++++++++++------- 4 files changed, 116 insertions(+), 38 deletions(-) create mode 100644 .changeset/auto-advisor-event-resubscribe.md diff --git a/.changeset/auto-advisor-event-resubscribe.md b/.changeset/auto-advisor-event-resubscribe.md new file mode 100644 index 0000000..8907063 --- /dev/null +++ b/.changeset/auto-advisor-event-resubscribe.md @@ -0,0 +1,5 @@ +--- +"@capybearista/opencode-auto-advisor": patch +--- + +Keep listening for the end of a run after the event stream fails. Before, one failed read stopped the listener until OpenCode restarted: Esc no longer ended a pending automatic review, a review of an interrupted run was delivered late into the next prompt, and finished requests stayed in memory. The plugin now subscribes again after one second. diff --git a/packages/opencode-auto-advisor/AGENTS.md b/packages/opencode-auto-advisor/AGENTS.md index cbbdfd2..3ec0452 100644 --- a/packages/opencode-auto-advisor/AGENTS.md +++ b/packages/opencode-auto-advisor/AGENTS.md @@ -32,7 +32,7 @@ - **Entries put in the `context` hook land one step later.** The runner admits entry changes at the step boundary, before the hook (`core/src/session/runner/llm.ts` at v2.2.0). The tail push covers the current step, and the entry covers every later one. - **Never append to the last message.** On a continuation step it is a `tool` message, and the Anthropic lowering accepts only tool-result parts there. - **The `context` hook re-fires on a retried step.** The retry may already carry the entry from the failed attempt. `stateKey` ignores system messages and memoizes the outcome; the marker stops a second copy. -- **Hooks and `generate.*` calls can't be cancelled.** A consult that outlives its run is dropped by the session epoch, which is bumped on `session.execution.{succeeded,failed,interrupted}`. The listener reading those events ends for good if one read fails: the adapter closes the stream on a read error (2.2.0 through 2.2.5) (`@opencode/plugin` `dist/promise/adapter.js`), so retrying the read does nothing. Accepted (no failed read has been seen): after one, a consult that outlives its run is no longer dropped, so its review is delivered late and stored in the entry for later runs, and the finished run's request stays in memory. A fix would re-subscribe; a test needs a fake stream that closes on error, like the real one. +- **Hooks and `generate.*` calls can't be cancelled.** A consult that outlives its run is dropped by the session epoch, which is bumped on `session.execution.{succeeded,failed,interrupted}`. On a failed read, the adapter closes the stream and `next()` rejects once (2.2.0 through 2.2.5; `packages/plugin/src/promise/adapter.ts` `makeStreams`), so retrying the read does nothing; a closed stream only reports `done`, which also happens when the plugin unloads. The listener therefore subscribes again after `RESUBSCRIBE_MS` (1 s) on a rejection and stops on `done`; events sent during that wait are missed. The fake stream in `src/server.test.ts` closes on error like the real one. - **Synthetic, skill, shell and compaction messages are `role: "user"` too**, and their metadata is dropped (`core/src/session/runner/to-llm-message.ts` at v2.2.0). A background subagent's result is one of them. They keep their stored ids in the hook. Real prompt ids come from the `prompt` hook plus every `type: "user"` message in `ctx.session.context`, read once per session (the session may predate the process). The turn starts at the newest real prompt present in the request: the hook fires at admission, before a steered or queued prompt is delivered. When a compaction drops that prompt, the turn carries on (its budget stays spent) and the requests block reads `(not in context)` until a new prompt arrives; a prompt absorbed into a compaction before any step saw it therefore never starts its own turn. Never treat other `role: "user"` messages as requests. If that history read fails, it is retried on the next step; until then, prompts from before the process started are labelled `NOTICE`. Accepted as rare and limited to one step. - **Our own reviews come back as system updates.** The Decision input leaves out system messages; otherwise a delivered review (`moment: done`) primes the next decision. - **A failing shell command is not a tool error.** A non-zero exit, a kill or a timeout completes the tool with a notice (`core/src/shell/result.ts`, unchanged through 2.2.5). On 2.2.5 the notice is its own text part (`tool/plugin/shell.ts`): a single part arrives as a plain string, several as `content`. The Decision input counts a failure only when a whole part of a `shell` or `bash` result is a notice (`bash` runs as `shell` on 2.2.5 and keeps its name; an alias note follows the notice), so quoted notices and other tools' output don't count. Tool results are truncated from the start so the notice survives. Recheck the strings and the part shape after an OpenCode upgrade. diff --git a/packages/opencode-auto-advisor/src/server.test.ts b/packages/opencode-auto-advisor/src/server.test.ts index d2e064a..5795d14 100644 --- a/packages/opencode-auto-advisor/src/server.test.ts +++ b/packages/opencode-auto-advisor/src/server.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { TOOL_DESCRIPTION } from "./advisor.js"; import fixtures from "./fixtures/s1-decisions.json"; -import server from "./server.js"; +import server, { RESUBSCRIBE_MS } from "./server.js"; import { COMPACTED, OMISSION_MARKER } from "./transcript.js"; // biome-ignore lint/suspicious/noExplicitAny: loosely typed fake host context @@ -32,6 +32,9 @@ afterEach(async () => { const writeConfig = (config: unknown) => writeFile(join(root, "config", "auto-advisor.json"), JSON.stringify(config)); +/** Queued as an event, it makes the fake stream's next read fail. */ +const READ_FAILURE = Symbol("read failure"); + const STUCK = fixtures.ok; const ROUTINE = { answers: [ @@ -130,18 +133,37 @@ async function start(setup: Setup = {}) { }, }, event: { - subscribe: () => ({ - [Symbol.asyncIterator]: () => ({ - next: async () => { - while (queue.length === 0) await new Promise((resolve) => (wake = resolve)); - return { done: false, value: queue.shift() }; - }, - return: async () => ({ done: true, value: undefined }), - }), - }), + // Like the real adapter (`promise/adapter.ts` at 2.2.5): a failed read closes the stream and + // rejects once; a closed stream (failed, returned, or unloaded) only reports `done`. + subscribe: () => { + subscriptions++; + let closed = false; + const done = { done: true, value: undefined }; + return { + [Symbol.asyncIterator]: () => ({ + next: async () => { + while (!closed && queue.length === 0) + await new Promise((resolve) => (wake = resolve)); + if (closed) return done; + const value = queue.shift(); + if (value === READ_FAILURE) { + closed = true; + throw new Error("event stream read failed"); + } + return { done: false, value }; + }, + return: async () => { + closed = true; + wake?.(); + return done; + }, + }), + }; + }, }, }; + let subscriptions = 0; const dispose = await server.setup(ctx); const send = async (event: Any) => { queue.push(event); @@ -157,6 +179,7 @@ async function start(setup: Setup = {}) { emitted, send, dispose, + subscriptions: () => subscriptions, tool: () => tool, rpc: () => rpc, }; @@ -491,6 +514,34 @@ describe("active (default)", () => { expect(host.entries.s).toBeUndefined(); }); + test("after a failed event read, it re-subscribes and still drops a stale consult", async () => { + let release: (value: { text: string }) => void = () => {}; + const host = await start({ advice: () => new Promise((resolve) => (release = resolve)) }); + await host.send(READ_FAILURE); + await Bun.sleep(RESUBSCRIBE_MS + 50); + expect(host.subscriptions()).toBe(2); + + const event = request("s", [user("u1"), ...step(1)]); + const pending = host.hooks.context(event); + await Bun.sleep(10); + await host.send({ + type: "session.execution.interrupted", + data: { sessionID: "s", reason: "user" }, + }); + release({ text: "late advice" }); + await pending; + expect(event.messages).toHaveLength(3); + expect(host.entries.s).toBeUndefined(); + }); + + test("a dispose during the re-subscribe wait ends the listener", async () => { + const host = await start(); + await host.send(READ_FAILURE); + await host.dispose(); + await Bun.sleep(RESUBSCRIBE_MS + 50); + expect(host.subscriptions()).toBe(1); + }); + test("an interrupt during the Decision call skips the consult", async () => { let answer: (value: Any) => void = () => {}; const host = await start({ decision: () => new Promise((resolve) => (answer = resolve)) }); diff --git a/packages/opencode-auto-advisor/src/server.ts b/packages/opencode-auto-advisor/src/server.ts index 1a93fd7..6de611b 100644 --- a/packages/opencode-auto-advisor/src/server.ts +++ b/packages/opencode-auto-advisor/src/server.ts @@ -36,6 +36,8 @@ export const PLUGIN_ID = "capybearista.opencode-auto-advisor"; const DEFAULT_CONTEXT_TOKENS = 128_000; /** Conservative chars-per-token, so the transcript fits even token-dense content. */ const CHARS_PER_TOKEN = 3; +/** Wait before re-subscribing after a failed event read, so a stream that keeps failing can't spin. */ +export const RESUBSCRIBE_MS = 1_000; interface Snapshot { readonly system: readonly SystemText[]; @@ -495,39 +497,59 @@ export default Plugin.define({ } }); - const events = ctx.event.subscribe({ - types: [ - "session.execution.succeeded", - "session.execution.failed", - "session.execution.interrupted", - "session.deleted", - ], - }); - const iterator = events[Symbol.asyncIterator](); + let disposed = false; + let iterator: AsyncIterator | undefined; + let backoff: ReturnType | undefined; + let resume: (() => void) | undefined; void (async () => { - for (;;) { - const next = await iterator.next(); - if (next.done) return; - const event = next.value as { type: string; data?: { sessionID?: string } }; - const sessionID = event.data?.sessionID; - if (!sessionID) continue; - if (event.type === "session.deleted") { - sessions.delete(sessionID); - continue; - } - const session = sessions.get(sessionID); - if (session) { - session.epoch++; - session.abort?.abort(); - // The request reference can hold large attachments; drop it once the run ends. - session.last = undefined; - session.memo = undefined; + while (!disposed) { + iterator = ctx.event + .subscribe({ + types: [ + "session.execution.succeeded", + "session.execution.failed", + "session.execution.interrupted", + "session.deleted", + ], + }) + [Symbol.asyncIterator](); + try { + for (;;) { + const next = await iterator.next(); + // The stream ends only when the plugin unloads. + if (next.done) return; + const event = next.value as { type: string; data?: { sessionID?: string } }; + const sessionID = event.data?.sessionID; + if (!sessionID) continue; + if (event.type === "session.deleted") { + sessions.delete(sessionID); + continue; + } + const session = sessions.get(sessionID); + if (session) { + session.epoch++; + session.abort?.abort(); + // The request reference can hold large attachments; drop it once the run ends. + session.last = undefined; + session.memo = undefined; + } + } + } catch { + // The adapter closes the stream on a failed read, so subscribe again. Events sent + // during the wait are missed. + await new Promise((resolve) => { + resume = resolve; + backoff = setTimeout(resolve, RESUBSCRIBE_MS); + }); } } })().catch(() => {}); return async () => { - await iterator.return?.(); + disposed = true; + clearTimeout(backoff); + resume?.(); + await iterator?.return?.(); await toolRegistration.dispose(); await promptHook.dispose(); await contextHook.dispose(); From e231e2c9df76f3310bfd39224a7b965a77aa6ec7 Mon Sep 17 00:00:00 2001 From: Arjun Pramanik Date: Mon, 2 Nov 2026 00:42:07 -0500 Subject: [PATCH 3/7] feat(auto-advisor)!: rename advisor.model to advisor.models Both model chains now share one shape: a single provider/model string or an ordered list. routing.models gains the single-string form. The old key is rejected as unknown. --- .changeset/auto-advisor-models-key.md | 7 ++++ packages/opencode-auto-advisor/README.md | 12 +++---- .../opencode-auto-advisor/scripts/sandbox.ts | 2 +- .../opencode-auto-advisor/src/config.test.ts | 33 ++++++++++++------- packages/opencode-auto-advisor/src/config.ts | 18 +++++----- .../opencode-auto-advisor/src/server.test.ts | 16 ++++----- 6 files changed, 51 insertions(+), 37 deletions(-) create mode 100644 .changeset/auto-advisor-models-key.md diff --git a/.changeset/auto-advisor-models-key.md b/.changeset/auto-advisor-models-key.md new file mode 100644 index 0000000..2162a64 --- /dev/null +++ b/.changeset/auto-advisor-models-key.md @@ -0,0 +1,7 @@ +--- +"@capybearista/opencode-auto-advisor": minor +--- + +The Advisor chain key is now `advisor.models`, matching `routing.models`. Both take one `"provider/model[#variant]"` string or an ordered list; `routing.models` now accepts a single string too. + +Breaking: rename `advisor.model` to `advisor.models` in `auto-advisor.json`. A file that still uses `advisor.model` is rejected as `unknown key: advisor.model`, which turns automatic reviews off and makes `advisor()` return the error. diff --git a/packages/opencode-auto-advisor/README.md b/packages/opencode-auto-advisor/README.md index f96b14f..1581dfd 100644 --- a/packages/opencode-auto-advisor/README.md +++ b/packages/opencode-auto-advisor/README.md @@ -69,7 +69,7 @@ Remove the entry from `"plugins"` in `opencode.json(c)`. Optionally delete `~/.c ## Usage - **Ask for a review**: the agent sees the `advisor()` tool and its guidance: consult after orienting and before substantive work, when stuck or changing approach, and before declaring done (after saving the work). An automatic review counts as a consultation already made. You can also say "consult the advisor before you continue". -- **Automatic reviews are on by default** (`routing.mode` `active`). Without `advisor.model`, they consult the agent's own model and are billed at its rates; set `advisor.model`, or set `routing.mode` to `observe` or `off`, if that's not what you want. +- **Automatic reviews are on by default** (`routing.mode` `active`). Without `advisor.models`, they consult the agent's own model and are billed at its rates; set `advisor.models`, or set `routing.mode` to `observe` or `off`, if that's not what you want. - Automatic reviews run in top-level sessions only. Subagent sessions don't get the tool or automatic reviews. - Automatic reviews are limited to two per user turn (`routing.maxConsultationsPerTurn`) and to one per 10 agent steps across the session (`routing.minStepsBetweenConsultations`). The step count carries over to the next turn, and a retried step doesn't count. Both limits are kept in memory, so restarting OpenCode resets them. Explicit `advisor()` calls ignore both limits. @@ -79,19 +79,19 @@ Global, optional, strict JSON at `~/.config/opencode/auto-advisor.json` (or `$OP ```json { - "advisor": { "model": "openai/gpt-6" }, + "advisor": { "models": "openai/gpt-6" }, "routing": { "mode": "active" } } ``` | Key | Default | Description | | :--- | :--- | :--- | -| `advisor.model` | `"inherit"` | `"provider/model"` of the Advisor, optionally with `#variant` for its reasoning level (`"openai/gpt-6#high"`). Without a variant, the provider's default applies. `"inherit"` uses the agent's own model and variant. A list of 1–8 entries is a fallback chain, tried in order: an unreachable model, an exhausted quota, an overload, a missing model or a request the model rejects (such as one over its context window) moves to the next at once. A content filter or an invalid `#variant` stops the chain. A rejection the provider reports without an HTTP status (mid-stream, for example) moves on too. A model that was rate-limited or overloaded, or that ran out the plugin's own deadline, is skipped for 60 s. Each model gets a transcript sized to its own window. Worst cases: a refused connection costs about 12 s before the next model (OpenCode retries it), and a provider that accepts the connection and then goes silent costs up to OpenCode's `headerTimeout` (5 min) or `chunkTimeout` (15 min) on every consult; lower those in that provider's settings, or set `advisor.timeoutMs`. | -| `advisor.timeoutMs` | `-1` | One deadline for the whole `advisor.model` chain, or `-1` for none. With none, a model that is still reasoning is never cut off; OpenCode's own provider timeouts (`headerTimeout`, 5 min without a response; `chunkTimeout`, 15 min without any data) end a silent one, and the chain moves on. A consult that runs out fails open; an explicit one returns an error. Esc (interrupt) stops the wait at once; the provider request itself runs on, because OpenCode's plugin API can't cancel it. | +| `advisor.models` | `"inherit"` | `"provider/model"` of the Advisor, optionally with `#variant` for its reasoning level (`"openai/gpt-6#high"`). Without a variant, the provider's default applies. `"inherit"` uses the agent's own model and variant. A list of 1–8 entries is a fallback chain, tried in order: an unreachable model, an exhausted quota, an overload, a missing model or a request the model rejects (such as one over its context window) moves to the next at once. A content filter or an invalid `#variant` stops the chain. A rejection the provider reports without an HTTP status (mid-stream, for example) moves on too. A model that was rate-limited or overloaded, or that ran out the plugin's own deadline, is skipped for 60 s. Each model gets a transcript sized to its own window. Worst cases: a refused connection costs about 12 s before the next model (OpenCode retries it), and a provider that accepts the connection and then goes silent costs up to OpenCode's `headerTimeout` (5 min) or `chunkTimeout` (15 min) on every consult; lower those in that provider's settings, or set `advisor.timeoutMs`. | +| `advisor.timeoutMs` | `-1` | One deadline for the whole `advisor.models` chain, or `-1` for none. With none, a model that is still reasoning is never cut off; OpenCode's own provider timeouts (`headerTimeout`, 5 min without a response; `chunkTimeout`, 15 min without any data) end a silent one, and the chain moves on. A consult that runs out fails open; an explicit one returns an error. Esc (interrupt) stops the wait at once; the provider request itself runs on, because OpenCode's plugin API can't cancel it. | | `advisor.compactContext` | `true` | Replaces OpenCode's Code Mode tool catalog, MCP server instructions and skill catalog with `[context-omitted: …]` markers in the Advisor's copy of the context. Your rules (AGENTS.md) and the conversation stay. The agent's own context is never changed. Incremental notices about tools, MCP servers or skills added mid-session are kept. `false` sends everything. | | `advisor.maxTokens` | `64000` | Approximate ceiling on the transcript sent per consult, at least `1000` (the tool-name list is never trimmed). The Advisor model's context window lowers it further (75% of the window). Over budget, older turns in the middle are left out; the system prompt, the first request and the newest steps stay. Counted at 3 characters per token, so the real token count is usually lower. | | `routing.mode` | `"active"` | `off`, `observe` (Decision model only, logged), or `active` (consults and delivers). | -| `routing.models` | `["opencode/jev-1.13-free"]` | Ordered decision models, 1–8 `"provider/model[#variant]"` refs. On an availability or auth failure, or a request the model rejects (such as one over its context window), the plugin tries the next one, so a small, fast model can go first. Each model gets 5 s, the whole chain 10 s. | +| `routing.models` | `["opencode/jev-1.13-free"]` | One `"provider/model[#variant]"` decision model, or an ordered list of 1–8. On an availability or auth failure, or a request the model rejects (such as one over its context window), the plugin tries the next one, so a small, fast model can go first. Each model gets 5 s, the whole chain 10 s. | | `routing.momentThreshold` | `0.7` | Minimum probability that the step is not routine. | | `routing.consequenceThreshold` | `1.5` | Minimum stakes score (0 trivial … 4 critical), compared unrounded. | | `routing.maxConsultationsPerTurn` | `2` | Automatic consults per user turn. | @@ -103,7 +103,7 @@ Global, optional, strict JSON at `~/.config/opencode/auto-advisor.json` (or `$OP ## Privacy and cost - **Decision provider**: in `observe` and `active`, each step's last `routing.requests` user requests (up to 20,000 characters each by default; see `routing.requestMaxChars`), recent tool calls and truncated results (no system prompt, no tool definitions) go to the configured decision model. The default, `opencode/jev-1.13-free`, is a limited-time free model on OpenCode Zen, built by TypeSafe AI. Check Zen's data-retention terms for it before using it with sensitive code. -- **Advisor**: automatic reviews are on by default and, without `advisor.model`, use the agent's own model. Each consult sends the agent's transcript (system prompt with OpenCode's catalogs left out when `advisor.compactContext` is on, up to `advisor.maxTokens`) to the Advisor model, uncached. It's billed at that model's rates. +- **Advisor**: automatic reviews are on by default and, without `advisor.models`, use the agent's own model. Each consult sends the agent's transcript (system prompt with OpenCode's catalogs left out when `advisor.compactContext` is on, up to `advisor.maxTokens`) to the Advisor model, uncached. It's billed at that model's rates. - **Latency**: in `active`, every step waits for one Decision call (about 0.1–0.4 s on Zen after a cold start), except while a limit blocks consults: then the call runs in the background, as in `observe`. A consult pauses the agent until the Advisor answers. - **Cache**: installing the plugin adds the `advisor` tool, which costs one prompt-cache miss on the tools block. After that, reviews only add to the end of a request. - **Telemetry**: one JSON line per routing decision in `~/.local/share/opencode/auto-advisor/telemetry.jsonl` (capped at 5 MB, one rotation), with models, answers, verdicts and timings. It also keeps the step's Decision input (its last 32,000 characters: your recent requests, tool calls and truncated results) and the id of the newest message, all on your machine and readable only by you. Never the Advisor's transcript or advice text. At the 32,000-character maximum, a 5 MB file holds about 150 steps, so long sessions keep only their latest steps. diff --git a/packages/opencode-auto-advisor/scripts/sandbox.ts b/packages/opencode-auto-advisor/scripts/sandbox.ts index 8fc2032..b233296 100644 --- a/packages/opencode-auto-advisor/scripts/sandbox.ts +++ b/packages/opencode-auto-advisor/scripts/sandbox.ts @@ -146,7 +146,7 @@ try { ); writeFileSync( join(config, "auto-advisor.json"), - JSON.stringify({ advisor: { model: "mock/exec" } }, null, 1), + JSON.stringify({ advisor: { models: "mock/exec" } }, null, 1), ); writeFileSync(join(project, "notes.txt"), "Sandbox notes.\n"); diff --git a/packages/opencode-auto-advisor/src/config.test.ts b/packages/opencode-auto-advisor/src/config.test.ts index 7037407..27a5db2 100644 --- a/packages/opencode-auto-advisor/src/config.test.ts +++ b/packages/opencode-auto-advisor/src/config.test.ts @@ -56,19 +56,19 @@ describe("parseConfig", () => { expect(parseConfig({ advisor: { compactContext: false } }).advisor.compactContext).toBe(false); }); - test("advisor.model accepts provider/model and inherit", () => { - expect(parseConfig({ advisor: { model: "openai/gpt-6" } }).advisor.models).toEqual([ + test("advisor.models accepts provider/model and inherit", () => { + expect(parseConfig({ advisor: { models: "openai/gpt-6" } }).advisor.models).toEqual([ { providerID: "openai", id: "gpt-6" }, ]); - expect(parseConfig({ advisor: { model: "inherit" } }).advisor.models).toEqual(["inherit"]); - expect(parseConfig({ advisor: { model: "openai/gpt-6#high" } }).advisor.models).toEqual([ + expect(parseConfig({ advisor: { models: "inherit" } }).advisor.models).toEqual(["inherit"]); + expect(parseConfig({ advisor: { models: "openai/gpt-6#high" } }).advisor.models).toEqual([ { providerID: "openai", id: "gpt-6", variant: "high" }, ]); }); - test("advisor.model accepts an ordered fallback list, inherit included", () => { + test("advisor.models accepts an ordered fallback list, inherit included", () => { const config = parseConfig({ - advisor: { model: ["opencode-go/deepseek#max", "openrouter/meta/muse#xhigh", "inherit"] }, + advisor: { models: ["opencode-go/deepseek#max", "openrouter/meta/muse#xhigh", "inherit"] }, }); expect(config.advisor.models).toEqual([ { providerID: "opencode-go", id: "deepseek", variant: "max" }, @@ -82,6 +82,12 @@ describe("parseConfig", () => { expect(parseConfig({ advisor: { timeoutMs: 600_000 } }).advisor.timeoutMs).toBe(600_000); }); + test("routing.models accepts a single provider/model string", () => { + expect(parseConfig({ routing: { models: "opencode/jev-1.13-free" } }).routing.models).toEqual([ + { providerID: "opencode", id: "jev-1.13-free" }, + ]); + }); + test("routing.models keeps order and splits on the first slash", () => { const config = parseConfig({ routing: { models: ["openrouter/typesafe/jev-1.13", "opencode/jev-1.13-free"] }, @@ -100,6 +106,8 @@ describe("parseConfig", () => { [{ routing: { models: Array.from({ length: 9 }, (_, i) => `p/m${i}`) } }, "routing.models"], [{ routing: { models: ["noslash"] } }, "routing.models"], [{ routing: { models: ["inherit"] } }, "routing.models"], + [{ routing: { models: "inherit" } }, "routing.models"], + [{ routing: { models: "noslash" } }, "routing.models"], [{ routing: { momentThreshold: 1.2 } }, "routing.momentThreshold"], [{ routing: { consequenceThreshold: -1 } }, "routing.consequenceThreshold"], [{ routing: { consequenceThreshold: 5 } }, "routing.consequenceThreshold"], @@ -115,17 +123,18 @@ describe("parseConfig", () => { [{ routing: { requestMaxChars: -2 } }, "routing.requestMaxChars"], [{ routing: { requestMaxChars: 150.5 } }, "routing.requestMaxChars"], [{ advisor: { timeoutMs: 0 } }, "advisor.timeoutMs"], - [{ advisor: { model: "x" } }, "advisor.model"], - [{ advisor: { model: [] } }, "advisor.model"], - [{ advisor: { model: ["a/b", "a/b"] } }, "advisor.model"], - [{ advisor: { model: ["inherit", "inherit"] } }, "advisor.model"], - [{ advisor: { model: ["a/b", "x"] } }, "advisor.model"], - [{ advisor: { model: Array.from({ length: 9 }, (_, i) => `p/m${i}`) } }, "advisor.model"], + [{ advisor: { models: "x" } }, "advisor.models"], + [{ advisor: { models: [] } }, "advisor.models"], + [{ advisor: { models: ["a/b", "a/b"] } }, "advisor.models"], + [{ advisor: { models: ["inherit", "inherit"] } }, "advisor.models"], + [{ advisor: { models: ["a/b", "x"] } }, "advisor.models"], + [{ advisor: { models: Array.from({ length: 9 }, (_, i) => `p/m${i}`) } }, "advisor.models"], [{ advisor: { timeoutMs: -2 } }, "advisor.timeoutMs"], [{ advisor: { compactContext: "yes" } }, "advisor.compactContext"], [{ advisor: { maxTokens: 64 } }, "advisor.maxTokens must be"], [{ advisor: { maxTokens: 1_500.5 } }, "advisor.maxTokens must be"], [{ advisor: { other: 1 } }, "unknown key: advisor.other"], + [{ advisor: { model: "openai/gpt-6" } }, "unknown key: advisor.model"], [{ tui: { sidebar: "yes" } }, "tui.sidebar"], [{ tui: { footer: true } }, "unknown key: tui.footer"], [[], "must be a JSON object"], diff --git a/packages/opencode-auto-advisor/src/config.ts b/packages/opencode-auto-advisor/src/config.ts index fdfdb76..5b15f70 100644 --- a/packages/opencode-auto-advisor/src/config.ts +++ b/packages/opencode-auto-advisor/src/config.ts @@ -107,8 +107,9 @@ function number(value: unknown, name: string, min: number, max: number, integer return value as number; } -/** 1..MAX_MODELS distinct entries, each a model ref or, where allowed, "inherit". */ -function modelList(list: unknown, inherit: boolean): (ModelRef | "inherit")[] | undefined { +/** One entry or 1..MAX_MODELS distinct entries, each a model ref or, where allowed, "inherit". */ +function modelList(value: unknown, inherit: boolean): (ModelRef | "inherit")[] | undefined { + const list = typeof value === "string" ? [value] : value; if (!Array.isArray(list) || list.length === 0 || list.length > MAX_MODELS) return undefined; if (new Set(list).size !== list.length) return undefined; const refs = list.map((item) => @@ -143,7 +144,7 @@ export function parseConfig(input: unknown): Config { throw new ConfigError(`unknown key: ${key}`); } } - const advisor = section(input, "advisor", ["model", "timeoutMs", "compactContext", "maxTokens"]); + const advisor = section(input, "advisor", ["models", "timeoutMs", "compactContext", "maxTokens"]); const routing = section(input, "routing", [ "mode", "models", @@ -157,14 +158,11 @@ export function parseConfig(input: unknown): Config { const tui = section(input, "tui", ["sidebar"]); let advisorModels = DEFAULTS.advisor.models; - if (advisor.model !== undefined) { - const list = modelList( - typeof advisor.model === "string" ? [advisor.model] : advisor.model, - true, - ); + if (advisor.models !== undefined) { + const list = modelList(advisor.models, true); if (!list) { throw new ConfigError( - `advisor.model must be "inherit", "provider/model[#variant]" or a list of 1..${MAX_MODELS} distinct such entries`, + `advisor.models must be "inherit", "provider/model[#variant]" or a list of 1..${MAX_MODELS} distinct such entries`, ); } advisorModels = list; @@ -183,7 +181,7 @@ export function parseConfig(input: unknown): Config { const list = modelList(routing.models, false); if (!list) { throw new ConfigError( - `routing.models must list 1..${MAX_MODELS} distinct "provider/model[#variant]" decision models`, + `routing.models must be "provider/model[#variant]" or a list of 1..${MAX_MODELS} distinct such decision models`, ); } models = list as ModelRef[]; diff --git a/packages/opencode-auto-advisor/src/server.test.ts b/packages/opencode-auto-advisor/src/server.test.ts index 5795d14..d7e5b9b 100644 --- a/packages/opencode-auto-advisor/src/server.test.ts +++ b/packages/opencode-auto-advisor/src/server.test.ts @@ -660,7 +660,7 @@ describe("invalid config", () => { describe("explicit advisor()", () => { test("reviews the last request plus the in-flight call, without spending the budget", async () => { - await writeConfig({ advisor: { model: "openai/gpt-6#high" } }); + await writeConfig({ advisor: { models: "openai/gpt-6#high" } }); const host = await start({ decision: async () => ROUTINE }); await host.hooks.context(request("s", [user("u1"), ...step(1)])); const result = await host @@ -673,7 +673,7 @@ describe("explicit advisor()", () => { }); test("an invalid config fails the call instead of using the Executor's model", async () => { - await writeConfig({ advisor: { model: "openai/gpt-6", unknown: true } }); + await writeConfig({ advisor: { models: "openai/gpt-6", unknown: true } }); const host = await start(); await host.hooks.context(request("s", [user("u1"), ...step(1)])); const result = await host @@ -779,7 +779,7 @@ describe("advisor chain", () => { test("an Advisor that fails hands over to the next; the answering model is reported", async () => { // Neither is the Executor's model, so the report cannot fall back to it. - await writeConfig({ advisor: { model: ["mock/dead", "mock/small"] } }); + await writeConfig({ advisor: { models: ["mock/dead", "mock/small"] } }); const host = await start({ advice: async (input) => input.model.id === "dead" @@ -802,7 +802,7 @@ describe("advisor chain", () => { test("the explicit tool names every model's failure", async () => { // Routing off: an automatic consult would rate-limit mock/a first, and it would be skipped. - await writeConfig({ advisor: { model: ["mock/a", "inherit"] }, routing: { mode: "off" } }); + await writeConfig({ advisor: { models: ["mock/a", "inherit"] }, routing: { mode: "off" } }); const host = await start({ advice: async (input) => Promise.reject( @@ -821,7 +821,7 @@ describe("advisor chain", () => { }); test("the transcript is rebuilt for each model's window", async () => { - await writeConfig({ advisor: { model: ["inherit", "mock/small"] } }); + await writeConfig({ advisor: { models: ["inherit", "mock/small"] } }); const host = await start({ advice: async (input) => input.model.id === "exec" @@ -839,7 +839,7 @@ describe("advisor chain", () => { }); test("timeoutMs is one deadline for the chain; running out is a timeout", async () => { - await writeConfig({ advisor: { model: ["inherit", "mock/small"], timeoutMs: 50 } }); + await writeConfig({ advisor: { models: ["inherit", "mock/small"], timeoutMs: 50 } }); const host = await start({ advice: () => new Promise(() => {}) }); await host.hooks.context(request("s", [user("u1"), ...step(1)])); await Bun.sleep(20); @@ -900,7 +900,7 @@ describe("decision context in telemetry", () => { describe("advisor chain, failures", () => { test("an interrupt ends the chain; the next model is never asked", async () => { - await writeConfig({ advisor: { model: ["inherit", "mock/small"] } }); + await writeConfig({ advisor: { models: ["inherit", "mock/small"] } }); let release: () => void = () => {}; const host = await start({ advice: () => new Promise((resolve) => (release = () => resolve({ text: "late" }))), @@ -926,7 +926,7 @@ describe("advisor chain, failures", () => { }, }); await writeConfig({ - advisor: { model: ["inherit", "mock/small"], timeoutMs: 40 }, + advisor: { models: ["inherit", "mock/small"], timeoutMs: 40 }, routing: { minStepsBetweenConsultations: 0 }, }); const history: Any[] = [user("u1"), ...step(1)]; From b51db9e93cdbc0f2f719192726b7c7618d8dbe37 Mon Sep 17 00:00:00 2001 From: Arjun Pramanik Date: Mon, 2 Nov 2026 00:50:01 -0500 Subject: [PATCH 4/7] feat(auto-advisor): space out Decision calls and skip them on blocked steps Context: since 2026-11-01 Zen rate-limits jev-1.13-free, and the 60 s skip sent 86% of decisions to the paid fallback; 28% of calls ran on steps a limit already blocked. routing.minStepsBetweenDecisions (default 10) spaces calls across the session, and blocked steps log a line without calling. The server test harness now writes a base config that decides on every step. --- .changeset/auto-advisor-decision-spacing.md | 7 ++ docs/auto-advisor/findings.md | 5 + packages/opencode-auto-advisor/AGENTS.md | 2 +- packages/opencode-auto-advisor/README.md | 13 +-- .../opencode-auto-advisor/src/config.test.ts | 11 +++ packages/opencode-auto-advisor/src/config.ts | 14 +++ .../opencode-auto-advisor/src/server.test.ts | 99 +++++++++++++++++-- packages/opencode-auto-advisor/src/server.ts | 36 +++++-- .../opencode-auto-advisor/src/telemetry.ts | 4 +- 9 files changed, 166 insertions(+), 25 deletions(-) create mode 100644 .changeset/auto-advisor-decision-spacing.md diff --git a/.changeset/auto-advisor-decision-spacing.md b/.changeset/auto-advisor-decision-spacing.md new file mode 100644 index 0000000..6fe58cc --- /dev/null +++ b/.changeset/auto-advisor-decision-spacing.md @@ -0,0 +1,7 @@ +--- +"@capybearista/opencode-auto-advisor": minor +--- + +Call the Decision model far less often. The new `routing.minStepsBetweenDecisions` (default 10; `1` restores every step) spaces Decision calls across a session, and a step where the cooldown or the per-turn limit already blocks a consult no longer calls it at all; its telemetry line now has no answers. This saves a free decision model's rate limit and keeps the paid fallback from taking over. + +Behavior change: with the default, a moment that falls on a skipped step gets no automatic review, and a turn shorter than 10 steps often ends without one. Lower the setting to trade calls for coverage. diff --git a/docs/auto-advisor/findings.md b/docs/auto-advisor/findings.md index 4a9813d..cab3745 100644 --- a/docs/auto-advisor/findings.md +++ b/docs/auto-advisor/findings.md @@ -95,6 +95,11 @@ Each section names the date and the OpenCode version it was checked on. S1–S5 - The timeline now sends each page to a file in a private temporary directory. Against a live server it then worked 5/5, where the old script failed 3/3. `scripts/timeline.test.ts` reproduces the cut with a fake CLI that writes 3 MB pages and exits at once (20/20 failures before the fix, 0/20 after). - Anything else that reads `opencode api` output must use a file, not a pipe. +## Decision spacing and pin bump (2026-11-02, OpenCode 2.2.5) +- Real telemetry from 2026-11-01 on: `opencode/jev-1.13-free` returned `rate_limit` with one session at 1–2 calls a minute, and on the first call after a 3 h gap; from Oct 28 to 31, 1,412 calls had none. One 429 skips the model for 60 s, so 346 of 402 answered decisions (86%) went to the paid fallback, and 115 of 408 calls (28%) ran on steps where a limit already blocked the consult. +- `routing.minStepsBetweenDecisions` (default 10) now spaces the calls, and a blocked step no longer calls the Decision model. In the sandbox, built against `@opencode/plugin` 2.2.5 and OpenTUI 0.8.0 with `advisor.models` in its config: two prompts, five agent steps, one telemetry line (step 1), as expected. That single call from a fresh sandbox was also rate limited, so the throttling isn't only our volume. +- The event re-subscribe can't be triggered live (it needs a failing stream); `src/server.test.ts` covers it with a fake stream that closes on error like `promise/adapter.ts` at 2.2.5. + ## Known limitations - `routing.requestMaxChars` caps each request, not the Decision input as a whole. The input is up to `routing.requests` × the cap plus about 12k characters of recent steps. The default is 20k (about 72k characters in total, roughly 18k–24k tokens) because Zen's free `jev-1.13` declares a 64k-token window (`models-dev` snapshot, 2.2.5). At 60k it could reach about 193k characters, and live it rejected 250k. A raised cap, more requests or a smaller first model can still overflow: the step then falls through to the next model, or gets `no-decision`, and automatic reviews pause until the large requests leave the window. Follow-up: size the requests block from each decision model's window. - A prompt that a summary compaction absorbs before any step sees it never starts its own turn. The turn is the newest real prompt present in the request (`server.ts`); when none is present it carries on from the last one, so the earlier turn's spent budget and step count continue and the Decision requests block reads `(not in context)` until the next prompt. Accepted as rare and fail-closed (fewer consults, never more). diff --git a/packages/opencode-auto-advisor/AGENTS.md b/packages/opencode-auto-advisor/AGENTS.md index 3ec0452..83747bf 100644 --- a/packages/opencode-auto-advisor/AGENTS.md +++ b/packages/opencode-auto-advisor/AGENTS.md @@ -19,7 +19,7 @@ ## Architecture -- `src/server.ts` wires everything: the `advisor` tool, a `prompt` hook that records real prompt ids, the `context` hook (route → consult → deliver, within the per-turn budget and the session step cooldown), an event subscription that ends stale consults, the `auto-advisor` RPC and telemetry. +- `src/server.ts` wires everything: the `advisor` tool, a `prompt` hook that records real prompt ids, the `context` hook (route → consult → deliver, within the per-turn budget and the session step cooldown; the Decision model runs only every `routing.minStepsBetweenDecisions` distinct steps and never on a step those limits block), an event subscription that ends stale consults, the `auto-advisor` RPC and telemetry. - `src/fallback.ts`: the model-chain walker shared by the Decision router and the Advisor chain (error policy, skip map, shared deadline). `src/decisions.ts`: the two Decision questions and the router over `routing.models`. `src/policy.ts`: the pure consult verdict. - `src/transcript.ts`: the Decision projection (the last `routing.requests` real prompts, each capped at `routing.requestMaxChars`, 20k by default (per request, not a total), the turn's recent steps without system updates, with subagent results and other user-role notices labelled `SUBAGENT`/`NOTICE`, and failure counts; no system prompt and no total cap) and the Advisor transcript (system text, tool names, messages with the same `SUBAGENT`/`NOTICE` labels; compacted, then budgeted, keeping the first real request to `min(advisor.maxTokens, 75% of the window)` at 3 chars per token). - `src/advisor.ts`: prompts and the timeout-raced consult. `src/delivery.ts`: review text, marker, tail push. diff --git a/packages/opencode-auto-advisor/README.md b/packages/opencode-auto-advisor/README.md index 1581dfd..382b0d0 100644 --- a/packages/opencode-auto-advisor/README.md +++ b/packages/opencode-auto-advisor/README.md @@ -71,7 +71,7 @@ Remove the entry from `"plugins"` in `opencode.json(c)`. Optionally delete `~/.c - **Ask for a review**: the agent sees the `advisor()` tool and its guidance: consult after orienting and before substantive work, when stuck or changing approach, and before declaring done (after saving the work). An automatic review counts as a consultation already made. You can also say "consult the advisor before you continue". - **Automatic reviews are on by default** (`routing.mode` `active`). Without `advisor.models`, they consult the agent's own model and are billed at its rates; set `advisor.models`, or set `routing.mode` to `observe` or `off`, if that's not what you want. - Automatic reviews run in top-level sessions only. Subagent sessions don't get the tool or automatic reviews. -- Automatic reviews are limited to two per user turn (`routing.maxConsultationsPerTurn`) and to one per 10 agent steps across the session (`routing.minStepsBetweenConsultations`). The step count carries over to the next turn, and a retried step doesn't count. Both limits are kept in memory, so restarting OpenCode resets them. Explicit `advisor()` calls ignore both limits. +- Automatic reviews are limited to two per user turn (`routing.maxConsultationsPerTurn`) and to one per 10 agent steps across the session (`routing.minStepsBetweenConsultations`). The step count carries over to the next turn, and a retried step doesn't count. The Decision model reads one step in 10 (`routing.minStepsBetweenDecisions`), also counted across turns, and isn't called on a step where a limit already blocks a consult. These limits are kept in memory, so restarting OpenCode resets them. Explicit `advisor()` calls ignore all of them. ## Configuration @@ -96,27 +96,28 @@ Global, optional, strict JSON at `~/.config/opencode/auto-advisor.json` (or `$OP | `routing.consequenceThreshold` | `1.5` | Minimum stakes score (0 trivial … 4 critical), compared unrounded. | | `routing.maxConsultationsPerTurn` | `2` | Automatic consults per user turn. | | `routing.minStepsBetweenConsultations` | `10` | Agent steps after an automatic consult starts (whatever its outcome) before another may start, counted across turns. `0` turns the cooldown off. | +| `routing.minStepsBetweenDecisions` | `10` | Agent steps after a Decision call starts (whatever its outcome) before another may start, counted across turns and in both `observe` and `active`; `1` decides on every step. It saves Decision quota (a free model's rate limit, or a paid fallback's bill), but a moment that falls on a skipped step is missed: the step where the agent finishes is often one, so a turn shorter than this often ends without a review. A new turn doesn't reset it. | | `routing.requests` | `3` | How many of your latest requests the Decision model sees, oldest first, from `1` to `20`. Subagent results and other notices are never counted as requests. | | `routing.requestMaxChars` | `20000` | Characters kept from **each** request sent to the decision model (its start), from `100`; `-1` sends them whole. It caps each request, not the total: the Decision input is up to `routing.requests` × this cap, plus about 12,000 characters of recent steps (about 72,000 with the defaults). The default `opencode/jev-1.13-free` has a 64k-token window, so raising the cap or `routing.requests` can overflow it; the step then falls through to the next model in `routing.models`, or gets no decision. Sizing to each model's window is planned. | | `tui.sidebar` | `true` | Shows the Advisor status at the bottom of the session sidebar. `false` hides it; `/advisor-review` still opens the review. | ## Privacy and cost -- **Decision provider**: in `observe` and `active`, each step's last `routing.requests` user requests (up to 20,000 characters each by default; see `routing.requestMaxChars`), recent tool calls and truncated results (no system prompt, no tool definitions) go to the configured decision model. The default, `opencode/jev-1.13-free`, is a limited-time free model on OpenCode Zen, built by TypeSafe AI. Check Zen's data-retention terms for it before using it with sensitive code. +- **Decision provider**: in `observe` and `active`, each decided step's last `routing.requests` user requests (up to 20,000 characters each by default; see `routing.requestMaxChars`), recent tool calls and truncated results (no system prompt, no tool definitions) go to the configured decision model. The default, `opencode/jev-1.13-free`, is a limited-time free model on OpenCode Zen, built by TypeSafe AI. Check Zen's data-retention terms for it before using it with sensitive code. - **Advisor**: automatic reviews are on by default and, without `advisor.models`, use the agent's own model. Each consult sends the agent's transcript (system prompt with OpenCode's catalogs left out when `advisor.compactContext` is on, up to `advisor.maxTokens`) to the Advisor model, uncached. It's billed at that model's rates. -- **Latency**: in `active`, every step waits for one Decision call (about 0.1–0.4 s on Zen after a cold start), except while a limit blocks consults: then the call runs in the background, as in `observe`. A consult pauses the agent until the Advisor answers. +- **Latency**: in `active`, a decided step waits for one Decision call (about 0.1–0.4 s on Zen after a cold start); other steps and steps a limit blocks don't wait. In `observe` the call runs in the background. A consult pauses the agent until the Advisor answers. - **Cache**: installing the plugin adds the `advisor` tool, which costs one prompt-cache miss on the tools block. After that, reviews only add to the end of a request. -- **Telemetry**: one JSON line per routing decision in `~/.local/share/opencode/auto-advisor/telemetry.jsonl` (capped at 5 MB, one rotation), with models, answers, verdicts and timings. It also keeps the step's Decision input (its last 32,000 characters: your recent requests, tool calls and truncated results) and the id of the newest message, all on your machine and readable only by you. Never the Advisor's transcript or advice text. At the 32,000-character maximum, a 5 MB file holds about 150 steps, so long sessions keep only their latest steps. +- **Telemetry**: one JSON line per decided or blocked step in `~/.local/share/opencode/auto-advisor/telemetry.jsonl` (capped at 5 MB, one rotation), with models, answers, verdicts and timings. It also keeps the step's Decision input (its last 32,000 characters: your recent requests, tool calls and truncated results) and the id of the newest message, all on your machine and readable only by you. Never the Advisor's transcript or advice text. At the 32,000-character maximum, a 5 MB file holds about 150 steps, so long sessions keep only their latest steps. ## Troubleshooting - **Judging the verdicts**: from a checkout of this repo, with OpenCode running, `bun run timeline [--out file.html]` (the ID is the `session` field of any telemetry line) in `packages/opencode-auto-advisor` writes one page per session: each routing decision with its moment probabilities, consequence, limit and Advisor outcome, and, expanded, the messages since the previous step and the exact Decision input. Like telemetry, the page is readable only by you. -- **Nothing happens automatically**: check `telemetry.jsonl` for the verdicts. `routine` and `low-consequence` mean the thresholds held the review back, `blocked` means the cooldown or the per-turn limit did (the `blocked` field says which; a step both block is logged as `cooldown`), and `no-decision` means no configured decision model answered. +- **Nothing happens automatically**: check `telemetry.jsonl` for the verdicts. `routine` and `low-consequence` mean the thresholds held the review back, `blocked` means the cooldown or the per-turn limit stopped a consult before any Decision call (the `blocked` field says which; a step both block is logged as `cooldown`), and `no-decision` means no configured decision model answered. A step skipped by `routing.minStepsBetweenDecisions` leaves no line, so most steps have none. - **"Advisor unavailable" in the footer or sidebar**: every Advisor model failed or the deadline ran out. The agent carried on without advice. `advisor.attempts` in `telemetry.jsonl` lists each model's outcome. - **No Advisor in the sidebar**: OpenCode shows the sidebar only in top-level sessions, and only on terminals wider than 120 columns unless you open it. While the review panel is open, it takes the sidebar's place. `/advisor-review` works either way. - **The review panel shows no model or duration**: they are kept in memory, so a review delivered before OpenCode restarted shows only its moment and text. - **Your config is ignored**: an invalid file switches automatic reviews off, and `advisor()` returns `invalid config: …` naming the bad value; unknown keys are rejected. With a local checkout, run `bun run build` after pulling: OpenCode loads `dist/`, and an older build rejects newer keys. -- **Observe mode logs fewer lines than steps**: while one Decision call is still running, later steps in the same session are skipped rather than queued. The same holds in `active` while a limit blocks consults. +- **Fewer lines than steps**: `routing.minStepsBetweenDecisions` skips most steps without a line. In `observe`, a step that arrives while a Decision call is still running is skipped too, rather than queued. ## Contributing diff --git a/packages/opencode-auto-advisor/src/config.test.ts b/packages/opencode-auto-advisor/src/config.test.ts index 27a5db2..5f93268 100644 --- a/packages/opencode-auto-advisor/src/config.test.ts +++ b/packages/opencode-auto-advisor/src/config.test.ts @@ -24,6 +24,7 @@ describe("parseConfig", () => { consequenceThreshold: 1.5, maxConsultationsPerTurn: 2, minStepsBetweenConsultations: 10, + minStepsBetweenDecisions: 10, requests: 3, requestMaxChars: 20_000, }, @@ -48,6 +49,13 @@ describe("parseConfig", () => { expect(config.routing.minStepsBetweenConsultations).toBe(0); }); + test("routing.minStepsBetweenDecisions accepts 1 (every step) to 1000", () => { + const every = parseConfig({ routing: { minStepsBetweenDecisions: 1 } }); + expect(every.routing.minStepsBetweenDecisions).toBe(1); + const sparse = parseConfig({ routing: { minStepsBetweenDecisions: 1000 } }); + expect(sparse.routing.minStepsBetweenDecisions).toBe(1000); + }); + test("tui.sidebar accepts a boolean", () => { expect(parseConfig({ tui: { sidebar: false } }).tui.sidebar).toBe(false); }); @@ -115,6 +123,9 @@ describe("parseConfig", () => { [{ routing: { maxConsultationsPerTurn: 1.5 } }, "routing.maxConsultationsPerTurn"], [{ routing: { minStepsBetweenConsultations: -1 } }, "routing.minStepsBetweenConsultations"], [{ routing: { minStepsBetweenConsultations: 2.5 } }, "routing.minStepsBetweenConsultations"], + [{ routing: { minStepsBetweenDecisions: 0 } }, "routing.minStepsBetweenDecisions"], + [{ routing: { minStepsBetweenDecisions: 1001 } }, "routing.minStepsBetweenDecisions"], + [{ routing: { minStepsBetweenDecisions: 2.5 } }, "routing.minStepsBetweenDecisions"], [{ routing: { requests: 0 } }, "routing.requests"], [{ routing: { requests: 21 } }, "routing.requests"], [{ routing: { requests: 1.5 } }, "routing.requests"], diff --git a/packages/opencode-auto-advisor/src/config.ts b/packages/opencode-auto-advisor/src/config.ts index 5b15f70..8cb6d8a 100644 --- a/packages/opencode-auto-advisor/src/config.ts +++ b/packages/opencode-auto-advisor/src/config.ts @@ -30,6 +30,8 @@ export interface Config { readonly maxConsultationsPerTurn: number; /** Distinct Executor steps after an automatic consult starts before another may; 0 is off. */ readonly minStepsBetweenConsultations: number; + /** Distinct Executor steps after a Decision call starts before another may; 1 is every step. */ + readonly minStepsBetweenDecisions: number; /** How many of the user's latest requests the Decision model sees. */ readonly requests: number; /** Characters per request in the Decision input, keeping the start; -1 is no cap. */ @@ -53,6 +55,7 @@ export const DEFAULTS: Config = { consequenceThreshold: 1.5, maxConsultationsPerTurn: 2, minStepsBetweenConsultations: 10, + minStepsBetweenDecisions: 10, requests: 3, requestMaxChars: 20_000, }, @@ -152,6 +155,7 @@ export function parseConfig(input: unknown): Config { "consequenceThreshold", "maxConsultationsPerTurn", "minStepsBetweenConsultations", + "minStepsBetweenDecisions", "requests", "requestMaxChars", ]); @@ -234,6 +238,16 @@ export function parseConfig(input: unknown): Config { 1_000, true, ), + minStepsBetweenDecisions: + routing.minStepsBetweenDecisions === undefined + ? DEFAULTS.routing.minStepsBetweenDecisions + : number( + routing.minStepsBetweenDecisions, + "routing.minStepsBetweenDecisions", + 1, + 1_000, + true, + ), requests: routing.requests === undefined ? DEFAULTS.routing.requests diff --git a/packages/opencode-auto-advisor/src/server.test.ts b/packages/opencode-auto-advisor/src/server.test.ts index d7e5b9b..bfad9ef 100644 --- a/packages/opencode-auto-advisor/src/server.test.ts +++ b/packages/opencode-auto-advisor/src/server.test.ts @@ -19,6 +19,7 @@ beforeEach(async () => { process.env.OPENCODE_CONFIG_DIR = join(root, "config"); process.env.XDG_DATA_HOME = join(root, "data"); await mkdir(join(root, "config"), { recursive: true }); + await writeConfig({}); }); afterEach(async () => { @@ -29,8 +30,17 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }); }); -const writeConfig = (config: unknown) => - writeFile(join(root, "config", "auto-advisor.json"), JSON.stringify(config)); +/** + * Most tests drive several steps and expect a decision on each, so the base config decides on + * every step; a test's own `routing` merges into it, and `routing: { minStepsBetweenDecisions: + * undefined }` restores the default. + */ +const BASE_ROUTING = { minStepsBetweenDecisions: 1 }; +const writeConfig = (config: Any) => + writeFile( + join(root, "config", "auto-advisor.json"), + JSON.stringify({ ...config, routing: { ...BASE_ROUTING, ...config.routing } }), + ); /** Queued as an event, it makes the fake stream's next read fail. */ const READ_FAILURE = Symbol("read failure"); @@ -349,12 +359,14 @@ describe("active (default)", () => { await host.hooks.context(request("s", [...history])); await Bun.sleep(10); expect(host.textCalls).toHaveLength(1); - const input = host.decisionCalls.at(-1).input[0].text; - expect(input).toContain( + const lines = await telemetryLines(); + // Step 2 is blocked by the budget, so its line keeps the input without a Decision call. + expect(lines[1].blocked).toBe("budget"); + expect(lines[1].input).toContain( "User requests (oldest first; the last is the latest):\nUSER: Fix the login test\n\nRecent", ); - expect(input).toContain("SUBAGENT: line.turn)).toEqual(["u1", "u1"]); + expect(lines[1].input).toContain("SUBAGENT: line.turn)).toEqual(["u1", "u1"]); }); test("requests from before a restart and from the prompt hook are both shown", async () => { @@ -626,6 +638,81 @@ describe("active (default)", () => { }); }); +describe("decision spacing", () => { + test("by default decides on the 1st and 11th distinct steps, across turns, not on retries", async () => { + await writeConfig({ routing: { minStepsBetweenDecisions: undefined } }); + const host = await start({ decision: async () => ROUTINE }); + const history: Any[] = [user("u1"), ...step(1)]; + await host.hooks.context(request("s", [...history])); + for (let n = 2; n <= 10; n++) { + if (n === 5) { + await host.hooks.prompt({ sessionID: "s", messageID: "u2", delivery: "steer" }); + history.push(user("u2", "next")); + } + history.push(...step(n)); + await host.hooks.context(request("s", [...history])); + await host.hooks.context(request("s", [...history])); + } + expect(host.decisionCalls).toHaveLength(1); + history.push(...step(11)); + await host.hooks.context(request("s", [...history])); + expect(host.decisionCalls).toHaveLength(2); + await Bun.sleep(10); + // A skipped step leaves no telemetry line. + expect((await telemetryLines()).map((line) => line.step)).toEqual([1, 7]); + }); + + test("a Decision call that fails still counts", async () => { + await writeConfig({ routing: { minStepsBetweenDecisions: 3 } }); + const host = await start({ + decision: () => Promise.reject({ _tag: "Generate.UnavailableError" }), + }); + const history: Any[] = [user("u1")]; + for (let n = 1; n <= 4; n++) { + history.push(...step(n)); + await host.hooks.context(request("s", [...history])); + } + expect(host.decisionCalls).toHaveLength(2); + await Bun.sleep(10); + expect((await telemetryLines()).map((line) => line.verdict)).toEqual([ + "no-decision", + "no-decision", + ]); + }); + + test("observe mode is spaced too", async () => { + await writeConfig({ routing: { mode: "observe", minStepsBetweenDecisions: 2 } }); + const host = await start(); + const history: Any[] = [user("u1")]; + for (let n = 1; n <= 3; n++) { + history.push(...step(n)); + await host.hooks.context(request("s", [...history])); + await Bun.sleep(10); + } + expect(host.decisionCalls).toHaveLength(2); + }); + + test("a blocked step makes no Decision call and logs why", async () => { + await writeConfig({ + routing: { maxConsultationsPerTurn: 1, minStepsBetweenConsultations: 0 }, + }); + const host = await start(); + const history: Any[] = [user("u1"), ...step(1)]; + await host.hooks.context(request("s", [...history])); + history.push(...step(2)); + await host.hooks.context(request("s", [...history])); + await Bun.sleep(10); + expect(host.decisionCalls).toHaveLength(1); + expect((await telemetryLines())[1]).toMatchObject({ + step: 2, + blocked: "budget", + verdict: "blocked", + attempts: [], + delivered: false, + }); + }); +}); + describe("observe", () => { test("records telemetry without blocking or changing the request", async () => { await writeConfig({ routing: { mode: "observe" } }); diff --git a/packages/opencode-auto-advisor/src/server.ts b/packages/opencode-auto-advisor/src/server.ts index 6de611b..e1eddec 100644 --- a/packages/opencode-auto-advisor/src/server.ts +++ b/packages/opencode-auto-advisor/src/server.ts @@ -69,6 +69,8 @@ interface SessionState { lastKey?: string; /** `steps` when the last automatic consult started, for the cooldown. */ consultedAt?: number; + /** `steps` when the last Decision call started, for `routing.minStepsBetweenDecisions`. */ + decidedAt?: number; /** Automatic consults started this turn. */ started: number; /** Identity of the last evaluated state, so a retried step reuses its outcome. */ @@ -351,6 +353,12 @@ export default Plugin.define({ } session.step++; session.steps++; + // Spacing saves Decision quota; a failed call counts, since it was still sent. + if ( + session.decidedAt !== undefined && + session.steps - session.decidedAt < routing.minStepsBetweenDecisions + ) + return; const base = { ts: new Date().toISOString(), session: event.sessionID, @@ -374,23 +382,30 @@ export default Plugin.define({ ? ("budget" as const) : undefined; - if (mode === "observe" || blocked) { - // Decide without consulting, so telemetry still shows what the Decision model saw. + if (blocked) { + // No consult can start, so the Decision call would only feed telemetry: skip it. + telemetry.record({ + ...base, + mode, + attempts: [], + verdict: "blocked", + blocked, + delivered: false, + }); + return; + } + + if (mode === "observe") { + // Decide without consulting, so telemetry shows what the Decision model saw. if (session.observing) return; session.observing = true; + session.decidedAt = session.steps; void route(routing.models, input) .then((routed) => { const verdict = routed.ok ? decide({ answers: routed.value.answers, routing }) : undefined; - telemetry.record({ - ...base, - mode, - ...summary(routed, verdict), - ...(blocked ? { blocked } : {}), - ...(blocked && verdict?.consult ? { verdict: "blocked" } : {}), - delivered: false, - }); + telemetry.record({ ...base, mode, ...summary(routed, verdict), delivered: false }); }) .catch(() => {}) .finally(() => { @@ -407,6 +422,7 @@ export default Plugin.define({ let delivered: Review | undefined; let record: TelemetryRecord | undefined; try { + session.decidedAt = session.steps; const routed = await route(routing.models, input); const verdict = routed.ok ? decide({ answers: routed.value.answers, routing }) diff --git a/packages/opencode-auto-advisor/src/telemetry.ts b/packages/opencode-auto-advisor/src/telemetry.ts index 5d4ad69..2247d9c 100644 --- a/packages/opencode-auto-advisor/src/telemetry.ts +++ b/packages/opencode-auto-advisor/src/telemetry.ts @@ -8,7 +8,7 @@ export const MAX_BYTES = 5 * 1024 * 1024; export const LOGGED_INPUT_CHARS = 32_000; /** - * One line per routing opportunity. Keeps the Decision input (its end) so a verdict can be + * One line per decided or blocked step. Keeps the Decision input (its end) so a verdict can be * judged later; never the Advisor transcript or advice text. */ export interface TelemetryRecord { @@ -28,7 +28,7 @@ export interface TelemetryRecord { readonly moment?: Record; readonly consequence?: number; readonly verdict: string; - /** Set when the per-turn budget or the cooldown kept an automatic consult from starting. */ + /** Set when the per-turn budget or the cooldown blocked the step; no Decision call was made. */ readonly blocked?: "budget" | "cooldown"; readonly advisor?: { readonly model: string; From e9b379be536df918d1877457ef5490beedb28393 Mon Sep 17 00:00:00 2001 From: Arjun Pramanik Date: Mon, 2 Nov 2026 03:22:12 -0500 Subject: [PATCH 5/7] feat(auto-advisor): show which model answered advisor() calls; accept JSONC config Explicit advisor() calls now write a kind: "explicit" telemetry line with every Advisor model tried, and the review panel names the model that answered the last one. auto-advisor.json accepts comments and trailing commas (Bun.JSONC), and the README shows the full default configuration, checked against DEFAULTS by a test. --- .../auto-advisor-explicit-visibility.md | 5 + packages/opencode-auto-advisor/AGENTS.md | 6 +- packages/opencode-auto-advisor/README.md | 75 +++++++++++++-- .../opencode-auto-advisor/src/config.test.ts | 53 ++++++++++- packages/opencode-auto-advisor/src/config.ts | 3 +- packages/opencode-auto-advisor/src/rpc.ts | 36 +++++--- .../opencode-auto-advisor/src/server.test.ts | 45 +++++++++ packages/opencode-auto-advisor/src/server.ts | 91 +++++++++++++------ .../opencode-auto-advisor/src/telemetry.ts | 30 ++++-- .../opencode-auto-advisor/src/tui.test.ts | 33 ++++++- packages/opencode-auto-advisor/src/tui.ts | 23 ++++- 11 files changed, 334 insertions(+), 66 deletions(-) create mode 100644 .changeset/auto-advisor-explicit-visibility.md diff --git a/.changeset/auto-advisor-explicit-visibility.md b/.changeset/auto-advisor-explicit-visibility.md new file mode 100644 index 0000000..36e0d77 --- /dev/null +++ b/.changeset/auto-advisor-explicit-visibility.md @@ -0,0 +1,5 @@ +--- +"@capybearista/opencode-auto-advisor": minor +--- + +Show which model answered the agent's own `advisor()` calls. The review panel gains a "Last advisor() call" line with the model, duration and time, and each explicit call now writes a `"kind": "explicit"` telemetry line with every Advisor model tried. `auto-advisor.json` now accepts comments and trailing commas (JSONC), and the README shows the full default configuration. diff --git a/packages/opencode-auto-advisor/AGENTS.md b/packages/opencode-auto-advisor/AGENTS.md index 83747bf..d1ba4ef 100644 --- a/packages/opencode-auto-advisor/AGENTS.md +++ b/packages/opencode-auto-advisor/AGENTS.md @@ -23,8 +23,8 @@ - `src/fallback.ts`: the model-chain walker shared by the Decision router and the Advisor chain (error policy, skip map, shared deadline). `src/decisions.ts`: the two Decision questions and the router over `routing.models`. `src/policy.ts`: the pure consult verdict. - `src/transcript.ts`: the Decision projection (the last `routing.requests` real prompts, each capped at `routing.requestMaxChars`, 20k by default (per request, not a total), the turn's recent steps without system updates, with subagent results and other user-role notices labelled `SUBAGENT`/`NOTICE`, and failure counts; no system prompt and no total cap) and the Advisor transcript (system text, tool names, messages with the same `SUBAGENT`/`NOTICE` labels; compacted, then budgeted, keeping the first real request to `min(advisor.maxTokens, 75% of the window)` at 3 chars per token). - `src/advisor.ts`: prompts and the timeout-raced consult. `src/delivery.ts`: review text, marker, tail push. -- `src/config.ts`: strict sparse `auto-advisor.json`, re-read when its mtime changes. `src/telemetry.ts`: JSONL with one rotation; each line carries the newest message id and the Decision input's last 32k chars. `scripts/timeline.ts` (`bun run timeline `, not shipped) joins those lines with the session's messages from `opencode api get /api/session//message` into an HTML page. `scripts/sandbox.ts` (`bun run sandbox`, not shipped) runs the isolated OpenCode used for live checks, in the foreground until Ctrl-C. -- `src/tui.ts`: the footer status, a `sidebar.footer` widget (hidden by `tui.sidebar`), and a `session.panel` named `auto-advisor` with the full review, opened by a click or `/advisor-review`. State comes from RPC events, plus one `status` call per mounted widget or panel, so a stored review shows after a restart. +- `src/config.ts`: strict sparse `auto-advisor.json` (JSONC syntax via `Bun.JSONC.parse`, unknown keys rejected), re-read when its mtime changes. `config.test.ts` checks that the README's "Default configuration" block parses to `DEFAULTS`. `src/telemetry.ts`: JSONL with one rotation; each step line carries the newest message id and the Decision input's last 32k chars, and each explicit `advisor()` call writes a `kind: "explicit"` line with only the Advisor's attempts and timing. `scripts/timeline.ts` (`bun run timeline `, not shipped) joins those lines with the session's messages from `opencode api get /api/session//message` into an HTML page. `scripts/sandbox.ts` (`bun run sandbox`, not shipped) runs the isolated OpenCode used for live checks, in the foreground until Ctrl-C. +- `src/tui.ts`: the footer status, a `sidebar.footer` widget (hidden by `tui.sidebar`), and a `session.panel` named `auto-advisor` with the full review, opened by a click or `/advisor-review`; it also names the model that answered the last explicit `advisor()` call (server memory, sent as `explicit` with an `explicit.finished` event). State comes from RPC events, plus one `status` call per mounted widget or panel, so a stored review shows after a restart. - `src/rpc.ts` is the contract both halves import. ### Gotchas @@ -46,7 +46,7 @@ - **A slot's render body runs once, untracked** (`tui/src/plugin/render.tsx` at v2.2.0); its input stays reactive through getters. Read `input.sessionID` and `input.name` inside reactive functions, and choose the panel's content from `input.name` reactively. Opening a panel hides the sidebar, footer widget included. - **Never `insert` children into `markdown`**, not even `[]`: it removes the blocks rendered from `content`, and the panel stays blank in the real TUI until `content` changes (ram-monitor's `/ram` modal hides it: its content changes once, when the async load finishes after the insert). `testRender` parses later, so the unit tests pass anyway; only a live check catches it. - **Read `opencode api` output from a file, never a pipe.** The CLI calls `process.exit` right after writing (`packages/cli/src/index.ts`, 2.2.5), so a piped response of a few MB arrives cut short at a random length; 350 KB still arrives whole, so small test sessions hide it. `fetchMessages` in `scripts/timeline.ts` writes each page to a file. -- **`bun run build` starts with `rm -rf dist`.** A running OpenCode watches the local plugin, reloads mid-build, and drops the plugin (server and TUI) if `dist/` is missing or the build fails. Restart OpenCode after rebuilding. +- **`bun run build` starts with `rm -rf dist`.** A running OpenCode watches the local plugin, reloads mid-build, and drops the plugin (server and TUI) if `dist/` is missing or the build fails. Restart OpenCode after rebuilding. The root `bun run test` builds too (`turbo.json`: `test` depends on `build`), so it has the same effect; to check without touching `dist/`, run `bun test` in this package. - **The TUI can't reach the host's markdown syntax style** or the `PanelInput` type. `markdownStyle` mirrors the markdown subset of `theme/src/tui/syntax.ts` from the public `context.theme.markdown` tokens, as ram-monitor does. - **One `opencode.json` entry loads both halves**; `cli.json` needs none. The TUI gets `context.options = {}`, so configuration stays server-side. - **Neither hook may throw into the user's request.** Failures are swallowed. diff --git a/packages/opencode-auto-advisor/README.md b/packages/opencode-auto-advisor/README.md index 382b0d0..b6b313a 100644 --- a/packages/opencode-auto-advisor/README.md +++ b/packages/opencode-auto-advisor/README.md @@ -38,7 +38,7 @@ Two paths reach the same stateless Advisor. The agent can call the `advisor()` t - **Advice on the same step**: the review reaches the waiting request immediately, and stays in the session as an instruction entry until a newer review replaces it. - **Cache-safe**: no system-prompt edits. Only the newest message of a request changes. - **Observe mode**: run the Decision model without consulting, and log what it would have done, to tune the thresholds. -- **TUI**: "Advisor reviewing…" in the prompt footer, and a one-line Advisor status at the bottom of the session sidebar. Click it, or run `/advisor-review` (also in the command palette), to read the latest review in a scrollable panel: rendered markdown, its moment, model, duration and time. +- **TUI**: "Advisor reviewing…" in the prompt footer, and a one-line Advisor status at the bottom of the session sidebar. Click it, or run `/advisor-review` (also in the command palette), to read the latest review in a scrollable panel: rendered markdown, its moment, model, duration and time. The panel also names the model that answered the agent's last `advisor()` call. ## Install @@ -72,18 +72,78 @@ Remove the entry from `"plugins"` in `opencode.json(c)`. Optionally delete `~/.c - **Automatic reviews are on by default** (`routing.mode` `active`). Without `advisor.models`, they consult the agent's own model and are billed at its rates; set `advisor.models`, or set `routing.mode` to `observe` or `off`, if that's not what you want. - Automatic reviews run in top-level sessions only. Subagent sessions don't get the tool or automatic reviews. - Automatic reviews are limited to two per user turn (`routing.maxConsultationsPerTurn`) and to one per 10 agent steps across the session (`routing.minStepsBetweenConsultations`). The step count carries over to the next turn, and a retried step doesn't count. The Decision model reads one step in 10 (`routing.minStepsBetweenDecisions`), also counted across turns, and isn't called on a step where a limit already blocks a consult. These limits are kept in memory, so restarting OpenCode resets them. Explicit `advisor()` calls ignore all of them. +- The two step limits overlap: a consult can only start on a step the Decision model read, so the consult cooldown only has an effect when it's larger than `routing.minStepsBetweenDecisions`. With the defaults (10 and 10), the Decision spacing sets the pace. The cooldown is for a setup like `"minStepsBetweenDecisions": 1` (read every step) with `"minStepsBetweenConsultations": 10` (consult at most once in 10). ## Configuration -Global, optional, strict JSON at `~/.config/opencode/auto-advisor.json` (or `$OPENCODE_CONFIG_DIR/auto-advisor.json`, or `$XDG_CONFIG_HOME/opencode/auto-advisor.json`). A missing file means all defaults, an omitted key means its default, and an invalid file turns automatic reviews off and makes the `advisor()` tool return the error. Changes apply on the next step without a restart. +One global, optional file, `auto-advisor.json`. The plugin reads the first of these that applies; there is no project-level file: -```json +1. `$OPENCODE_CONFIG_DIR/auto-advisor.json`, if `OPENCODE_CONFIG_DIR` is set +2. `$XDG_CONFIG_HOME/opencode/auto-advisor.json`, if `XDG_CONFIG_HOME` is set +3. `~/.config/opencode/auto-advisor.json` + +The file is JSONC: comments and trailing commas are allowed. A missing file means all defaults, an omitted key means its default, and an invalid file (a syntax error, an unknown key or a bad value) turns automatic reviews off and makes the `advisor()` tool return the error. Changes apply on the next step without a restart. + +A typical setup names the Advisor and leaves everything else at its default: + +```jsonc { - "advisor": { "models": "openai/gpt-6" }, - "routing": { "mode": "active" } + "advisor": { "models": "openai/gpt-6#high" } } ``` +> [!IMPORTANT] +> Without `advisor.models`, automatic reviews consult the agent's own model, billed at its rates. + +
+Default configuration (click to expand) + +Every key with its default value. Copy only the keys you want to change. + +```jsonc +{ + "advisor": { + // The Advisor: "inherit" (the agent's own model), "provider/model[#variant]", + // or an ordered fallback list of 1–8 such entries. + "models": ["inherit"], + // One deadline in ms for the whole chain; -1 waits for the provider's own timeouts. + "timeoutMs": -1, + // Leave OpenCode's tool, MCP and skill catalogs out of the Advisor's copy of the context. + "compactContext": true, + // Approximate transcript ceiling per consult, in tokens (at least 1000). + "maxTokens": 64000, + }, + "routing": { + // "off", "observe" (Decision model only, logged) or "active" (consults and delivers). + "mode": "active", + // Decision model, or an ordered fallback list of 1–8. + "models": ["opencode/jev-1.13-free"], + // Minimum probability that the step is not routine. + "momentThreshold": 0.7, + // Minimum stakes score, 0 (trivial) to 4 (critical). + "consequenceThreshold": 1.5, + // Automatic consults per user turn. + "maxConsultationsPerTurn": 2, + // Steps after an automatic consult before another may start; 0 turns it off. + "minStepsBetweenConsultations": 10, + // Steps after a Decision call before another; 1 decides on every step. + "minStepsBetweenDecisions": 10, + // How many of your latest requests the Decision model sees (1–20). + "requests": 3, + // Characters kept from each request (at least 100); -1 sends them whole. + "requestMaxChars": 20000, + }, + "tui": { + // Show the Advisor status at the bottom of the session sidebar. + "sidebar": true, + }, +} +``` + +
+ +The table has the details for each key. + | Key | Default | Description | | :--- | :--- | :--- | | `advisor.models` | `"inherit"` | `"provider/model"` of the Advisor, optionally with `#variant` for its reasoning level (`"openai/gpt-6#high"`). Without a variant, the provider's default applies. `"inherit"` uses the agent's own model and variant. A list of 1–8 entries is a fallback chain, tried in order: an unreachable model, an exhausted quota, an overload, a missing model or a request the model rejects (such as one over its context window) moves to the next at once. A content filter or an invalid `#variant` stops the chain. A rejection the provider reports without an HTTP status (mid-stream, for example) moves on too. A model that was rate-limited or overloaded, or that ran out the plugin's own deadline, is skipped for 60 s. Each model gets a transcript sized to its own window. Worst cases: a refused connection costs about 12 s before the next model (OpenCode retries it), and a provider that accepts the connection and then goes silent costs up to OpenCode's `headerTimeout` (5 min) or `chunkTimeout` (15 min) on every consult; lower those in that provider's settings, or set `advisor.timeoutMs`. | @@ -107,7 +167,7 @@ Global, optional, strict JSON at `~/.config/opencode/auto-advisor.json` (or `$OP - **Advisor**: automatic reviews are on by default and, without `advisor.models`, use the agent's own model. Each consult sends the agent's transcript (system prompt with OpenCode's catalogs left out when `advisor.compactContext` is on, up to `advisor.maxTokens`) to the Advisor model, uncached. It's billed at that model's rates. - **Latency**: in `active`, a decided step waits for one Decision call (about 0.1–0.4 s on Zen after a cold start); other steps and steps a limit blocks don't wait. In `observe` the call runs in the background. A consult pauses the agent until the Advisor answers. - **Cache**: installing the plugin adds the `advisor` tool, which costs one prompt-cache miss on the tools block. After that, reviews only add to the end of a request. -- **Telemetry**: one JSON line per decided or blocked step in `~/.local/share/opencode/auto-advisor/telemetry.jsonl` (capped at 5 MB, one rotation), with models, answers, verdicts and timings. It also keeps the step's Decision input (its last 32,000 characters: your recent requests, tool calls and truncated results) and the id of the newest message, all on your machine and readable only by you. Never the Advisor's transcript or advice text. At the 32,000-character maximum, a 5 MB file holds about 150 steps, so long sessions keep only their latest steps. +- **Telemetry**: one JSON line per decided or blocked step, and one per explicit `advisor()` call (`"kind": "explicit"`, with the Advisor's attempts and timing only), in `~/.local/share/opencode/auto-advisor/telemetry.jsonl` (capped at 5 MB, one rotation), with models, answers, verdicts and timings. It also keeps the step's Decision input (its last 32,000 characters: your recent requests, tool calls and truncated results) and the id of the newest message, all on your machine and readable only by you. Never the Advisor's transcript or advice text. At the 32,000-character maximum, a 5 MB file holds about 150 steps, so long sessions keep only their latest steps. ## Troubleshooting @@ -115,7 +175,8 @@ Global, optional, strict JSON at `~/.config/opencode/auto-advisor.json` (or `$OP - **Nothing happens automatically**: check `telemetry.jsonl` for the verdicts. `routine` and `low-consequence` mean the thresholds held the review back, `blocked` means the cooldown or the per-turn limit stopped a consult before any Decision call (the `blocked` field says which; a step both block is logged as `cooldown`), and `no-decision` means no configured decision model answered. A step skipped by `routing.minStepsBetweenDecisions` leaves no line, so most steps have none. - **"Advisor unavailable" in the footer or sidebar**: every Advisor model failed or the deadline ran out. The agent carried on without advice. `advisor.attempts` in `telemetry.jsonl` lists each model's outcome. - **No Advisor in the sidebar**: OpenCode shows the sidebar only in top-level sessions, and only on terminals wider than 120 columns unless you open it. While the review panel is open, it takes the sidebar's place. `/advisor-review` works either way. -- **The review panel shows no model or duration**: they are kept in memory, so a review delivered before OpenCode restarted shows only its moment and text. +- **The review panel shows no model or duration**: they are kept in memory, so a review delivered before OpenCode restarted shows only its moment and text. The same goes for the "Last advisor() call" line. +- **Which model answered an `advisor()` call**: the panel's "Last advisor() call" line names the latest one a model answered. For every call, including failed ones, read the `"kind": "explicit"` lines in `telemetry.jsonl`: `advisor.model` and `advisor.attempts` list each model tried. - **Your config is ignored**: an invalid file switches automatic reviews off, and `advisor()` returns `invalid config: …` naming the bad value; unknown keys are rejected. With a local checkout, run `bun run build` after pulling: OpenCode loads `dist/`, and an older build rejects newer keys. - **Fewer lines than steps**: `routing.minStepsBetweenDecisions` skips most steps without a line. In `observe`, a step that arrives while a Decision call is still running is skipped too, rather than queued. diff --git a/packages/opencode-auto-advisor/src/config.test.ts b/packages/opencode-auto-advisor/src/config.test.ts index 5f93268..a206328 100644 --- a/packages/opencode-auto-advisor/src/config.test.ts +++ b/packages/opencode-auto-advisor/src/config.test.ts @@ -1,5 +1,14 @@ import { describe, expect, test } from "bun:test"; -import { DEFAULTS, parseConfig, parseModelRef, resolveConfigPath } from "./config.js"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + createConfigLoader, + DEFAULTS, + parseConfig, + parseModelRef, + resolveConfigPath, +} from "./config.js"; describe("parseConfig", () => { test("empty object yields every default", () => { @@ -183,3 +192,45 @@ describe("resolveConfigPath", () => { expect(resolveConfigPath({}, "/h")).toBe("/h/.config/opencode/auto-advisor.json"); }); }); + +describe("createConfigLoader", () => { + const load = async (text: string) => { + const dir = await mkdtemp(join(tmpdir(), "auto-advisor-config-")); + try { + const path = join(dir, "auto-advisor.json"); + await writeFile(path, text); + return await createConfigLoader(path)(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }; + + test("accepts comments and trailing commas", async () => { + const loaded = await load(`{ + // The Advisor + "advisor": { "models": ["openai/gpt-6#high", "inherit",] }, + /* Decisions */ + "routing": { "mode": "observe", }, + }`); + expect(loaded.error).toBeUndefined(); + expect(loaded.config.advisor.models).toEqual([ + { providerID: "openai", id: "gpt-6", variant: "high" }, + "inherit", + ]); + expect(loaded.config.routing.mode).toBe("observe"); + }); + + test("a syntax error turns routing off and names the file", async () => { + const loaded = await load(`{ "routing": }`); + expect(loaded.config.routing.mode).toBe("off"); + expect(loaded.error).toContain("auto-advisor.json: "); + }); +}); + +test("the README's default configuration is the real defaults", async () => { + const readme = await readFile(join(import.meta.dir, "..", "README.md"), "utf8"); + const block = readme.split("Default configuration")[1]?.split("```jsonc\n")[1]; + expect(block).toBeDefined(); + const parsed = parseConfig(Bun.JSONC.parse((block ?? "").split("\n```")[0])); + expect(parsed).toEqual(DEFAULTS); +}); diff --git a/packages/opencode-auto-advisor/src/config.ts b/packages/opencode-auto-advisor/src/config.ts index 8cb6d8a..1f6ef28 100644 --- a/packages/opencode-auto-advisor/src/config.ts +++ b/packages/opencode-auto-advisor/src/config.ts @@ -297,7 +297,8 @@ export function createConfigLoader(path: string = resolveConfigPath()) { mtime = (await file.stat()).mtimeMs; if (cached?.mtime === mtime) return cached.loaded; try { - loaded = { config: parseConfig(JSON.parse(await file.readFile("utf8"))) }; + // JSONC: comments and trailing commas, so the README's commented defaults paste as is. + loaded = { config: parseConfig(Bun.JSONC.parse(await file.readFile("utf8"))) }; } catch (cause) { const message = cause instanceof Error ? cause.message : String(cause); loaded = { diff --git a/packages/opencode-auto-advisor/src/rpc.ts b/packages/opencode-auto-advisor/src/rpc.ts index 45bfa32..ba8b9e0 100644 --- a/packages/opencode-auto-advisor/src/rpc.ts +++ b/packages/opencode-auto-advisor/src/rpc.ts @@ -26,9 +26,21 @@ export interface Status { readonly failed: boolean; /** Latest automatic review, read from the session's instruction entry. */ readonly latest: ReviewSummary | null; + /** The last explicit `advisor()` call that a model answered; server memory only. */ + readonly explicit: ReviewDetails | null; } const string = { type: "string" } as const; +const details = { + anyOf: [ + { type: "null" }, + { + type: "object", + properties: { at: string, model: string, ms: { type: "number" } }, + required: ["at", "model", "ms"], + }, + ], +} as const; const status = { type: "object", properties: { @@ -47,23 +59,24 @@ const status = { id: string, moment: string, advice: string, - details: { - anyOf: [ - { type: "null" }, - { - type: "object", - properties: { at: string, model: string, ms: { type: "number" } }, - required: ["at", "model", "ms"], - }, - ], - }, + details, }, required: ["id", "moment", "advice", "details"], }, ], }, + explicit: details, }, - required: ["sessionID", "mode", "sidebar", "configError", "reviewing", "failed", "latest"], + required: [ + "sessionID", + "mode", + "sidebar", + "configError", + "reviewing", + "failed", + "latest", + "explicit", + ], } as const; export const AutoAdvisorRpc = Rpc.define({ @@ -82,5 +95,6 @@ export const AutoAdvisorRpc = Rpc.define({ events: { "review.started": { schema: status }, "review.finished": { schema: status }, + "explicit.finished": { schema: status }, }, }); diff --git a/packages/opencode-auto-advisor/src/server.test.ts b/packages/opencode-auto-advisor/src/server.test.ts index bfad9ef..92a2f65 100644 --- a/packages/opencode-auto-advisor/src/server.test.ts +++ b/packages/opencode-auto-advisor/src/server.test.ts @@ -907,6 +907,51 @@ describe("advisor chain", () => { ); }); + test("an explicit consult logs its chain and reports the answering model", async () => { + await writeConfig({ + advisor: { models: ["mock/dead", "mock/small"] }, + routing: { mode: "off" }, + }); + const host = await start({ + advice: async (input) => + input.model.id === "dead" + ? Promise.reject(providerError("rate_limit")) + : { text: "Check the token clock skew first." }, + }); + await host.hooks.context(request("s", [user("u1")])); + await host + .tool() + .execute({}, { sessionID: "s", messageID: "m", signal: new AbortController().signal }); + await Bun.sleep(20); + const [line] = await telemetryLines(); + expect(line).toMatchObject({ session: "s", message: "m", kind: "explicit" }); + expect(line.advisor).toMatchObject({ model: "mock/small", outcome: "ok", chars: 33 }); + expect(line.advisor.attempts.map((a: Any) => [a.model, a.outcome])).toEqual([ + ["mock/dead", "next"], + ["mock/small", "ok"], + ]); + expect(host.emitted).toEqual(["explicit.finished"]); + const status = await host.rpc().status({ sessionID: "s" }); + expect(status.explicit).toMatchObject({ model: "mock/small" }); + expect(status.latest).toBeNull(); + }); + + test("a failed explicit consult is logged and leaves the last answering model", async () => { + await writeConfig({ advisor: { models: "mock/dead" }, routing: { mode: "off" } }); + const host = await start({ advice: async () => Promise.reject(providerError("rate_limit")) }); + await host.hooks.context(request("s", [user("u1")])); + await host + .tool() + .execute({}, { sessionID: "s", messageID: "m", signal: new AbortController().signal }); + await Bun.sleep(20); + const [line] = await telemetryLines(); + expect(line).toMatchObject({ + kind: "explicit", + advisor: { model: "mock/dead", outcome: "error" }, + }); + expect((await host.rpc().status({ sessionID: "s" })).explicit).toBeNull(); + }); + test("the transcript is rebuilt for each model's window", async () => { await writeConfig({ advisor: { models: ["inherit", "mock/small"] } }); const host = await start({ diff --git a/packages/opencode-auto-advisor/src/server.ts b/packages/opencode-auto-advisor/src/server.ts index e1eddec..60fc10b 100644 --- a/packages/opencode-auto-advisor/src/server.ts +++ b/packages/opencode-auto-advisor/src/server.ts @@ -20,7 +20,12 @@ import { import { createWalker } from "./fallback.js"; import { decide } from "./policy.js"; import { AutoAdvisorRpc, type ReviewDetails, type Status } from "./rpc.js"; -import { createTelemetry, LOGGED_INPUT_CHARS, type TelemetryRecord } from "./telemetry.js"; +import { + type AdvisorRecord, + createTelemetry, + LOGGED_INPUT_CHARS, + type TelemetryRecord, +} from "./telemetry.js"; import { advisorTranscript, decisionInput, @@ -79,6 +84,8 @@ interface SessionState { abort?: AbortController; /** The latest delivered review's details; memory only, unlike the entry that holds its text. */ latest?: { id: string; details: ReviewDetails }; + /** The last explicit `advisor()` call a model answered; memory only. */ + explicit?: ReviewDetails; observing: boolean; reviewing: boolean; failed: boolean; @@ -89,6 +96,21 @@ const asText = (part: unknown) => ? { text: (part as { text: string }).text } : undefined; +/** The Advisor part of a telemetry line for a consult in which no model answered. */ +function failedAdvisor(cause: unknown, ms: number, stale = false): AdvisorRecord { + const attempts = cause instanceof AdvisorChainError ? cause.attempts : []; + return { + model: attempts.at(-1)?.model ?? "unknown", + attempts, + ms, + outcome: stale + ? "stale" + : attempts.some((attempt) => attempt.outcome === "timeout") + ? "timeout" + : "error", + }; +} + /** Identity of a request's history, ignoring system updates (entries admitted on a retry). */ function stateKey(messages: readonly Message[]): string { const history = messages.filter((message) => message.role !== "system"); @@ -197,6 +219,7 @@ export default Plugin.define({ reviewing: session?.reviewing ?? false, failed: session?.failed ?? false, latest: await latestReview(sessionID), + explicit: session?.explicit ?? null, }; }; @@ -205,14 +228,13 @@ export default Plugin.define({ }); // Each event awaits a status lookup; chaining keeps `finished` from overtaking `started`. let emitting: Promise = Promise.resolve(); - const emit = (name: "review.started" | "review.finished", sessionID: string) => { + const emit = ( + name: "review.started" | "review.finished" | "explicit.finished", + sessionID: string, + ) => { emitting = emitting .then(() => status(sessionID)) - .then((snapshot) => - name === "review.started" - ? rpc.events.emit("review.started", { ...snapshot }) - : rpc.events.emit("review.finished", { ...snapshot }), - ) + .then((snapshot) => rpc.events.emit(name, { ...snapshot })) .catch(() => {}); }; @@ -277,13 +299,38 @@ export default Plugin.define({ const { config, error } = await loadConfig(); // An invalid file falls back to defaults; consulting the Executor's model would hide that. if (error) throw new Error(`invalid config: ${error}`); - const { advice } = await review( - { ...snapshot, messages: [...snapshot.messages, ...inflight] }, - config.advisor, - undefined, - tool.signal, - ); - return { content: advice }; + const start = Date.now(); + const logged = (advisor: AdvisorRecord) => { + telemetry.record({ + ts: new Date(start).toISOString(), + session: tool.sessionID, + message: tool.messageID, + kind: "explicit", + advisor, + }); + emit("explicit.finished", tool.sessionID); + }; + try { + const consulted = await review( + { ...snapshot, messages: [...snapshot.messages, ...inflight] }, + config.advisor, + undefined, + tool.signal, + ); + const ms = Date.now() - start; + session.explicit = { at: new Date().toISOString(), model: consulted.model, ms }; + logged({ + model: consulted.model, + attempts: consulted.attempts, + ms, + outcome: "ok", + chars: consulted.advice.length, + }); + return { content: consulted.advice }; + } catch (cause) { + logged(failedAdvisor(cause, Date.now() - start)); + throw cause; + } } catch (cause) { const message = cause instanceof Error ? cause.message : String(cause); return { @@ -485,21 +532,7 @@ export default Plugin.define({ } catch (cause) { const stale = session.epoch !== epoch; if (!stale) session.failed = true; - const attempts = cause instanceof AdvisorChainError ? cause.attempts : []; - const last = attempts.at(-1); - record = { - ...record, - advisor: { - model: last?.model ?? "unknown", - attempts, - ms: Date.now() - start, - outcome: stale - ? "stale" - : attempts.some((attempt) => attempt.outcome === "timeout") - ? "timeout" - : "error", - }, - }; + record = { ...record, advisor: failedAdvisor(cause, Date.now() - start, stale) }; } finally { session.reviewing = false; emit("review.finished", event.sessionID); diff --git a/packages/opencode-auto-advisor/src/telemetry.ts b/packages/opencode-auto-advisor/src/telemetry.ts index 2247d9c..62c2974 100644 --- a/packages/opencode-auto-advisor/src/telemetry.ts +++ b/packages/opencode-auto-advisor/src/telemetry.ts @@ -30,17 +30,29 @@ export interface TelemetryRecord { readonly verdict: string; /** Set when the per-turn budget or the cooldown blocked the step; no Decision call was made. */ readonly blocked?: "budget" | "cooldown"; - readonly advisor?: { - readonly model: string; - readonly ms: number; - readonly outcome: "ok" | "error" | "timeout" | "stale"; - readonly chars?: number; - /** One per Advisor model tried, in chain order. */ - readonly attempts?: readonly Attempt[]; - }; + readonly advisor?: AdvisorRecord; readonly delivered: boolean; } +export interface AdvisorRecord { + readonly model: string; + readonly ms: number; + readonly outcome: "ok" | "error" | "timeout" | "stale"; + readonly chars?: number; + /** One per Advisor model tried, in chain order. */ + readonly attempts?: readonly Attempt[]; +} + +/** One line per explicit `advisor()` call: no step, no Decision call, no budget. */ +export interface ExplicitRecord { + readonly ts: string; + readonly session: string; + /** Id of the assistant message that made the call. */ + readonly message: string; + readonly kind: "explicit"; + readonly advisor: AdvisorRecord; +} + export function resolveTelemetryPath( env: Record = process.env, home: string = homedir(), @@ -72,7 +84,7 @@ export function createTelemetry(path: string = resolveTelemetryPath(), maxBytes } }; return { - record(entry: TelemetryRecord): void { + record(entry: TelemetryRecord | ExplicitRecord): void { const line = `${JSON.stringify(entry)}\n`; queue = queue.then(() => write(line)).catch(() => {}); }, diff --git a/packages/opencode-auto-advisor/src/tui.test.ts b/packages/opencode-auto-advisor/src/tui.test.ts index 484525b..c56c9db 100644 --- a/packages/opencode-auto-advisor/src/tui.test.ts +++ b/packages/opencode-auto-advisor/src/tui.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { type JSX, testRender } from "@opentui/solid"; import { createSignal } from "solid-js"; import type { Status } from "./rpc.js"; -import tui, { footerText, PANEL, panelSummary, sidebarStatus } from "./tui.js"; +import tui, { explicitSummary, footerText, PANEL, panelSummary, sidebarStatus } from "./tui.js"; // biome-ignore lint/suspicious/noExplicitAny: loosely typed fake TUI context type Any = any; @@ -23,6 +23,7 @@ const status = (overrides: Partial = {}): Status => ({ reviewing: false, failed: false, latest, + explicit: null, ...overrides, }); @@ -52,6 +53,13 @@ describe("text", () => { ); expect(panelSummary(null)).toBe("No automatic review in this session yet."); }); + + test("explicit summary names the model that answered the last advisor() call", () => { + expect(explicitSummary({ at, model: "opencode/deepseek#max", ms: 12_340 })).toBe( + "Last advisor() call: opencode/deepseek#max · 12.3 s · 13:08", + ); + expect(explicitSummary(null)).toBe(""); + }); }); function harness(answer: Status = status()) { @@ -151,10 +159,14 @@ describe("setup", () => { "session.panel", "sidebar.footer", ]); - expect(Object.keys(host.handlers).sort()).toEqual(["review.finished", "review.started"]); + expect(Object.keys(host.handlers).sort()).toEqual([ + "explicit.finished", + "review.finished", + "review.started", + ]); host.cleanup(); expect(Object.keys(host.slots)).toEqual([]); - expect(host.unsubscribed()).toBe(2); + expect(host.unsubscribed()).toBe(3); }); test("the palette command toggles the review panel", () => { @@ -239,6 +251,21 @@ describe("review panel", () => { setup.renderer.destroy(); }); + test("shows the last advisor() call's model, even with no automatic review", async () => { + const host = harness( + status({ latest: null, explicit: { at, model: "opencode/deepseek#max", ms: 12_340 } }), + ); + const { setup, frame } = await host.mount( + () => host.slots["session.panel"].render(panel(PANEL)), + 80, + 12, + ); + const text = await frame(); + expect(text).toContain("No automatic review in this session yet."); + expect(text).toContain("Last advisor() call: opencode/deepseek#max · 12.3 s · 13:08"); + setup.renderer.destroy(); + }); + test("renders nothing for another plugin's panel", async () => { const host = harness(); const { setup, frame } = await host.mount(() => diff --git a/packages/opencode-auto-advisor/src/tui.ts b/packages/opencode-auto-advisor/src/tui.ts index dd558fc..b2653df 100644 --- a/packages/opencode-auto-advisor/src/tui.ts +++ b/packages/opencode-auto-advisor/src/tui.ts @@ -12,6 +12,7 @@ export const PANEL = "auto-advisor"; type View = "reviewing" | "finished" | "failed" | "idle"; type Latest = Status["latest"]; +type Explicit = Status["explicit"]; type Context = Plugin.Context; /** The fields used from the host's `session.panel` input (not exported by `@opencode/plugin`). */ interface PanelInput { @@ -31,7 +32,7 @@ interface Client { status(input: { sessionID: string }, options?: unknown): Promise; readonly events: { on( - name: "review.started" | "review.finished", + name: "review.started" | "review.finished" | "explicit.finished", handler: (envelope: Envelope) => void, ): () => void; }; @@ -58,15 +59,23 @@ export function sidebarStatus(view: View, latest: Latest, mode: Status["mode"]): return mode === "active" ? "no review yet" : mode; } +const detailsText = (details: NonNullable) => + `${details.model} · ${(details.ms / 1000).toFixed(1)} s · ${clock(details.at)}`; + /** The panel's one-line summary above the advice. */ export function panelSummary(latest: Latest): string { if (!latest) return "No automatic review in this session yet."; const details = latest.details - ? `${latest.details.model} · ${(latest.details.ms / 1000).toFixed(1)} s · ${clock(latest.details.at)}` + ? detailsText(latest.details) : "delivered before OpenCode restarted"; return `Moment: ${latest.moment} · ${details}`; } +/** The panel's line for the last explicit `advisor()` call, or "" when none was answered. */ +export function explicitSummary(explicit: Explicit): string { + return explicit ? `Last advisor() call: ${detailsText(explicit)}` : ""; +} + type Prop = unknown | (() => unknown); const isEvent = (key: string) => key.startsWith("on") && key[2] === key[2]?.toUpperCase(); @@ -136,6 +145,7 @@ export default Plugin.define({ const client = context.client.rpc(AutoAdvisorRpc) as unknown as Client; const [views, setViews] = createSignal>({}); const [reviews, setReviews] = createSignal>({}); + const [explicits, setExplicits] = createSignal>({}); // Config is global; undefined until the server first answers, so a hidden widget never flashes. const [config, setConfig] = createSignal | undefined>( undefined, @@ -152,6 +162,7 @@ export default Plugin.define({ const apply = (status: Status) => { setConfig({ mode: status.mode, sidebar: status.sidebar }); setReviews((known) => ({ ...known, [status.sessionID]: status.latest })); + setExplicits((known) => ({ ...known, [status.sessionID]: status.explicit })); }; /** Reads the stored review, so one from before a restart shows too. */ const refresh = (sessionID: string) => { @@ -164,6 +175,9 @@ export default Plugin.define({ before.set(data.sessionID, data.latest?.id); setView(data.sessionID, "reviewing"); }); + const offExplicit = client.events.on("explicit.finished", ({ data }) => { + if (data) apply(data); + }); const offFinished = client.events.on("review.finished", ({ data }) => { if (!data) return; apply(data); @@ -298,6 +312,7 @@ export default Plugin.define({ ], })); const review = () => latest(input.sessionID); + const explicit = () => explicits()[input.sessionID] ?? null; return node( "box", { @@ -313,6 +328,9 @@ export default Plugin.define({ node("box", { flexDirection: "column", flexShrink: 0 }, [ node("text", { fg: () => theme().text.base }, "Advisor review"), node("text", { fg: () => theme().text.muted }, () => panelSummary(review())), + node("text", { fg: () => theme().text.muted, visible: () => Boolean(explicit()) }, () => + explicitSummary(explicit()), + ), ]), node( "scrollbox", @@ -345,6 +363,7 @@ export default Plugin.define({ return () => { offStarted(); + offExplicit(); offFinished(); for (const timer of timers) clearTimeout(timer); unregisterFooter(); From e2271ddd840436e40ba55feee1de6eefc9142367 Mon Sep 17 00:00:00 2001 From: Arjun Pramanik Date: Mon, 2 Nov 2026 03:22:13 -0500 Subject: [PATCH 6/7] docs: add default-config sections to plugin READMEs --- .../opencode-adversarial-review/README.md | 4 +++ packages/opencode-output-styles/README.md | 31 ++++++++++++++++++- packages/opencode-ram-monitor/README.md | 21 +++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/opencode-adversarial-review/README.md b/packages/opencode-adversarial-review/README.md index 4c1cfb6..5b79c53 100644 --- a/packages/opencode-adversarial-review/README.md +++ b/packages/opencode-adversarial-review/README.md @@ -121,12 +121,16 @@ Nothing is pinned — model and temperature are both inherited. Resolution order ```jsonc { "agents": { + // Used by /adversarial-review. Omit to inherit the invoking session's model. "adversarial-reviewer": { "model": "openrouter/openai/gpt-6-sol" }, + // Used by /constructive-review. "constructive-reviewer": { "model": "openrouter/openai/gpt-6-sol" } } } ``` +The plugin has no options of its own and nothing to show as defaults: with no `agents` entry, both reviewers use the invoking session's model and temperature. Their mode, hidden flag, description and read-only permissions are set by the plugin; a `system` or `color` you set on either agent is kept (see [Permissions & Security](#permissions--security)). + ## Permissions & Security Both reviewers are sandboxed and unattended; the single accepted risk is pasted arguments (below). Details: diff --git a/packages/opencode-output-styles/README.md b/packages/opencode-output-styles/README.md index 5079929..6b0b8d4 100644 --- a/packages/opencode-output-styles/README.md +++ b/packages/opencode-output-styles/README.md @@ -139,9 +139,38 @@ A file with the same id replaces the built-in completely: project over global ov ## Configuration +Options go in the plugin's entry in `opencode.json(c)`, using the object form. The TUI half reads them from the server, so `cli.json` needs nothing. Styles themselves are files; see [Custom styles](#custom-styles). + +```jsonc +{ + "plugins": [ + { "package": "@capybearista/opencode-output-styles@latest", "options": { "sidebar": false } } + ] +} +``` + +
+Default options (click to expand) + +```jsonc +{ + "plugins": [ + { + "package": "@capybearista/opencode-output-styles@latest", + "options": { + // Show the "Output Style: " line in the session sidebar. + "sidebar": true, + }, + }, + ], +} +``` + +
+ | Option | Default | Description | | --- | --- | --- | -| `sidebar` | `true` | Show the `Output Style: ` line in the session sidebar. | +| `sidebar` | `true` | Show the `Output Style: ` line in the session sidebar. Any value other than `false` keeps it on. | ## Troubleshooting diff --git a/packages/opencode-ram-monitor/README.md b/packages/opencode-ram-monitor/README.md index 4257a43..ee6aa14 100644 --- a/packages/opencode-ram-monitor/README.md +++ b/packages/opencode-ram-monitor/README.md @@ -128,6 +128,27 @@ Pass options in the plugin's `cli.json` entry, using the object form: > [!WARNING] > Use the object form above. The V1 tuple form (`["@capybearista/opencode-ram-monitor", { ... }]`) is not valid in V2's `cli.json`, and one invalid entry makes OpenCode ignore the whole file, including your theme, keybinds, and every other plugin. +
+Default options (click to expand) + +```jsonc +{ + "plugins": [ + { + "package": "@capybearista/opencode-ram-monitor@latest", + "options": { + // "full" (bordered two-row card) or "minimal" (one borderless line). + "layout": "full", + // Polling interval in ms, clamped between 1000 and 60000. + "refreshIntervalMs": 5000, + }, + }, + ], +} +``` + +
+ | Option | Type | Default | Description | | :--- | :--- | :--- | :--- | | `layout` | `"full"` \| `"minimal"` | `"full"` | `full` is the bordered two-row card. `minimal` is one borderless line: `RAM ● 4.88 GB (tools 3.39 GB)`. | From ae186535560b2580148c85a0b1ae09cb88a08f2c Mon Sep 17 00:00:00 2001 From: Arjun Pramanik Date: Mon, 2 Nov 2026 04:03:57 -0500 Subject: [PATCH 7/7] fix(auto-advisor): render advisor() calls in the timeline and log every explicit consult The timeline crashed on kind: "explicit" telemetry lines (no verdict) and let them take the next step's messages; it now shows each call as its own one-line row. An explicit call that failed before the Advisor chain ran (invalid config, failed history read) wrote no telemetry line; every call past the refusals now logs exactly once, and any Advisor record where no model answered carries advisor.error. Explicit ms measures the Advisor only, like automatic consults. --- packages/opencode-auto-advisor/AGENTS.md | 2 +- packages/opencode-auto-advisor/README.md | 6 +- .../scripts/timeline.test.ts | 42 ++++++++++++ .../opencode-auto-advisor/scripts/timeline.ts | 34 ++++++++-- .../opencode-auto-advisor/src/server.test.ts | 54 ++++++++++++++- packages/opencode-auto-advisor/src/server.ts | 65 ++++++++++--------- .../opencode-auto-advisor/src/telemetry.ts | 2 + 7 files changed, 163 insertions(+), 42 deletions(-) diff --git a/packages/opencode-auto-advisor/AGENTS.md b/packages/opencode-auto-advisor/AGENTS.md index d1ba4ef..d433a81 100644 --- a/packages/opencode-auto-advisor/AGENTS.md +++ b/packages/opencode-auto-advisor/AGENTS.md @@ -23,7 +23,7 @@ - `src/fallback.ts`: the model-chain walker shared by the Decision router and the Advisor chain (error policy, skip map, shared deadline). `src/decisions.ts`: the two Decision questions and the router over `routing.models`. `src/policy.ts`: the pure consult verdict. - `src/transcript.ts`: the Decision projection (the last `routing.requests` real prompts, each capped at `routing.requestMaxChars`, 20k by default (per request, not a total), the turn's recent steps without system updates, with subagent results and other user-role notices labelled `SUBAGENT`/`NOTICE`, and failure counts; no system prompt and no total cap) and the Advisor transcript (system text, tool names, messages with the same `SUBAGENT`/`NOTICE` labels; compacted, then budgeted, keeping the first real request to `min(advisor.maxTokens, 75% of the window)` at 3 chars per token). - `src/advisor.ts`: prompts and the timeout-raced consult. `src/delivery.ts`: review text, marker, tail push. -- `src/config.ts`: strict sparse `auto-advisor.json` (JSONC syntax via `Bun.JSONC.parse`, unknown keys rejected), re-read when its mtime changes. `config.test.ts` checks that the README's "Default configuration" block parses to `DEFAULTS`. `src/telemetry.ts`: JSONL with one rotation; each step line carries the newest message id and the Decision input's last 32k chars, and each explicit `advisor()` call writes a `kind: "explicit"` line with only the Advisor's attempts and timing. `scripts/timeline.ts` (`bun run timeline `, not shipped) joins those lines with the session's messages from `opencode api get /api/session//message` into an HTML page. `scripts/sandbox.ts` (`bun run sandbox`, not shipped) runs the isolated OpenCode used for live checks, in the foreground until Ctrl-C. +- `src/config.ts`: strict sparse `auto-advisor.json` (JSONC syntax via `Bun.JSONC.parse`, unknown keys rejected), re-read when its mtime changes. `config.test.ts` checks that the README's "Default configuration" block parses to `DEFAULTS`. `src/telemetry.ts`: JSONL with one rotation; each step line carries the newest message id and the Decision input's last 32k chars, and each explicit `advisor()` call that gets past the refusals (child session, no conversation yet) writes a `kind: "explicit"` line with only the Advisor's attempts and timing. Any Advisor record where no model answered (automatic or explicit) carries `advisor.error`. Every telemetry reader must handle both shapes: `scripts/timeline.ts` renders explicit lines as one-line rows that take no messages from the routing steps. `scripts/timeline.ts` (`bun run timeline `, not shipped) joins those lines with the session's messages from `opencode api get /api/session//message` into an HTML page. `scripts/sandbox.ts` (`bun run sandbox`, not shipped) runs the isolated OpenCode used for live checks, in the foreground until Ctrl-C. - `src/tui.ts`: the footer status, a `sidebar.footer` widget (hidden by `tui.sidebar`), and a `session.panel` named `auto-advisor` with the full review, opened by a click or `/advisor-review`; it also names the model that answered the last explicit `advisor()` call (server memory, sent as `explicit` with an `explicit.finished` event). State comes from RPC events, plus one `status` call per mounted widget or panel, so a stored review shows after a restart. - `src/rpc.ts` is the contract both halves import. diff --git a/packages/opencode-auto-advisor/README.md b/packages/opencode-auto-advisor/README.md index b6b313a..7942596 100644 --- a/packages/opencode-auto-advisor/README.md +++ b/packages/opencode-auto-advisor/README.md @@ -167,13 +167,13 @@ The table has the details for each key. - **Advisor**: automatic reviews are on by default and, without `advisor.models`, use the agent's own model. Each consult sends the agent's transcript (system prompt with OpenCode's catalogs left out when `advisor.compactContext` is on, up to `advisor.maxTokens`) to the Advisor model, uncached. It's billed at that model's rates. - **Latency**: in `active`, a decided step waits for one Decision call (about 0.1–0.4 s on Zen after a cold start); other steps and steps a limit blocks don't wait. In `observe` the call runs in the background. A consult pauses the agent until the Advisor answers. - **Cache**: installing the plugin adds the `advisor` tool, which costs one prompt-cache miss on the tools block. After that, reviews only add to the end of a request. -- **Telemetry**: one JSON line per decided or blocked step, and one per explicit `advisor()` call (`"kind": "explicit"`, with the Advisor's attempts and timing only), in `~/.local/share/opencode/auto-advisor/telemetry.jsonl` (capped at 5 MB, one rotation), with models, answers, verdicts and timings. It also keeps the step's Decision input (its last 32,000 characters: your recent requests, tool calls and truncated results) and the id of the newest message, all on your machine and readable only by you. Never the Advisor's transcript or advice text. At the 32,000-character maximum, a 5 MB file holds about 150 steps, so long sessions keep only their latest steps. +- **Telemetry**: one JSON line per decided or blocked step, and one per explicit `advisor()` call that reaches a consult (`"kind": "explicit"`, with the Advisor's attempts and timing only). When no Advisor model answered, automatic or explicit, `advisor.error` says why (`all models failed (…)`, `invalid config: …`), in `~/.local/share/opencode/auto-advisor/telemetry.jsonl` (capped at 5 MB, one rotation), with models, answers, verdicts and timings. It also keeps the step's Decision input (its last 32,000 characters: your recent requests, tool calls and truncated results) and the id of the newest message, all on your machine and readable only by you. Never the Advisor's transcript or advice text. At the 32,000-character maximum, a 5 MB file holds about 150 steps, so long sessions keep only their latest steps. ## Troubleshooting -- **Judging the verdicts**: from a checkout of this repo, with OpenCode running, `bun run timeline [--out file.html]` (the ID is the `session` field of any telemetry line) in `packages/opencode-auto-advisor` writes one page per session: each routing decision with its moment probabilities, consequence, limit and Advisor outcome, and, expanded, the messages since the previous step and the exact Decision input. Like telemetry, the page is readable only by you. +- **Judging the verdicts**: from a checkout of this repo, with OpenCode running, `bun run timeline [--out file.html]` (the ID is the `session` field of any telemetry line) in `packages/opencode-auto-advisor` writes one page per session: each routing decision with its moment probabilities, consequence, limit and Advisor outcome, plus a one-line row for each explicit `advisor()` call, and, expanded, the messages since the previous step and the exact Decision input. Like telemetry, the page is readable only by you. - **Nothing happens automatically**: check `telemetry.jsonl` for the verdicts. `routine` and `low-consequence` mean the thresholds held the review back, `blocked` means the cooldown or the per-turn limit stopped a consult before any Decision call (the `blocked` field says which; a step both block is logged as `cooldown`), and `no-decision` means no configured decision model answered. A step skipped by `routing.minStepsBetweenDecisions` leaves no line, so most steps have none. -- **"Advisor unavailable" in the footer or sidebar**: every Advisor model failed or the deadline ran out. The agent carried on without advice. `advisor.attempts` in `telemetry.jsonl` lists each model's outcome. +- **"Advisor unavailable" in the footer or sidebar**: every Advisor model failed or the deadline ran out. The agent carried on without advice. `advisor.attempts` in `telemetry.jsonl` lists each model's outcome, and `advisor.error` sums it up. - **No Advisor in the sidebar**: OpenCode shows the sidebar only in top-level sessions, and only on terminals wider than 120 columns unless you open it. While the review panel is open, it takes the sidebar's place. `/advisor-review` works either way. - **The review panel shows no model or duration**: they are kept in memory, so a review delivered before OpenCode restarted shows only its moment and text. The same goes for the "Last advisor() call" line. - **Which model answered an `advisor()` call**: the panel's "Last advisor() call" line names the latest one a model answered. For every call, including failed ones, read the `"kind": "explicit"` lines in `telemetry.jsonl`: `advisor.model` and `advisor.attempts` list each model tried. diff --git a/packages/opencode-auto-advisor/scripts/timeline.test.ts b/packages/opencode-auto-advisor/scripts/timeline.test.ts index d6875c4..1fd34cd 100644 --- a/packages/opencode-auto-advisor/scripts/timeline.test.ts +++ b/packages/opencode-auto-advisor/scripts/timeline.test.ts @@ -39,6 +39,15 @@ const record = (step: number, message: string | undefined, extra: object = {}) = ...extra, }); +/** An explicit `advisor()` call's line: no step, verdict or Decision input. */ +const explicit = (at: number, message: string, advisor: object = {}) => ({ + ts: new Date(1_793_000_000_000 + at * 1_000).toISOString(), + session: "s", + message, + kind: "explicit", + advisor: { model: "mock/deep", ms: 4_200, outcome: "ok", attempts: [], ...advisor }, +}); + describe("timelineRows", () => { test("each step gets the messages since the previous step, in session order", () => { const rows = timelineRows( @@ -51,6 +60,19 @@ describe("timelineRows", () => { expect(rows[1].messages.map((m) => m.id)).toEqual(["u2", "a2"]); }); + test("an advisor() call takes no messages from the routing steps around it", () => { + const rows = timelineRows( + "s", + [record(1, "a1"), explicit(1.5, "a2"), record(2, "a2")], + messages, + ); + expect(rows.map((row) => row.messages.map((m) => m.id))).toEqual([ + ["u1", "a1"], + [], + ["u2", "a2"], + ]); + }); + test("a step whose message is unknown, or from before the field existed, has none", () => { const rows = timelineRows("s", [record(1, undefined), record(2, "gone")], messages); expect(rows.map((row) => row.messages)).toEqual([[], []]); @@ -85,6 +107,26 @@ describe("renderTimeline", () => { expect(html).not.toContain("now"); }); + test("renders advisor() calls as their own rows next to routing steps", () => { + const rows = timelineRows( + "s", + [ + record(1, "a1"), + explicit(1.5, "a2", { model: "mock/", ms: 1_000, outcome: "error", error: "down" }), + explicit(1.7, "a2"), + record(2, "a2"), + ], + messages, + ); + const html = renderTimeline("s", rows); + expect(html).toContain("2 routing decisions and 2 advisor() calls"); + expect(html).toContain("advisor() call"); + expect(html).toContain("mock/<deep>"); + expect(html).toContain("error · 1.0 s · down"); + expect(html).toContain("mock/deep · ok · 4.2 s"); + expect(html).not.toContain("undefined"); + }); + test("escapes the logged input and renders lines from before it was logged", () => { const rows = timelineRows( "s", diff --git a/packages/opencode-auto-advisor/scripts/timeline.ts b/packages/opencode-auto-advisor/scripts/timeline.ts index 9d6532a..fe9615f 100644 --- a/packages/opencode-auto-advisor/scripts/timeline.ts +++ b/packages/opencode-auto-advisor/scripts/timeline.ts @@ -9,11 +9,20 @@ import { closeSync, mkdtempSync, openSync, readFileSync, rmSync } from "node:fs" import { readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { resolveTelemetryPath, type TelemetryRecord } from "../src/telemetry.js"; +import { + type ExplicitRecord, + resolveTelemetryPath, + type TelemetryRecord, +} from "../src/telemetry.js"; /** Lines written before `message`/`input` existed lack them. */ -type Line = Omit & +type Step = Omit & Partial>; +/** A routing step, or an explicit `advisor()` call (no step, verdict or Decision input). */ +type Line = Step | ExplicitRecord; + +const isExplicit = (record: Line): record is ExplicitRecord => + "kind" in record && record.kind === "explicit"; interface StoredPart { readonly type: string; @@ -48,6 +57,8 @@ export function timelineRows( .filter((record) => record.session === sessionID) .toSorted((a, b) => a.ts.localeCompare(b.ts)) .map((record) => { + // A call's message is also the next step's: leave the messages to the routing steps. + if (isExplicit(record)) return { record, messages: [] }; const at = record.message === undefined ? undefined : index.get(record.message); if (at === undefined) return { record, messages: [] }; const since = messages.slice(from, at + 1); @@ -85,7 +96,14 @@ const VERDICT_CLASS: Record = { "no-decision": "failed", }; +function callHtml(record: ExplicitRecord): string { + const { model, outcome, ms, error } = record.advisor; + const facts = [model, outcome, `${(ms / 1000).toFixed(1)} s`, error ?? ""].filter(Boolean); + return `
${html(new Date(record.ts).toLocaleTimeString())}advisor() call${html(facts.join(" · "))}
`; +} + function rowHtml({ record, messages }: Row): string { + if (isExplicit(record)) return callHtml(record); const time = new Date(record.ts).toLocaleTimeString(); const moment = Object.entries(record.moment ?? {}) .map(([name, p]) => `${name} ${p.toFixed(2)}`) @@ -111,6 +129,13 @@ function rowHtml({ record, messages }: Row): string {

Decision input${record.inputChars ? ` (${record.inputChars} chars; last ${record.input?.length ?? 0} logged)` : ""}

${html(record.input ?? "(not logged)")}
`; } +function counts(rows: readonly Row[]): string { + const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? "" : "s"}`; + const calls = rows.filter((row) => isExplicit(row.record)).length; + const steps = plural(rows.length - calls, "routing decision"); + return calls ? `${steps} and ${plural(calls, "advisor() call")}` : steps; +} + export function renderTimeline(sessionID: string, rows: readonly Row[]): string { return ` @@ -126,9 +151,10 @@ summary { cursor: pointer; display: flex; gap: 10px; flex-wrap: wrap; align-item .verdict { font-weight: 600; } .consult { color: var(--consult); } .blocked { color: var(--blocked); } .failed { color: var(--failed); } pre { background: var(--code); margin: 0; padding: 8px; white-space: pre-wrap; word-break: break-word; max-height: 360px; overflow: auto; } .msg { margin: 6px 0; } .type { font-size: 12px; color: var(--muted); } +.call { border-bottom: 1px solid var(--line); display: flex; gap: 10px; flex-wrap: wrap; align-items: baseline; padding: 6px 0 6px 16px; }

Auto-Advisor timeline · ${html(sessionID)}

-

${rows.length} routing decisions. Expand a step to see what happened since the previous one and what the Decision model read.

+

${counts(rows)}. Expand a step to see what happened since the previous one and what the Decision model read.

${rows.map(rowHtml).join("\n")} `; @@ -203,5 +229,5 @@ if (import.meta.main) { // like telemetry. `mode` applies only to a new file, so an existing one is replaced. await rm(file, { force: true }); await writeFile(file, renderTimeline(sessionID, rows), { mode: 0o600, flag: "wx" }); - console.log(`${rows.length} steps → ${file}`); + console.log(`${counts(rows)} → ${file}`); } diff --git a/packages/opencode-auto-advisor/src/server.test.ts b/packages/opencode-auto-advisor/src/server.test.ts index 92a2f65..9ef6f23 100644 --- a/packages/opencode-auto-advisor/src/server.test.ts +++ b/packages/opencode-auto-advisor/src/server.test.ts @@ -771,6 +771,53 @@ describe("explicit advisor()", () => { expect(host.textCalls).toHaveLength(0); }); + test("an invalid config is logged as a failed call with its reason", async () => { + await writeConfig({ advisor: { model: "openai/gpt-6" }, routing: { mode: "off" } }); + const host = await start(); + await host.hooks.context(request("s", [user("u1")])); + await host + .tool() + .execute({}, { sessionID: "s", messageID: "m", signal: new AbortController().signal }); + await Bun.sleep(20); + const [line] = await telemetryLines(); + expect(line).toMatchObject({ session: "s", message: "m", kind: "explicit" }); + expect(line.advisor).toMatchObject({ model: "unknown", attempts: [], outcome: "error" }); + expect(line.advisor.error).toContain("invalid config:"); + expect(line.advisor.error).toContain("unknown key: advisor.model"); + expect(host.emitted).toEqual(["explicit.finished"]); + }); + + test("a failed history read is logged once, with its reason", async () => { + await writeConfig({ routing: { mode: "off" } }); + const host = await start(); + await host.hooks.context(request("s", [user("u1")])); + host.ctx.session.context = async () => Promise.reject(new Error("history unavailable")); + const result = await host + .tool() + .execute({}, { sessionID: "s", messageID: "m", signal: new AbortController().signal }); + expect(result.content).toBe("Advisor consultation failed: history unavailable"); + await Bun.sleep(20); + const lines = await telemetryLines(); + expect(lines).toHaveLength(1); + expect(lines[0].advisor).toMatchObject({ + model: "unknown", + attempts: [], + outcome: "error", + error: "history unavailable", + }); + expect(host.emitted).toEqual(["explicit.finished"]); + }); + + test("a refused call is not a consult and writes no line", async () => { + await writeConfig({ routing: { mode: "off" } }); + const host = await start(); + const call = { messageID: "m", signal: new AbortController().signal }; + await host.tool().execute({}, { ...call, sessionID: "child" }); + await host.tool().execute({}, { ...call, sessionID: "s" }); + await Bun.sleep(20); + expect(await telemetryLines()).toEqual([]); + }); + test("ignores the per-turn budget and the cooldown", async () => { const host = await start(); await host.hooks.context(request("s", [user("u1"), ...step(1)])); @@ -944,11 +991,14 @@ describe("advisor chain", () => { .tool() .execute({}, { sessionID: "s", messageID: "m", signal: new AbortController().signal }); await Bun.sleep(20); - const [line] = await telemetryLines(); - expect(line).toMatchObject({ + const lines = await telemetryLines(); + expect(lines).toHaveLength(1); + expect(lines[0]).toMatchObject({ kind: "explicit", advisor: { model: "mock/dead", outcome: "error" }, }); + expect(lines[0].advisor.error).toStartWith("all models failed (mock/dead:"); + expect(host.emitted).toEqual(["explicit.finished"]); expect((await host.rpc().status({ sessionID: "s" })).explicit).toBeNull(); }); diff --git a/packages/opencode-auto-advisor/src/server.ts b/packages/opencode-auto-advisor/src/server.ts index 60fc10b..8e43722 100644 --- a/packages/opencode-auto-advisor/src/server.ts +++ b/packages/opencode-auto-advisor/src/server.ts @@ -102,6 +102,7 @@ function failedAdvisor(cause: unknown, ms: number, stale = false): AdvisorRecord return { model: attempts.at(-1)?.model ?? "unknown", attempts, + error: cause instanceof Error ? cause.message : String(cause), ms, outcome: stale ? "stale" @@ -281,12 +282,25 @@ export default Plugin.define({ options: { codemode: false }, execute: async (_input, tool) => { const session = state(tool.sessionID); + /** Set once the call is a consult; the refusals above it are not logged. */ + let start: number | undefined; + const logged = (at: number, advisor: AdvisorRecord) => { + telemetry.record({ + ts: new Date(at).toISOString(), + session: tool.sessionID, + message: tool.messageID, + kind: "explicit", + advisor, + }); + emit("explicit.finished", tool.sessionID); + }; try { if (await isChild(tool.sessionID, session)) { return { content: "The Advisor is only available in top-level sessions." }; } const snapshot = session.last; if (!snapshot) return { content: "The Advisor has no conversation to review yet." }; + start = Date.now(); // With routing off, nothing has read the stored prompts yet. await realPrompts(tool.sessionID, session); // The cached request predates this step; add the in-flight message holding this call. @@ -299,39 +313,26 @@ export default Plugin.define({ const { config, error } = await loadConfig(); // An invalid file falls back to defaults; consulting the Executor's model would hide that. if (error) throw new Error(`invalid config: ${error}`); - const start = Date.now(); - const logged = (advisor: AdvisorRecord) => { - telemetry.record({ - ts: new Date(start).toISOString(), - session: tool.sessionID, - message: tool.messageID, - kind: "explicit", - advisor, - }); - emit("explicit.finished", tool.sessionID); - }; - try { - const consulted = await review( - { ...snapshot, messages: [...snapshot.messages, ...inflight] }, - config.advisor, - undefined, - tool.signal, - ); - const ms = Date.now() - start; - session.explicit = { at: new Date().toISOString(), model: consulted.model, ms }; - logged({ - model: consulted.model, - attempts: consulted.attempts, - ms, - outcome: "ok", - chars: consulted.advice.length, - }); - return { content: consulted.advice }; - } catch (cause) { - logged(failedAdvisor(cause, Date.now() - start)); - throw cause; - } + // Like an automatic consult's, `ms` is the Advisor's time, not the history reads. + start = Date.now(); + const consulted = await review( + { ...snapshot, messages: [...snapshot.messages, ...inflight] }, + config.advisor, + undefined, + tool.signal, + ); + const ms = Date.now() - start; + session.explicit = { at: new Date().toISOString(), model: consulted.model, ms }; + logged(start, { + model: consulted.model, + attempts: consulted.attempts, + ms, + outcome: "ok", + chars: consulted.advice.length, + }); + return { content: consulted.advice }; } catch (cause) { + if (start !== undefined) logged(start, failedAdvisor(cause, Date.now() - start)); const message = cause instanceof Error ? cause.message : String(cause); return { content: `Advisor consultation failed: ${message}`, diff --git a/packages/opencode-auto-advisor/src/telemetry.ts b/packages/opencode-auto-advisor/src/telemetry.ts index 62c2974..26dd643 100644 --- a/packages/opencode-auto-advisor/src/telemetry.ts +++ b/packages/opencode-auto-advisor/src/telemetry.ts @@ -39,6 +39,8 @@ export interface AdvisorRecord { readonly ms: number; readonly outcome: "ok" | "error" | "timeout" | "stale"; readonly chars?: number; + /** Why no model answered: the chain's summary, or a failure before any model was tried. */ + readonly error?: string; /** One per Advisor model tried, in chain order. */ readonly attempts?: readonly Attempt[]; }