diff --git a/AGENTS.md b/AGENTS.md
index 24a5e91..75c31db 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -19,7 +19,7 @@ Subdirectories contain specialized files that extend these rules.
### Plugin Development (MUST)
-Scope: V1 packages. The five V2 ports use the Promise API instead; see the V2 boundary below.
+Scope: V1 packages. The V2 packages use the Promise API instead; see the V2 boundary below.
- **MUST** keep server and TUI entrypoints split. If a plugin exposes both, publish separate `./server` and `./tui` exports instead of exporting both from one module.
- **MUST** verify the real runtime path when testing local plugins. Check both the package build output and the harness config that OpenCode actually loads.
@@ -30,7 +30,7 @@ Scope: V1 packages. The five V2 ports use the Promise API instead; see the V2 bo
### V2 Boundary (MUST)
-Five packages have V2 ports; one remains V1-only. [`docs/v1-plugins.md`](docs/v1-plugins.md) is the compatibility authority for V1 install, pinning, and the V2 boundary — link it instead of duplicating version tables.
+Five packages have V2 ports, one is V2-only, and one remains V1-only. [`docs/v1-plugins.md`](docs/v1-plugins.md) is the compatibility authority for V1 install, pinning, and the V2 boundary — link it instead of duplicating version tables.
| Package | V2 status |
| --- | --- |
@@ -39,10 +39,11 @@ Five packages have V2 ports; one remains V1-only. [`docs/v1-plugins.md`](docs/v1
| `opencode-adversarial-review` | Ported: server plugin, plural `plugins` in `opencode.json`; `2.x` on `latest` |
| `opencode-agent-prompt-inheritance` | V1 only; V2 port TBD |
| `opencode-output-styles` | Ported: combined server + TUI plugin, one plural `plugins` entry in `opencode.json` (TUI half loads automatically; requires OpenCode 2.2.0+); `2.x` on `latest`, V1 users pin `@1` |
+| `opencode-auto-advisor` | V2-only: combined server + TUI plugin, one plural `plugins` entry in `opencode.json` (requires OpenCode 2.2.5+); `2.x` on `latest`, no V1 line |
| `opencode-ram-monitor` | Ported: TUI plugin (TUI-only since `2.1.0`, requires OpenCode 2.1+), plural `plugins` in `cli.json`; `2.x` on `latest` |
- **MUST** use the V2 Promise API in ports: `Plugin.define({ id, setup })` from `@opencode/plugin` 2.x. Do not carry V1 `server`/`config` hook signatures into a port.
-- **MUST** keep V2 entrypoints at the package root: `server.js` for server plugins (loader, adversarial-review, output-styles, which also ships `tui.js`), `tui.js` for TUI plugins (timeline, ram-monitor). Filesystem-local config points at the package directory, not the entry file.
+- **MUST** keep V2 entrypoints at the package root: `server.js` for server plugins (loader, adversarial-review, output-styles and auto-advisor, which also ship `tui.js`), `tui.js` for TUI plugins (timeline, ram-monitor). Filesystem-local config points at the package directory, not the entry file.
- **MUST** use the plural `"plugins"` key and split host config: server plugins in `opencode.json`, TUI plugins in `cli.json`. V1 keeps the singular `"plugin"` key in `opencode.json` / `tui.json`.
- **MUST NOT** write `@v2` as a specifier — npm parses it as a semver range. Use `@latest` (or an exact `@2.0.0` pin for a fixed setup).
- **MUST** keep V1 and V2 lines apart. The loader and timeline V1 releases are frozen but available by exact pin. Both lines may share the default config directory, so use separate profiles (`OPENCODE_CONFIG_DIR`) for side-by-side testing.
@@ -96,6 +97,7 @@ CI runs typecheck, lint, and test only — `bun run build` is a local-only gate;
### Packages
- **`packages/opencode-adversarial-review/`** → Adversarial code review plugin with a clean-context review subagent and `/adversarial-review` command (see `packages/opencode-adversarial-review/AGENTS.md`)
- **`packages/opencode-agent-prompt-inheritance/`** → Preserves provider system prompts when custom agents inject their own instructions (see `packages/opencode-agent-prompt-inheritance/AGENTS.md`)
+- **`packages/opencode-auto-advisor/`** → Claude Code-style Advisor with Decision-model-triggered automatic reviews (see `packages/opencode-auto-advisor/AGENTS.md`)
- **`packages/opencode-agents-loader/`** → OpenCode plugin that extends command and agent discovery to ~/.agents/ and .agents/ directories (see packages/opencode-agents-loader/AGENTS.md)
- **`packages/opencode-double-tap-timeline/`** → OpenCode plugin (see packages/opencode-double-tap-timeline/AGENTS.md)
- **`packages/opencode-output-styles/`** → OpenCode plugin (see packages/opencode-output-styles/AGENTS.md)
diff --git a/README.md b/README.md
index a375243..2e3f5b0 100644
--- a/README.md
+++ b/README.md
@@ -46,6 +46,7 @@
| [⏱️ Double-Tap Timeline](./packages/opencode-double-tap-timeline/) | Open the session timeline with two Escape presses. |
| [🛠️ Agents Loader](./packages/opencode-agents-loader/) | Reuse agents and commands across AI coding tools. |
| [🗣️ Output Styles](./packages/opencode-output-styles/) | Customize how your agents approach conversations. |
+| [🧭 Auto Advisor](./packages/opencode-auto-advisor/) | Get a second model's review at the moments that matter. |
| [💬 Agent Prompt Inheritance](./packages/opencode-agent-prompt-inheritance/) | **V1 only:** Preserve base model instructions when customizing agents. |
## Meet the Plugins
@@ -156,6 +157,16 @@ Switch between persistent response styles without repeating the same instruction
Includes Claude Code's built-in `Proactive`, `Concise`, `Explanatory`, and `Learning` styles, plus custom project-local and global styles. Use `/output-style`, the picker, or the clickable sidebar line to switch; each session keeps its own style, with a global setting for the rest.
+### 🧭 Auto Advisor [[opencode-auto-advisor]](./packages/opencode-auto-advisor/)
+
+
+
+
+
+**A Claude Code-style Advisor for your agent.**
+
+Your agent can call a zero-argument `advisor()` tool to have a second model, ideally a stronger one, review its work. By default, a fast Decision model also watches each step and brings in the Advisor on its own, at most twice per turn and once per 10 steps, when the agent is about to commit to an approach, is stuck, or is finishing, and a mistake would be costly. Reviews never edit the system prompt, so the prompt cache stays intact.
+
## Quick Start (OpenCode V2)
@@ -176,12 +187,13 @@ Add your selected server plugins to `opencode.json` or `opencode.jsonc`:
"plugins": [
"@capybearista/opencode-adversarial-review@latest",
"@capybearista/opencode-agents-loader@latest",
- "@capybearista/opencode-output-styles@latest"
+ "@capybearista/opencode-output-styles@latest",
+ "@capybearista/opencode-auto-advisor@latest"
]
}
```
-A plugin with both a server and a TUI half, such as Output Styles, needs only this one entry; its TUI half loads automatically, so it does not belong in `cli.json`.
+A plugin with both a server and a TUI half, such as Output Styles or Auto Advisor, needs only this one entry; its TUI half loads automatically, so it does not belong in `cli.json`.
### 2. TUI plugins
@@ -213,6 +225,7 @@ The repository supports both OpenCode V2 and V1, but plugin availability and con
| [Double-Tap Timeline](./packages/opencode-double-tap-timeline/) | `@latest` · `cli.json` | 2.0.0 | `@1.0.1` · `tui.json(c)` |
| [RAM Monitor](./packages/opencode-ram-monitor/) | `@latest` · `cli.json` | 2.1.0 | `@1.1.0` · `opencode.json(c)` and `tui.json(c)` |
| [Output Styles](./packages/opencode-output-styles/) | `@latest` · `opencode.json(c)` | 2.2.0 | `@1` · `opencode.json(c)` |
+| [Auto Advisor](./packages/opencode-auto-advisor/) | `@latest` · `opencode.json(c)` | 2.2.5 | Not available |
| [Agent Prompt Inheritance](./packages/opencode-agent-prompt-inheritance/) | Not yet ported | — | `@latest` · `opencode.json(c)` |
**Using OpenCode V1?** See the [V1 Plugin Guide](./docs/v1-plugins.md) for installation, version pinning, and upgrade instructions.
diff --git a/bun.lock b/bun.lock
index a518ba1..e281a50 100644
--- a/bun.lock
+++ b/bun.lock
@@ -52,6 +52,30 @@
"@opencode/plugin": ">=2.0.2 <3",
},
},
+ "packages/opencode-auto-advisor": {
+ "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",
+ "@types/bun": "latest",
+ "@types/node": "^26.6.5",
+ "solid-js": "1.9.17",
+ "typescript": "^7.0.2",
+ },
+ "peerDependencies": {
+ "@opencode/plugin": ">=2.2.5 <3",
+ "@opentui/core": ">=0.8.0",
+ "@opentui/solid": ">=0.8.0",
+ "solid-js": ">=1.9.0",
+ },
+ "optionalPeers": [
+ "@opentui/core",
+ "@opentui/solid",
+ "solid-js",
+ ],
+ },
"packages/opencode-double-tap-timeline": {
"name": "@capybearista/opencode-double-tap-timeline",
"version": "2.0.1",
@@ -73,7 +97,7 @@
},
"packages/opencode-output-styles": {
"name": "@capybearista/opencode-output-styles",
- "version": "2.1.0",
+ "version": "2.2.0",
"dependencies": {
"yaml": "^2.9.1",
},
@@ -252,6 +276,8 @@
"@capybearista/opencode-agents-loader": ["@capybearista/opencode-agents-loader@workspace:packages/opencode-agents-loader"],
+ "@capybearista/opencode-auto-advisor": ["@capybearista/opencode-auto-advisor@workspace:packages/opencode-auto-advisor"],
+
"@capybearista/opencode-double-tap-timeline": ["@capybearista/opencode-double-tap-timeline@workspace:packages/opencode-double-tap-timeline"],
"@capybearista/opencode-output-styles": ["@capybearista/opencode-output-styles@workspace:packages/opencode-output-styles"],
@@ -1146,6 +1172,8 @@
"@capybearista/opencode-adversarial-review/@types/bun": ["@types/bun@1.4.4", "", { "dependencies": { "bun-types": "1.4.4" } }, "sha512-9wkRDXUXK124pEnAutE6n/l18j+i9GC5IAHK8TmX/2nlySETmky0OU6yL5U8pW1AoKh++AN9auDCr647BfurjQ=="],
+ "@capybearista/opencode-auto-advisor/@types/bun": ["@types/bun@1.4.4", "", { "dependencies": { "bun-types": "1.4.4" } }, "sha512-9wkRDXUXK124pEnAutE6n/l18j+i9GC5IAHK8TmX/2nlySETmky0OU6yL5U8pW1AoKh++AN9auDCr647BfurjQ=="],
+
"@capybearista/opencode-output-styles/@types/bun": ["@types/bun@1.4.4", "", { "dependencies": { "bun-types": "1.4.4" } }, "sha512-9wkRDXUXK124pEnAutE6n/l18j+i9GC5IAHK8TmX/2nlySETmky0OU6yL5U8pW1AoKh++AN9auDCr647BfurjQ=="],
"@capybearista/opencode-ram-monitor/@types/bun": ["@types/bun@1.4.4", "", { "dependencies": { "bun-types": "1.4.4" } }, "sha512-9wkRDXUXK124pEnAutE6n/l18j+i9GC5IAHK8TmX/2nlySETmky0OU6yL5U8pW1AoKh++AN9auDCr647BfurjQ=="],
@@ -1308,6 +1336,8 @@
"@capybearista/opencode-adversarial-review/@types/bun/bun-types": ["bun-types@1.4.4", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-yjrJh5oGX7mMPKGd19YXyIjmEuj4G4zNC30bs7PM9Az7NQ3ndF3j6hbeRdvJTRQEhJ/FYQwW0a2moweEi2hMxQ=="],
+ "@capybearista/opencode-auto-advisor/@types/bun/bun-types": ["bun-types@1.4.4", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-yjrJh5oGX7mMPKGd19YXyIjmEuj4G4zNC30bs7PM9Az7NQ3ndF3j6hbeRdvJTRQEhJ/FYQwW0a2moweEi2hMxQ=="],
+
"@capybearista/opencode-output-styles/@types/bun/bun-types": ["bun-types@1.4.4", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-yjrJh5oGX7mMPKGd19YXyIjmEuj4G4zNC30bs7PM9Az7NQ3ndF3j6hbeRdvJTRQEhJ/FYQwW0a2moweEi2hMxQ=="],
"@capybearista/opencode-ram-monitor/@types/bun/bun-types": ["bun-types@1.4.4", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-yjrJh5oGX7mMPKGd19YXyIjmEuj4G4zNC30bs7PM9Az7NQ3ndF3j6hbeRdvJTRQEhJ/FYQwW0a2moweEi2hMxQ=="],
diff --git a/docs/README.md b/docs/README.md
index 9180dbb..d66582f 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -14,6 +14,7 @@ The root [README](../README.md) and package READMEs describe current installatio
| [opencode-plugin-ipc-and-os-patterns.md](./opencode-plugin-ipc-and-os-patterns.md) | Plugin-author reference | IPC and cross-platform patterns learned from building these plugins, with V1 and V2 startup examples. |
| [output-styles/claude-output-styles.md](./output-styles/claude-output-styles.md) | Upstream reference | Snapshot of Claude Code output-style docs. Background for the output-styles plugin, not a statement of this repo's behavior. |
| [output-styles/v2-port-findings.md](./output-styles/v2-port-findings.md) | Package note | What we verified about Claude Code output styles and OpenCode V2 while porting output-styles. |
+| [auto-advisor/findings.md](./auto-advisor/findings.md) | Package note | OpenCode 2.2.x behavior the auto-advisor design rests on (2.2.0, rechecked on 2.2.5, the minimum): host Decision calls and errors, when entries land, tail delivery per protocol, retries, plus a re-verify recipe. |
| [ram-monitor/post-mortem-v1.0.0.md](./ram-monitor/post-mortem-v1.0.0.md) | Historical package note | What broke and what fixed it during ram-monitor 1.0.0 stabilization (V1; the config and session-discovery approach it describes was replaced in 2.1.0). |
| [adversarial-review/codex-comparison.md](./adversarial-review/codex-comparison.md) | Package note | Prompt-structure comparison between the official Codex plugin and this repo's review plugin. |
diff --git a/docs/auto-advisor/findings.md b/docs/auto-advisor/findings.md
new file mode 100644
index 0000000..4a9813d
--- /dev/null
+++ b/docs/auto-advisor/findings.md
@@ -0,0 +1,101 @@
+# Auto Advisor: verified OpenCode 2.2.x behavior
+
+Each section names the date and the OpenCode version it was checked on. S1–S5 were verified 2026-10-28 on `@opencode/cli@2.2.0` with an isolated HOME/XDG, a local mock executor (OpenAI chat and Anthropic messages endpoints that log request bodies) and real OpenCode Zen. The design in `packages/opencode-auto-advisor` rests on these facts; re-check them before changing hooks, delivery or the router.
+
+## S1 — `ctx.experimental.generate.decisions` with `opencode/jev-1.13-free`
+- Anonymous works, no Zen login. `cost: 0`, `native.opencode.usage.opencode.billing_source: "free"`.
+- Latency: first call 2.8 s (cold); next 9 calls 112–350 ms (median ~155 ms). ~500 input tokens / 65 output.
+- Answer shapes match the schema: choice `{choice, probabilities[], confidence}`, score `{score (raw, e.g. 2.49), probabilities[], confidence}`. Plugin bridge returns `{answers, cost, tokens, native}`; HTTP wraps it in `data`.
+- Errors arrive as plain objects with `_tag` (+ `message`, fields):
+ - unknown model or provider → `Generate.ModelNotFoundError`
+ - chat model → `Generate.UnsupportedModelError` (`capability: "decision"`)
+ - provider without credentials (openrouter, no key) → `Generate.ModelNotFoundError` (provider not in catalog), not an auth error
+ - bad request shape → `SchemaError` (client-side, before the host)
+ - input too large → `Generate.ProviderRequestError` `reason: "invalid_request"` (HTTP 400 `max_tokens`)
+- Input size: 100k chars accepted; 250k rejected. The projection has no total cap since round 3: a request too large for one decision model fails over to the next in `routing.models`; `routing.requestMaxChars` caps each request when needed.
+- S1b (8 synthetic cases, smoke only): moment 8/8 correct. Consequence: prod migration 3.81, key rotation 2.8, rate-limit design 2.1, both "stuck" cases ~1.85, CLI flag done 0.52, routine 0.4–1.1.
+
+## S2 — delivery timing (OpenAI chat mock)
+- Tail `{role:"system"}` pushed in the `context` hook at step N is in step N's request (as a user-role ``, XML-escaped).
+- `entry.put` in the hook at step N is admitted at step N+1 as a durable `…` placed after step N's tool result, and stays at that index in later requests.
+- Prefix: consecutive primary requests share the whole prefix except the ephemeral tail; tools identical.
+- Hook payload: user/assistant messages carry ids; `tool` messages have none; admitted entries appear as `role:"system"` with an id.
+- A provider 503 retry re-fires the `context` hook, and the retry already includes the entry put during the failed attempt (dedupe marker needed). On retry the entry is placed before that step's reply, then moves after it → one-time prefix break, retry case only.
+
+## S3 — reentrancy and events
+- `generate.text` from inside the `context` hook does not fire the plugin's hooks.
+- Event names: `session.execution.{started,succeeded,interrupted,failed}`; also `session.step.{started,ended}`, `session.instructions.updated`.
+
+## S4 — explicit tool context
+- Tool ctx keys: `sessionID, agent, messageID, id, progress, signal`.
+- `ctx.session.context()` during execution includes the in-flight assistant message (`id === toolCtx.messageID`, tool state `running`).
+- Prompt hook: `{sessionID, messageID, prompt, metadata, delivery}`; `delivery: "steer"` for `opencode run`.
+
+## S5 — protocols
+- Anthropic, Claude 4.6: tail system message merged into the tool-result user turn as a text block, escaped.
+- Anthropic, Claude 5.5: native mid-conversation `system` message, not escaped.
+- OpenAI chat: user-role ``, escaped.
+
+## Live check of the built plugin (2026-10-28)
+- One `plugins` entry in `opencode.json` loads both halves: `/api/plugin` reports `features: {server: true, tui: true}`.
+- `active` with thresholds at 0 and real Jev: step 1 and step 2 each got a review at the tail of their own request; each review became a durable entry from the next step on; the budget stopped at 2. Across primary requests the system prompt and tools were byte-identical and only the newest tail message changed.
+- `observe` with an explicit `advisor()` call from the mock: no request was changed, telemetry was written, and the Advisor's prompt contained the in-flight `TOOL CALL advisor`; its advice came back as the tool result.
+- Interrupt during a consult (`POST /api/session/:id/interrupt`, Advisor reply held 20 s): `session.execution.interrupted` aborted the consult after 4.4 s, telemetry recorded `stale`, nothing was delivered, and the interrupted step sent no model request.
+- Not covered live: compaction and child sessions. `src/server.test.ts` covers child sessions and a turn surviving a compaction that drops its prompt (a request shape built by hand, not captured from the host).
+
+## Live check of the decision fixes and the TUI (2026-10-30, OpenCode 2.2.0)
+- A failing shell command reached the model as `24 passed, 1 failed\n\nExited with code 1` (tool completed, no error), and the plugin's shell-notice pattern (now `SHELL_NOTICE`) matches it. Real Jev read that step as `stuck` 0.65, `done` 0.
+- With the per-turn budget spent, the next step logged `verdict: "blocked"`, `blocked: "budget"`, without a consult.
+- After a server restart, a synthetic message (`POST /api/session/:id/synthetic`) started a run: the turn stayed the real prompt's id, read from `ctx.session.context` (`type: "user"`), not the synthetic message's.
+- TUI (`opencode --server … -s ` in a 180-column tmux): the `sidebar.footer` widget showed `Advisor ✓ done 02:50`; a click (SGR mouse sequence) and `/advisor-review` opened the panel; the advice rendered as markdown and scrolled to its last line with the wheel; `f` toggled full screen, Esc closed it; `tui.sidebar: false` hid the widget while `/advisor-review` still worked.
+
+## Re-verify recipe
+1. `bun run build`, then `bun run sandbox` in `packages/opencode-auto-advisor` (`SANDBOX_OPENCODE_VERSION` picks another version than 2.2.5, the minimum; each version is installed once under `$XDG_CACHE_HOME/opencode-auto-advisor/`). It runs in the foreground until Ctrl-C, in a new private (0700) directory with a scratch HOME. It loads this package from source with a `mock` provider (its request bodies go to `requests.jsonl`; it answers the agent with a `read` for two steps, then text, and anything without tools with text), starts `opencode serve`, and prints the URL and commands; the password is in `/env.sh`. Your own `OPENCODE_*` variables never reach the sandbox, and sourcing `env.sh` unsets them in your shell. The directory is kept after the run. Add test models to the mock (`scripts/sandbox/mock.ts`) and the config it writes as a check needs them.
+2. Drive it with `opencode run --server "" < /dev/null`, with `OPENCODE_PASSWORD` set (`source /env.sh`). Without `< /dev/null`, `run` can hang on an open stdin.
+3. A large prompt (over 128 KB, the per-argument limit) goes straight to the API: `curl -u opencode: -H 'content-type: application/json' --data-binary @body.json /api/session//prompt`, with `{"text": "…"}` in `body.json`. Read `opencode api` output from a file, never a pipe (see "Timeline on a large session").
+4. Decision calls can be sent directly: `POST /api/experimental/generate/decisions?directory=` with the same basic auth; the response wraps the result in `data`.
+5. Kill processes by pid, not `pkill -f`, which can match the shell running the command; `pgrep -f` likewise lists that shell. The sandbox stops its own processes on Ctrl-C.
+6. After an OpenCode upgrade, check that `compactContext`'s strings in `src/transcript.ts` still match `core/src/codemode/instructions.ts`, `mcp/instructions.ts` and `skill/instructions.ts`: run one explicit `advisor()` consult and confirm the Advisor's prompt has the three `[context-omitted: …]` markers. A mismatch only stops the compaction.
+7. Also check `SHELL_NOTICE` in `src/transcript.ts` against `core/src/shell/result.ts` (`Exited with code N`, `Killed by …`, `Timed out before completion`), that `tool/plugin/shell.ts` still emits the notice as its own text part, that `plugin/tool-input-repair.ts` still aliases only `bash` to `shell`, and that `to-llm-message.ts` still lowers synthetic and shell messages to `role: "user"` while `ctx.session.context` keeps their `type`. A mismatch hides failed commands from the Decision model.
+
+## Live check of round 3 (2026-10-31, OpenCode 2.2.0)
+- Advisor chain `["dead/x" (port 9), "mock/overflow", "mock/silent", "mock/keepalive"]`, provider `headerTimeout`/`chunkTimeout` 1500 ms, `advisor.timeoutMs` -1:
+ - `dead/x`: `ProviderRequestError (provider)` after 12.5 s, because the host retries a refused connection (`retryOneShot`).
+ - `mock/overflow` (HTTP 400 `context_length_exceeded`): `ProviderRequestError (invalid_request)` after 33 ms; moved on.
+ - `mock/silent` (headers, then nothing): ended by the chunk timeout after 1.5 s; moved on.
+ - `mock/keepalive` (SSE comments for 4 s, then text): answered after 4.2 s, so keep-alives keep a slow model alive past `chunkTimeout`. The panel and telemetry named `mock/keepalive`.
+- A long plan prompt (8,266 chars), then `opencode run --session … "Leave .slim/deepwork/ unignored."`: the second turn's Decision input held the whole plan and the correction (8,372 chars of requests); the turn was the correction's id.
+- Real `opencode/jev-1.13-free` with a 300,180-char input: `ProviderRequestError (invalid_request)` in 296 ms, and the router moved to the next model. A decision model declared in config (`"type": "decision"` under `providers.openrouter.models`) resolved as `Generate.UnsupportedModelError`, so a mock decision model could not be tested. That is not true of every OpenRouter model: in a real 2.2.5 setup (2026-11-01), `openrouter/typesafe/jev-1.14` answered every step as the first decision model, in 150–315 ms. Why the sandbox's declared model was refused is not established.
+- TUI: the sidebar widget is one line (`Advisor ✓ approach 11:03`); a click opened the panel.
+- `bun run timeline ` read the telemetry and `opencode api get …/message` and wrote the page; each step listed the messages since the previous one. These sessions were small; for large ones see below.
+
+## Recheck on OpenCode 2.2.5 (2026-10-31)
+- Diffed v2.2.0..v2.2.5 for every host file the plugin relies on.
+ - **Changed:** the Code Mode catalog text (`core/src/codemode/instructions.ts`). It now starts "The tools listed below… cannot be called directly", and its header is now `## Code Mode tools (call only inside \`execute\`)`. With the 2.2.0 strings, compaction of that catalog silently stopped. The plugin now requires 2.2.5 and matches the new text.
+ - **Unchanged:**
+ - shell notices (`shell/result.ts`);
+ - the subagent wrapper;
+ - the `prompt` hook;
+ - message ids and the lowering of user, synthetic, skill and shell messages;
+ - the generate errors, `retryOneShot`, and the header and chunk timeouts;
+ - the message list API (a cursor still can't be combined with `order`);
+ - the event stream closing after a read error;
+ - the MCP and skill catalog text.
+ - **New, not used yet:** the instructions system part keeps each source as `metadata.experimental.instructions` (`{key, text}`).
+ - **Also new:** a tool call named `bash` runs `shell` but keeps the name `bash`.
+- Live, on `@opencode/cli@2.2.5`, with a probe MCP server (Code Mode on) and a probe skill:
+ - The Advisor prompt had all three `[context-omitted …]` markers and no raw Code Mode, MCP or skill catalog text.
+ - A shell command that exited 3 reached the hook as a two-part content result (`(no output)`, `Exited with code 3`) and counted as failed.
+ - A `read` of a file containing `Exited with code 1` counted as ok (`Latest tool result: ok`). The 2.2.5 `read` argument is `path`.
+ - TUI (OpenTUI 0.8): the one-line widget showed `Advisor ✓ stuck 15:28`, a click opened the panel, and the markdown advice rendered.
+
+## Timeline on a large session (2026-11-01, OpenCode 2.2.5)
+- In a real session, `bun run timeline` failed with `JSON Parse error: Unterminated string`. The CLI writes the response and then calls `process.exit` (`packages/cli/src/index.ts`), so when stdout is a pipe, output still being written is lost.
+- Real CLI, a 3.2 MB message page, read 10 times through `Bun.spawnSync`: through a pipe it arrived at 877 KB, 1.1 MB, 2.0 MB or whole; written to a file it arrived whole every time. A 350 KB response arrived whole either way.
+- 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.
+
+## 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).
+- `opencode-ram-monitor` builds its modal with `insert(markdown, [])`, the call that blanked this plugin's panel. On 2.2.0 the `/ram` modal still renders its text (checked live 2026-10-31), because its content changes once, when the async load finishes after the insert, which redraws the blocks; not changed.
diff --git a/packages/opencode-auto-advisor/AGENTS.md b/packages/opencode-auto-advisor/AGENTS.md
new file mode 100644
index 0000000..cbbdfd2
--- /dev/null
+++ b/packages/opencode-auto-advisor/AGENTS.md
@@ -0,0 +1,59 @@
+# opencode-auto-advisor — OpenCode plugin
+
+**Technology**: TypeScript / OpenCode V2 plugin (server + TUI)
+**Entry Points**: `src/server.ts` (server), `src/tui.ts` (TUI)
+**Parent Context**: This extends [../../AGENTS.md](../../AGENTS.md)
+**Compatibility**: OpenCode >= 2.2.5 (2.x line, `latest`). No V1 line.
+
+## Quick Reference
+
+| Script | Purpose |
+| ------------------- | ----------------------------------------- |
+| `bun run check` | Lint and format (Biome) |
+| `bun run lint` | Validate without modifying |
+| `bun run typecheck` | Type check (`src`, then `scripts`) |
+| `bun test` | Run tests |
+| `bun run build` | Declarations (tsc) + bundles (bun build) |
+| `bun run smoke` | Build, then check both built entries load |
+| `bun run sandbox` | Isolated OpenCode + mock, for live checks |
+
+## 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/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/rpc.ts` is the contract both halves import.
+
+### Gotchas
+- **Never edit `event.system`.** It breaks the provider's prompt cache. Reviews go to the tail of `event.messages` as a `role: "system"` message and to the `auto-advisor` instruction entry.
+- **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.
+- **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.
+- **The cooldown counts distinct steps.** A retry re-fires the `context` hook with the same `stateKey`; it is skipped, not counted.
+- **`generate.text` without a model uses the global default**, not the agent's. Always pass the agent's `event.model` for "inherit".
+- **A model ref without a variant gets no reasoning overlay** (the provider's default), not the agent's variant. `event.model` carries the agent's variant, so "inherit" keeps it; an unknown `#variant` fails the call with `InvalidRequestError` (`field: model.variant`).
+- **`compactContext` matches OpenCode 2.2.5's rendered text**, not keys. 2.2.0 to 2.2.4 joined the instruction sources into one system part with no keys; 2.2.5 also keeps them as `metadata.experimental.instructions` (`{key, text}`; keys `core/codemode`, `core/mcp-guidance`, `core/skill-guidance`), still experimental, so text matching stays until that settles. 2.2.5 reworded the Code Mode catalog, which silently stopped its compaction until the strings were updated. The start and end strings come from `core/src/codemode/instructions.ts`, `mcp/instructions.ts` and `skill/instructions.ts`. A block is replaced only when its end comes before the next copy's start, so a wording change in the host costs tokens, never other context. Only full renders are matched; the host's incremental notices ("New skills are available…" and the like) are kept, at their small cost. Recheck the strings after an OpenCode upgrade.
+- **Decision errors are plain objects with `_tag`.** A provider without credentials reports `Generate.ModelNotFoundError`, not an auth error. Shapes are in `src/fixtures/s1-decisions.json`.
+- **The host retries rate limits and unsent requests itself** (`retryOneShot`, `core/src/model-retry.ts`): a refused connection takes about 12 s to fail. Neither chain retries; each skips a throttled or timed-out model for 60 s. Host header and chunk timeouts arrive as `ProviderRequestError` reason `provider` and get no skip; the Advisor chain has no plugin deadline by default, so they bound a silent model.
+- **A field-less `Generate.InvalidRequestError` is a provider rejection without an HTTP status** (mid-stream, an unsupported operation, or a request the protocol could not encode; `core/src/generate.ts` `providerError`); with a `field` it is a config error. Only the latter stops a chain.
+- **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.
+- **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.
+
+## Testing Guidelines
+
+- Location: colocated (`src/*.test.ts`, `scripts/*.test.ts`). Test files are not typechecked (no package's are); `scripts/tsconfig.json` checks the other scripts. `bunfig.toml` preloads `@opentui/solid/preload` for the TUI tests.
+- Tests set `OPENCODE_CONFIG_DIR` and `XDG_DATA_HOME` to temp dirs; never read the real `HOME`.
+- `src/server.test.ts` drives a fake host context. Request shapes match what a real 2.2.x `context` hook delivers.
+- Hook, delivery or protocol changes need a live check against real 2.2.5 (the minimum): `bun run build && bun run sandbox`. The recipe and verified behavior are in [the findings doc](../../docs/auto-advisor/findings.md).
diff --git a/packages/opencode-auto-advisor/LICENSE.txt b/packages/opencode-auto-advisor/LICENSE.txt
new file mode 100644
index 0000000..d0a1fa1
--- /dev/null
+++ b/packages/opencode-auto-advisor/LICENSE.txt
@@ -0,0 +1,373 @@
+Mozilla Public License Version 2.0
+==================================
+
+1. Definitions
+--------------
+
+1.1. "Contributor"
+ means each individual or legal entity that creates, contributes to
+ the creation of, or owns Covered Software.
+
+1.2. "Contributor Version"
+ means the combination of the Contributions of others (if any) used
+ by a Contributor and that particular Contributor's Contribution.
+
+1.3. "Contribution"
+ means Covered Software of a particular Contributor.
+
+1.4. "Covered Software"
+ means Source Code Form to which the initial Contributor has attached
+ the notice in Exhibit A, the Executable Form of such Source Code
+ Form, and Modifications of such Source Code Form, in each case
+ including portions thereof.
+
+1.5. "Incompatible With Secondary Licenses"
+ means
+
+ (a) that the initial Contributor has attached the notice described
+ in Exhibit B to the Covered Software; or
+
+ (b) that the Covered Software was made available under the terms of
+ version 1.1 or earlier of the License, but not also under the
+ terms of a Secondary License.
+
+1.6. "Executable Form"
+ means any form of the work other than Source Code Form.
+
+1.7. "Larger Work"
+ means a work that combines Covered Software with other material, in
+ a separate file or files, that is not Covered Software.
+
+1.8. "License"
+ means this document.
+
+1.9. "Licensable"
+ means having the right to grant, to the maximum extent possible,
+ whether at the time of the initial grant or subsequently, any and
+ all of the rights conveyed by this License.
+
+1.10. "Modifications"
+ means any of the following:
+
+ (a) any file in Source Code Form that results from an addition to,
+ deletion from, or modification of the contents of Covered
+ Software; or
+
+ (b) any new file in Source Code Form that contains any Covered
+ Software.
+
+1.11. "Patent Claims" of a Contributor
+ means any patent claim(s), including without limitation, method,
+ process, and apparatus claims, in any patent Licensable by such
+ Contributor that would be infringed, but for the grant of the
+ License, by the making, using, selling, offering for sale, having
+ made, import, or transfer of either its Contributions or its
+ Contributor Version.
+
+1.12. "Secondary License"
+ means either the GNU General Public License, Version 2.0, the GNU
+ Lesser General Public License, Version 2.1, the GNU Affero General
+ Public License, Version 3.0, or any later versions of those
+ licenses.
+
+1.13. "Source Code Form"
+ means the form of the work preferred for making modifications.
+
+1.14. "You" (or "Your")
+ means an individual or a legal entity exercising rights under this
+ License. For legal entities, "You" includes any entity that
+ controls, is controlled by, or is under common control with You. For
+ purposes of this definition, "control" means (a) the power, direct
+ or indirect, to cause the direction or management of such entity,
+ whether by contract or otherwise, or (b) ownership of more than
+ fifty percent (50%) of the outstanding shares or beneficial
+ ownership of such entity.
+
+2. License Grants and Conditions
+--------------------------------
+
+2.1. Grants
+
+Each Contributor hereby grants You a world-wide, royalty-free,
+non-exclusive license:
+
+(a) under intellectual property rights (other than patent or trademark)
+ Licensable by such Contributor to use, reproduce, make available,
+ modify, display, perform, distribute, and otherwise exploit its
+ Contributions, either on an unmodified basis, with Modifications, or
+ as part of a Larger Work; and
+
+(b) under Patent Claims of such Contributor to make, use, sell, offer
+ for sale, have made, import, and otherwise transfer either its
+ Contributions or its Contributor Version.
+
+2.2. Effective Date
+
+The licenses granted in Section 2.1 with respect to any Contribution
+become effective for each Contribution on the date the Contributor first
+distributes such Contribution.
+
+2.3. Limitations on Grant Scope
+
+The licenses granted in this Section 2 are the only rights granted under
+this License. No additional rights or licenses will be implied from the
+distribution or licensing of Covered Software under this License.
+Notwithstanding Section 2.1(b) above, no patent license is granted by a
+Contributor:
+
+(a) for any code that a Contributor has removed from Covered Software;
+ or
+
+(b) for infringements caused by: (i) Your and any other third party's
+ modifications of Covered Software, or (ii) the combination of its
+ Contributions with other software (except as part of its Contributor
+ Version); or
+
+(c) under Patent Claims infringed by Covered Software in the absence of
+ its Contributions.
+
+This License does not grant any rights in the trademarks, service marks,
+or logos of any Contributor (except as may be necessary to comply with
+the notice requirements in Section 3.4).
+
+2.4. Subsequent Licenses
+
+No Contributor makes additional grants as a result of Your choice to
+distribute the Covered Software under a subsequent version of this
+License (see Section 10.2) or under the terms of a Secondary License (if
+permitted under the terms of Section 3.3).
+
+2.5. Representation
+
+Each Contributor represents that the Contributor believes its
+Contributions are its original creation(s) or it has sufficient rights
+to grant the rights to its Contributions conveyed by this License.
+
+2.6. Fair Use
+
+This License is not intended to limit any rights You have under
+applicable copyright doctrines of fair use, fair dealing, or other
+equivalents.
+
+2.7. Conditions
+
+Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
+in Section 2.1.
+
+3. Responsibilities
+-------------------
+
+3.1. Distribution of Source Form
+
+All distribution of Covered Software in Source Code Form, including any
+Modifications that You create or to which You contribute, must be under
+the terms of this License. You must inform recipients that the Source
+Code Form of the Covered Software is governed by the terms of this
+License, and how they can obtain a copy of this License. You may not
+attempt to alter or restrict the recipients' rights in the Source Code
+Form.
+
+3.2. Distribution of Executable Form
+
+If You distribute Covered Software in Executable Form then:
+
+(a) such Covered Software must also be made available in Source Code
+ Form, as described in Section 3.1, and You must inform recipients of
+ the Executable Form how they can obtain a copy of such Source Code
+ Form by reasonable means in a timely manner, at a charge no more
+ than the cost of distribution to the recipient; and
+
+(b) You may distribute such Executable Form under the terms of this
+ License, or sublicense it under different terms, provided that the
+ license for the Executable Form does not attempt to limit or alter
+ the recipients' rights in the Source Code Form under this License.
+
+3.3. Distribution of a Larger Work
+
+You may create and distribute a Larger Work under terms of Your choice,
+provided that You also comply with the requirements of this License for
+the Covered Software. If the Larger Work is a combination of Covered
+Software with a work governed by one or more Secondary Licenses, and the
+Covered Software is not Incompatible With Secondary Licenses, this
+License permits You to additionally distribute such Covered Software
+under the terms of such Secondary License(s), so that the recipient of
+the Larger Work may, at their option, further distribute the Covered
+Software under the terms of either this License or such Secondary
+License(s).
+
+3.4. Notices
+
+You may not remove or alter the substance of any license notices
+(including copyright notices, patent notices, disclaimers of warranty,
+or limitations of liability) contained within the Source Code Form of
+the Covered Software, except that You may alter any license notices to
+the extent required to remedy known factual inaccuracies.
+
+3.5. Application of Additional Terms
+
+You may choose to offer, and to charge a fee for, warranty, support,
+indemnity or liability obligations to one or more recipients of Covered
+Software. However, You may do so only on Your own behalf, and not on
+behalf of any Contributor. You must make it absolutely clear that any
+such warranty, support, indemnity, or liability obligation is offered by
+You alone, and You hereby agree to indemnify every Contributor for any
+liability incurred by such Contributor as a result of warranty, support,
+indemnity or liability terms You offer. You may include additional
+disclaimers of warranty and limitations of liability specific to any
+jurisdiction.
+
+4. Inability to Comply Due to Statute or Regulation
+---------------------------------------------------
+
+If it is impossible for You to comply with any of the terms of this
+License with respect to some or all of the Covered Software due to
+statute, judicial order, or regulation then You must: (a) comply with
+the terms of this License to the maximum extent possible; and (b)
+describe the limitations and the code they affect. Such description must
+be placed in a text file included with all distributions of the Covered
+Software under this License. Except to the extent prohibited by statute
+or regulation, such description must be sufficiently detailed for a
+recipient of ordinary skill to be able to understand it.
+
+5. Termination
+--------------
+
+5.1. The rights granted under this License will terminate automatically
+if You fail to comply with any of its terms. However, if You become
+compliant, then the rights granted under this License from a particular
+Contributor are reinstated (a) provisionally, unless and until such
+Contributor explicitly and finally terminates Your grants, and (b) on an
+ongoing basis, if such Contributor fails to notify You of the
+non-compliance by some reasonable means prior to 60 days after You have
+come back into compliance. Moreover, Your grants from a particular
+Contributor are reinstated on an ongoing basis if such Contributor
+notifies You of the non-compliance by some reasonable means, this is the
+first time You have received notice of non-compliance with this License
+from such Contributor, and You become compliant prior to 30 days after
+Your receipt of the notice.
+
+5.2. If You initiate litigation against any entity by asserting a patent
+infringement claim (excluding declaratory judgment actions,
+counter-claims, and cross-claims) alleging that a Contributor Version
+directly or indirectly infringes any patent, then the rights granted to
+You by any and all Contributors for the Covered Software under Section
+2.1 of this License shall terminate.
+
+5.3. In the event of termination under Sections 5.1 or 5.2 above, all
+end user license agreements (excluding distributors and resellers) which
+have been validly granted by You or Your distributors under this License
+prior to termination shall survive termination.
+
+************************************************************************
+* *
+* 6. Disclaimer of Warranty *
+* ------------------------- *
+* *
+* Covered Software is provided under this License on an "as is" *
+* basis, without warranty of any kind, either expressed, implied, or *
+* statutory, including, without limitation, warranties that the *
+* Covered Software is free of defects, merchantable, fit for a *
+* particular purpose or non-infringing. The entire risk as to the *
+* quality and performance of the Covered Software is with You. *
+* Should any Covered Software prove defective in any respect, You *
+* (not any Contributor) assume the cost of any necessary servicing, *
+* repair, or correction. This disclaimer of warranty constitutes an *
+* essential part of this License. No use of any Covered Software is *
+* authorized under this License except under this disclaimer. *
+* *
+************************************************************************
+
+************************************************************************
+* *
+* 7. Limitation of Liability *
+* -------------------------- *
+* *
+* Under no circumstances and under no legal theory, whether tort *
+* (including negligence), contract, or otherwise, shall any *
+* Contributor, or anyone who distributes Covered Software as *
+* permitted above, be liable to You for any direct, indirect, *
+* special, incidental, or consequential damages of any character *
+* including, without limitation, damages for lost profits, loss of *
+* goodwill, work stoppage, computer failure or malfunction, or any *
+* and all other commercial damages or losses, even if such party *
+* shall have been informed of the possibility of such damages. This *
+* limitation of liability shall not apply to liability for death or *
+* personal injury resulting from such party's negligence to the *
+* extent applicable law prohibits such limitation. Some *
+* jurisdictions do not allow the exclusion or limitation of *
+* incidental or consequential damages, so this exclusion and *
+* limitation may not apply to You. *
+* *
+************************************************************************
+
+8. Litigation
+-------------
+
+Any litigation relating to this License may be brought only in the
+courts of a jurisdiction where the defendant maintains its principal
+place of business and such litigation shall be governed by laws of that
+jurisdiction, without reference to its conflict-of-law provisions.
+Nothing in this Section shall prevent a party's ability to bring
+cross-claims or counter-claims.
+
+9. Miscellaneous
+----------------
+
+This License represents the complete agreement concerning the subject
+matter hereof. If any provision of this License is held to be
+unenforceable, such provision shall be reformed only to the extent
+necessary to make it enforceable. Any law or regulation which provides
+that the language of a contract shall be construed against the drafter
+shall not be used to construe this License against a Contributor.
+
+10. Versions of the License
+---------------------------
+
+10.1. New Versions
+
+Mozilla Foundation is the license steward. Except as provided in Section
+10.3, no one other than the license steward has the right to modify or
+publish new versions of this License. Each version will be given a
+distinguishing version number.
+
+10.2. Effect of New Versions
+
+You may distribute the Covered Software under the terms of the version
+of the License under which You originally received the Covered Software,
+or under the terms of any subsequent version published by the license
+steward.
+
+10.3. Modified Versions
+
+If you create software not governed by this License, and you want to
+create a new license for such software, you may create and use a
+modified version of this License if you rename the license and remove
+any references to the name of the license steward (except to note that
+such modified license differs from this License).
+
+10.4. Distributing Source Code Form that is Incompatible With Secondary
+Licenses
+
+If You choose to distribute Source Code Form that is Incompatible With
+Secondary Licenses under the terms of this version of the License, the
+notice described in Exhibit B of this License must be attached.
+
+Exhibit A - Source Code Form License Notice
+-------------------------------------------
+
+ This Source Code Form is subject to the terms of the Mozilla Public
+ License, v. 2.0. If a copy of the MPL was not distributed with this
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+If it is not possible or desirable to put the notice in a particular
+file, then You may include the notice in a location (such as a LICENSE
+file in a relevant directory) where a recipient would be likely to look
+for such a notice.
+
+You may add additional accurate notices of copyright ownership.
+
+Exhibit B - "Incompatible With Secondary Licenses" Notice
+---------------------------------------------------------
+
+ This Source Code Form is "Incompatible With Secondary Licenses", as
+ defined by the Mozilla Public License, v. 2.0.
diff --git a/packages/opencode-auto-advisor/README.md b/packages/opencode-auto-advisor/README.md
new file mode 100644
index 0000000..f96b14f
--- /dev/null
+++ b/packages/opencode-auto-advisor/README.md
@@ -0,0 +1,129 @@
+# opencode-auto-advisor
+
+A Claude Code-style Advisor for OpenCode: a second model, ideally a stronger one, reviews your agent's work when it asks, or automatically at the moments that matter.
+
+
+
+
+
+
+
+---
+
+## Why?
+
+> Most turns of a coding task are routine, but a few decide the outcome: committing to an approach, getting stuck on the same failure, declaring the work done. Claude Code's [Advisor](https://code.claude.com/docs/en/advisor) lets the agent consult a stronger model at those moments. This plugin brings the same idea to OpenCode, and adds a way to catch those moments even when the agent doesn't ask.
+
+## Philosophy: Extending OpenCode
+
+OpenCode is designed to be highly extensible. This plugin uses only public V2 plugin APIs: a tool, the `context` hook, host-resolved [Decision models](https://opencode.ai/docs/en/zen/#jev), stateless generation, and session instruction entries. It never edits the system prompt, so it doesn't break the provider's prompt cache.
+
+### Architecture
+
+```mermaid
+flowchart LR
+ E[Executor step] --> D{Decision model:
moment + stakes}
+ D -- routine or low stakes --> E
+ D -- approach / stuck / done --> A[Advisor model]
+ E -- "advisor()" --> A
+ A -- advice --> E
+```
+
+Two paths reach the same stateless Advisor. The agent can call the `advisor()` tool itself. In `active` mode, a fast Decision model also reads each step, and the plugin consults the Advisor when the agent is at one of Claude Code's three moments and a wrong next step would cost something.
+
+## Features
+
+- **Explicit `advisor()` tool**: zero arguments. The Advisor reads the agent's system prompt, tool list and transcript, and returns concise, actionable advice as the tool result.
+- **Automatic reviews (on by default)**: a Decision model classifies each step as `approach`, `stuck`, `done` or `routine`, and scores how costly a mistake would be (0–4). The plugin consults the Advisor when both pass your thresholds.
+- **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.
+
+## Install
+
+### OpenCode V2
+
+Requires OpenCode **2.2.5** or later.
+
+This plugin has a Server component + TUI component.
+
+Add one entry under the `"plugins"` key in `opencode.json(c)`:
+
+```json
+{
+ "plugins": ["@capybearista/opencode-auto-advisor@latest"]
+}
+```
+
+The TUI half loads automatically, so `cli.json` needs no entry.
+
+### OpenCode V1
+
+Not available. The plugin depends on OpenCode 2.2's Decision and instruction-entry APIs, and on the 2.2.5 host text it compacts.
+
+### Uninstall
+
+Remove the entry from `"plugins"` in `opencode.json(c)`. Optionally delete `~/.config/opencode/auto-advisor.json` and the telemetry folder `~/.local/share/opencode/auto-advisor/`.
+
+## 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 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.
+
+## 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.
+
+```json
+{
+ "advisor": { "model": "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.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.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. |
+| `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.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.
+- **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.
+- **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.
+
+## 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.
+- **"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.
+
+## Contributing
+
+This package lives in the `opencode-plugins` monorepo. See the [contribution guidelines](../../CONTRIBUTING.md) for setup, the full check suite, and releases, and open or find an issue before a pull request.
+
+From this directory, `bun test` runs the package tests, `bun run smoke` checks the built package, and `bun run sandbox` runs an isolated OpenCode with this package and a mock model, for live checks. To try a change in your own OpenCode, see [Testing in OpenCode](../../CONTRIBUTING.md#testing-in-opencode).
+
+## License
+
+[MPL-2.0](./LICENSE.txt)
diff --git a/packages/opencode-auto-advisor/bunfig.toml b/packages/opencode-auto-advisor/bunfig.toml
new file mode 100644
index 0000000..b16283c
--- /dev/null
+++ b/packages/opencode-auto-advisor/bunfig.toml
@@ -0,0 +1,4 @@
+preload = ["@opentui/solid/preload"]
+
+[test]
+preload = ["@opentui/solid/preload"]
diff --git a/packages/opencode-auto-advisor/package.json b/packages/opencode-auto-advisor/package.json
new file mode 100644
index 0000000..df0df09
--- /dev/null
+++ b/packages/opencode-auto-advisor/package.json
@@ -0,0 +1,65 @@
+{
+ "name": "@capybearista/opencode-auto-advisor",
+ "version": "2.0.0",
+ "description": "A Claude Code-style Advisor for OpenCode: a second model reviews the agent's work on request or automatically at decision points.",
+ "type": "module",
+ "license": "MPL-2.0",
+ "author": "capyBearista ",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/capyBearista/opencode-plugins.git",
+ "directory": "packages/opencode-auto-advisor"
+ },
+ "exports": {
+ "./server": {
+ "types": "./dist/server.d.ts",
+ "default": "./dist/server.js"
+ },
+ "./tui": {
+ "types": "./dist/tui.d.ts",
+ "default": "./dist/tui.js"
+ }
+ },
+ "files": [
+ "dist",
+ "server.js",
+ "tui.js"
+ ],
+ "scripts": {
+ "build": "rm -rf dist && tsc --emitDeclarationOnly && bun build src/server.ts src/tui.ts --outdir=dist --target=bun --external '@opencode/*' --external '@opentui/*' --external 'solid-js'",
+ "typecheck": "tsc --noEmit && tsc -p scripts",
+ "lint": "bunx @biomejs/biome check .",
+ "check": "bunx @biomejs/biome check --write .",
+ "ci": "bunx @biomejs/biome ci .",
+ "test": "bun test",
+ "smoke": "bun run build && bun scripts/smoke-built.ts",
+ "timeline": "bun scripts/timeline.ts",
+ "sandbox": "bun scripts/sandbox.ts"
+ },
+ "peerDependencies": {
+ "@opencode/plugin": ">=2.2.5 <3",
+ "@opentui/core": ">=0.8.0",
+ "@opentui/solid": ">=0.8.0",
+ "solid-js": ">=1.9.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentui/core": {
+ "optional": true
+ },
+ "@opentui/solid": {
+ "optional": true
+ },
+ "solid-js": {
+ "optional": true
+ }
+ },
+ "devDependencies": {
+ "@opencode/plugin": "2.2.0",
+ "@opentui/core": "0.7.0",
+ "@opentui/solid": "0.7.0",
+ "@types/bun": "latest",
+ "@types/node": "^26.6.5",
+ "solid-js": "1.9.17",
+ "typescript": "^7.0.2"
+ }
+}
diff --git a/packages/opencode-auto-advisor/scripts/fixtures/fake-opencode.ts b/packages/opencode-auto-advisor/scripts/fixtures/fake-opencode.ts
new file mode 100644
index 0000000..b138d58
--- /dev/null
+++ b/packages/opencode-auto-advisor/scripts/fixtures/fake-opencode.ts
@@ -0,0 +1,23 @@
+/**
+ * Stands in for `opencode api get ` in tests. Like OpenCode's CLI (2.2.5,
+ * `packages/cli/src/index.ts`), it writes the response and exits at once, which cuts a large
+ * response short when stdout is a pipe. Two pages; like the server, it rejects a cursor combined
+ * with `order`.
+ */
+const path = process.argv.at(-1) ?? "";
+const query = new URL(path, "http://x").searchParams;
+const message = (id: string) => ({ id, type: "user", text: `${id} ${"x".repeat(1_000_000)}` });
+let page: object;
+if (query.has("cursor") && query.has("order")) {
+ process.stderr.write("Cursor cannot be combined with order\n");
+ process.exit(1);
+} else if (query.get("cursor") === "page2") {
+ page = { data: [message("m4"), message("m5"), message("m6")] };
+} else if (query.get("order") === "asc") {
+ page = { data: [message("m1"), message("m2"), message("m3")], cursor: { next: "page2" } };
+} else {
+ process.stderr.write(`unexpected request: ${path}\n`);
+ process.exit(1);
+}
+process.stdout.write(`${JSON.stringify(page)}\n`);
+process.exit(0);
diff --git a/packages/opencode-auto-advisor/scripts/sandbox.ts b/packages/opencode-auto-advisor/scripts/sandbox.ts
new file mode 100644
index 0000000..8fc2032
--- /dev/null
+++ b/packages/opencode-auto-advisor/scripts/sandbox.ts
@@ -0,0 +1,195 @@
+/**
+ * An isolated OpenCode with this package loaded from source and a mock model provider, for the
+ * live checks in docs/auto-advisor/findings.md. Run `bun run build` first: OpenCode loads `dist/`.
+ *
+ * bun run sandbox start it in the foreground; Ctrl-C stops it
+ *
+ * Each run gets a new private (0700) directory: HOME and XDG directories, the project,
+ * `requests.jsonl` (every request the mock received), `serve.log` and `env.sh`. It is kept after
+ * the run for inspection. The server password stays in that directory (and the server's own
+ * log), since the server runs tools as you. Inherited `OPENCODE_*` variables are dropped, so a
+ * profile you have set (`OPENCODE_CONFIG_DIR` and the like) never replaces the sandbox's. The
+ * CLI (`SANDBOX_OPENCODE_VERSION`, default 2.2.5) is installed once per version under
+ * `$XDG_CACHE_HOME/opencode-auto-advisor/`.
+ */
+import { spawnSync } from "node:child_process";
+import {
+ closeSync,
+ existsSync,
+ mkdirSync,
+ mkdtempSync,
+ openSync,
+ readFileSync,
+ writeFileSync,
+} from "node:fs";
+import { homedir, tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import type { Subprocess } from "bun";
+
+const PACKAGE = resolve(import.meta.dir, "..");
+const VERSION = process.env.SANDBOX_OPENCODE_VERSION ?? "2.2.5";
+
+/** POSIX single quotes: nothing inside is expanded when `env.sh` is sourced or a command pasted. */
+const quote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`;
+
+function installCli(): string {
+ const cache = process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache");
+ const prefix = join(cache, "opencode-auto-advisor", `cli-${VERSION}`);
+ const bin = join(prefix, "node_modules", ".bin");
+ if (!existsSync(join(bin, "opencode"))) {
+ const install = spawnSync("npm", ["install", "--prefix", prefix, `@opencode/cli@${VERSION}`], {
+ stdio: "inherit",
+ });
+ if (install.status !== 0) throw new Error("npm install @opencode/cli failed");
+ }
+ return bin;
+}
+
+const dir = mkdtempSync(join(tmpdir(), "auto-advisor-sandbox-"));
+const home = join(dir, "home");
+const env: Record = {
+ HOME: home,
+ XDG_CONFIG_HOME: join(home, ".config"),
+ XDG_DATA_HOME: join(home, ".local", "share"),
+ XDG_STATE_HOME: join(home, ".local", "state"),
+ XDG_CACHE_HOME: join(home, ".cache"),
+ PATH: `${installCli()}:${process.env.PATH}`,
+};
+const inherited = Object.entries(process.env).filter(
+ (entry): entry is [string, string] => !entry[0].startsWith("OPENCODE_") && entry[1] !== undefined,
+);
+const childEnv = { ...Object.fromEntries(inherited), ...env };
+const config = join(env.XDG_CONFIG_HOME, "opencode");
+const project = join(dir, "project");
+for (const path of [config, env.XDG_DATA_HOME, env.XDG_STATE_HOME, env.XDG_CACHE_HOME, project]) {
+ mkdirSync(path, { recursive: true });
+}
+
+const children: Subprocess[] = [];
+let stopping: Promise | undefined;
+
+function start(command: string[], cwd: string, log: string): Subprocess {
+ // A signal during startup must not leave a child that shutdown never saw.
+ if (stopping) throw new Error("stopping");
+ const out = openSync(log, "w");
+ const child = Bun.spawn(command, {
+ cwd,
+ env: childEnv,
+ stdin: "ignore",
+ stdout: out,
+ stderr: out,
+ });
+ closeSync(out);
+ children.push(child);
+ return child;
+}
+
+async function waitFor(what: string, child: Subprocess, read: () => T | undefined): Promise {
+ for (let i = 0; i < 600; i++) {
+ const value = read();
+ if (value !== undefined) return value;
+ if (child.exitCode !== null || child.signalCode !== null) {
+ throw new Error(`${what} exited (${child.exitCode ?? child.signalCode})`);
+ }
+ await Bun.sleep(100);
+ }
+ throw new Error(`timed out waiting for ${what}`);
+}
+
+/** Stops every child and waits for it (SIGKILL after 10 s), then exits. Runs once. */
+function shutdown(code: number): Promise {
+ stopping ??= (async () => {
+ for (const child of children) child.kill("SIGTERM");
+ const exited = Promise.all(children.map((child) => child.exited));
+ if (!(await Promise.race([exited.then(() => true), Bun.sleep(10_000).then(() => false)]))) {
+ for (const child of children) child.kill("SIGKILL");
+ }
+ process.exit(code);
+ })();
+ return stopping;
+}
+process.on("SIGINT", () => void shutdown(130));
+process.on("SIGTERM", () => void shutdown(143));
+
+try {
+ const portFile = join(dir, "mock.port");
+ const mock = start(
+ ["bun", join(import.meta.dir, "sandbox", "mock.ts"), portFile, join(dir, "requests.jsonl")],
+ dir,
+ join(dir, "mock.log"),
+ );
+ const mockPort = await waitFor("the mock", mock, () =>
+ existsSync(portFile) ? readFileSync(portFile, "utf8").trim() || undefined : undefined,
+ );
+
+ const model = {
+ capabilities: { tools: true, input: ["text"], output: ["text"] },
+ limit: { context: 200_000, output: 4_096 },
+ };
+ writeFileSync(
+ join(config, "opencode.json"),
+ JSON.stringify(
+ {
+ plugins: [PACKAGE],
+ model: "mock/exec",
+ providers: {
+ mock: {
+ package: "@opencode/ai/providers/openai-compatible",
+ settings: { baseURL: `http://127.0.0.1:${mockPort}/v1`, apiKey: "mock" },
+ models: { exec: model },
+ },
+ },
+ },
+ null,
+ 1,
+ ),
+ );
+ writeFileSync(
+ join(config, "auto-advisor.json"),
+ JSON.stringify({ advisor: { model: "mock/exec" } }, null, 1),
+ );
+ writeFileSync(join(project, "notes.txt"), "Sandbox notes.\n");
+
+ const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
+ const port = listener.port;
+ listener.stop(true);
+ const serveLog = join(dir, "serve.log");
+ const serve = start(["opencode", "serve", "--port", String(port)], project, serveLog);
+ const password = await waitFor("opencode serve", serve, () =>
+ existsSync(serveLog)
+ ? /server password (\S+)/.exec(readFileSync(serveLog, "utf8"))?.[1]
+ : undefined,
+ );
+
+ const url = `http://127.0.0.1:${port}`;
+ const exports = { ...env, OPENCODE_PASSWORD: password };
+ // Sourced into the caller's shell, so it also clears the caller's own OpenCode variables.
+ writeFileSync(
+ join(dir, "env.sh"),
+ `unset $(env | sed -n 's/^\\(OPENCODE_[A-Za-z0-9_]*\\)=.*/\\1/p')\n${Object.entries(exports)
+ .map(([name, value]) => `export ${name}=${quote(value)}`)
+ .join("\n")}\n`,
+ );
+
+ console.log(`sandbox: ${dir}
+server: ${url} (OpenCode ${VERSION}; user "opencode", password in env.sh)
+
+In another terminal:
+source ${quote(join(dir, "env.sh"))}
+cd ${quote(project)}
+opencode run --server ${url} "" < /dev/null
+ (without < /dev/null, run can wait on an open stdin)
+opencode api get /api/session --server ${url} > sessions.json
+ (read API output from a file: the CLI exits right after writing, so a large piped
+ response is cut short)
+
+Requests the mock received: ${join(dir, "requests.jsonl")}
+Ctrl-C stops the sandbox; its directory is kept.`);
+
+ const code = await Promise.race([mock.exited, serve.exited]);
+ if (!stopping) console.error(`a sandbox process exited with code ${code}; stopping`);
+ await shutdown(1);
+} catch (error) {
+ if (!stopping) console.error(error instanceof Error ? error.message : error);
+ await shutdown(1);
+}
diff --git a/packages/opencode-auto-advisor/scripts/sandbox/mock.ts b/packages/opencode-auto-advisor/scripts/sandbox/mock.ts
new file mode 100644
index 0000000..69fbebd
--- /dev/null
+++ b/packages/opencode-auto-advisor/scripts/sandbox/mock.ts
@@ -0,0 +1,58 @@
+/**
+ * The sandbox's model provider: an OpenAI-compatible chat endpoint that streams fixed answers and
+ * appends every request body to `requests.jsonl` next to this sandbox's state.
+ *
+ * A request with tools (the agent) gets a `read` of `notes.txt` for its first two steps, then
+ * text. A request without tools (the Advisor, a title) gets text.
+ *
+ * bun mock.ts
+ */
+import { appendFileSync, writeFileSync } from "node:fs";
+
+const [portFile, logFile] = process.argv.slice(2);
+if (!portFile || !logFile) throw new Error("usage: bun mock.ts ");
+
+interface Body {
+ readonly model?: string;
+ readonly messages?: readonly { readonly role: string }[];
+ readonly tools?: readonly unknown[];
+}
+
+const encoder = new TextEncoder();
+const stream = (model: string, deltas: readonly object[], finish: string) =>
+ new Response(
+ new ReadableStream({
+ start(controller) {
+ const chunk = (delta: object, finish_reason: string | null) =>
+ `data: ${JSON.stringify({ id: "mock", object: "chat.completion.chunk", model, choices: [{ index: 0, delta, finish_reason }] })}\n\n`;
+ for (const delta of deltas) controller.enqueue(encoder.encode(chunk(delta, null)));
+ controller.enqueue(encoder.encode(chunk({}, finish)));
+ controller.enqueue(encoder.encode("data: [DONE]\n\n"));
+ controller.close();
+ },
+ }),
+ { headers: { "content-type": "text/event-stream" } },
+ );
+
+const server = Bun.serve({
+ port: 0,
+ hostname: "127.0.0.1",
+ async fetch(request) {
+ const body = (await request.json().catch(() => ({}))) as Body;
+ appendFileSync(logFile, `${JSON.stringify({ at: new Date().toISOString(), body })}\n`);
+ const model = String(body.model);
+ const steps = (body.messages ?? []).filter((message) => message.role === "assistant").length;
+ if (body.tools?.length && steps < 2) {
+ const call = {
+ index: 0,
+ id: `call_${Date.now()}`,
+ type: "function",
+ function: { name: "read", arguments: JSON.stringify({ path: "notes.txt" }) },
+ };
+ return stream(model, [{ role: "assistant", tool_calls: [call] }], "tool_calls");
+ }
+ const text = body.tools?.length ? "Done." : "**Mock advice.** Check the plan before editing.";
+ return stream(model, [{ role: "assistant", content: text }], "stop");
+ },
+});
+writeFileSync(portFile, String(server.port));
diff --git a/packages/opencode-auto-advisor/scripts/smoke-built.ts b/packages/opencode-auto-advisor/scripts/smoke-built.ts
new file mode 100644
index 0000000..6a27c5b
--- /dev/null
+++ b/packages/opencode-auto-advisor/scripts/smoke-built.ts
@@ -0,0 +1,95 @@
+import "@opentui/solid/preload";
+import { basename } from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { resolve } from "@opencode/plugin/host";
+
+type PluginDefinition = {
+ readonly id: string;
+ readonly setup: (context: unknown) => Promise | unknown;
+};
+
+function assert(condition: unknown, message: string): asserts condition {
+ if (!condition) throw new Error(`smoke: ${message}`);
+}
+
+async function load(entry: string | undefined, file: string): Promise {
+ assert(entry, `no ${file} entry resolves`);
+ const url = entry.startsWith("file:") ? entry : pathToFileURL(entry).href;
+ assert(basename(fileURLToPath(url)) === file, `unexpected entry: ${entry}`);
+ const module = (await import(url)) as { default: PluginDefinition };
+ assert(typeof module.default.setup === "function", `${file} setup is not a function`);
+ return module.default;
+}
+
+const packageDirectory = fileURLToPath(new URL("..", import.meta.url));
+// One opencode.json(c) entry must resolve both halves; nothing goes in cli.json.
+const entrypoints = resolve({ directory: packageDirectory });
+
+const server = await load(entrypoints.server, "server.js");
+assert(server.id === "capybearista.opencode-auto-advisor", "server id mismatch");
+
+const registered: string[] = [];
+const registration = { dispose: async () => {}, events: { emit: () => {} } };
+const cleanup = await server.setup({
+ experimental: { generate: { decisions: async () => ({}), text: async () => ({ text: "" }) } },
+ rpc: {
+ register: async (definition: { id: string }) => {
+ registered.push(`rpc:${definition.id}`);
+ return registration;
+ },
+ },
+ tool: {
+ transform: async (fn: (editor: { add: (tool: { name: string }) => void }) => void) => {
+ fn({ add: (tool) => registered.push(`tool:${tool.name}`) });
+ return registration;
+ },
+ },
+ session: {
+ hook: async (name: string) => {
+ registered.push(`hook:${name}`);
+ return registration;
+ },
+ },
+ event: {
+ subscribe: () => ({
+ [Symbol.asyncIterator]: () => ({
+ next: () => new Promise(() => {}),
+ return: async () => ({ done: true, value: undefined }),
+ }),
+ }),
+ },
+});
+assert(
+ registered.join(",") === "rpc:auto-advisor,tool:advisor,hook:prompt,hook:context",
+ `unexpected server registrations: ${registered.join(",")}`,
+);
+assert(typeof cleanup === "function", "server setup returns no cleanup");
+await (cleanup as () => Promise)();
+
+const tui = await load(entrypoints.tui, "tui.js");
+assert(tui.id === "capybearista.opencode-auto-advisor.tui", "tui id mismatch");
+
+const slots: string[] = [];
+const commands: string[] = [];
+await tui.setup({
+ client: { rpc: () => ({ events: { on: () => () => {} } }) },
+ keymap: {
+ layer: (factory: () => { commands: Array<{ id: string }> }) => {
+ for (const command of factory().commands) commands.push(command.id);
+ },
+ },
+ ui: {
+ slot: (claim: { append: string }) => {
+ slots.push(claim.append);
+ return () => {};
+ },
+ },
+ theme: { text: {} },
+});
+assert(
+ slots.join(",") === "prompt.footer.status,sidebar.footer,session.panel",
+ `unexpected TUI slots: ${slots.join(",")}`,
+);
+assert(commands.join(",") === "advisor.review", `unexpected TUI commands: ${commands.join(",")}`);
+
+process.stdout.write("smoke: built server and TUI entries loaded and registered\n");
diff --git a/packages/opencode-auto-advisor/scripts/timeline.test.ts b/packages/opencode-auto-advisor/scripts/timeline.test.ts
new file mode 100644
index 0000000..8ebeaba
--- /dev/null
+++ b/packages/opencode-auto-advisor/scripts/timeline.test.ts
@@ -0,0 +1,138 @@
+import { describe, expect, test } from "bun:test";
+import { chmodSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { fetchMessages, renderTimeline, timelineRows } from "./timeline.js";
+
+const at = (n: number) => ({ created: 1_793_000_000_000 + n * 1_000 });
+const messages = [
+ { id: "u1", type: "user", text: "The big plan", time: at(0) },
+ {
+ id: "a1",
+ type: "assistant",
+ content: [
+ { type: "text", text: "Reading files" },
+ { type: "tool", name: "read", state: { status: "completed", input: { file: "x" } } },
+ ],
+ time: at(1),
+ },
+ { id: "u2", type: "user", text: "A small fix", time: at(2) },
+ {
+ id: "a2",
+ type: "assistant",
+ content: [{ type: "text", text: "Done now" }],
+ time: at(3),
+ },
+ { id: "y1", type: "system", text: "[auto-advisor review r1] Check the tests.", time: at(4) },
+ { id: "a3", type: "assistant", content: [{ type: "text", text: "Next" }], time: at(5) },
+];
+const record = (step: number, message: string | undefined, extra: object = {}) => ({
+ ts: new Date(1_793_000_000_000 + step * 1_000).toISOString(),
+ session: "s",
+ turn: "u2",
+ step,
+ mode: "active",
+ attempts: [],
+ verdict: "routine",
+ delivered: false,
+ ...(message ? { message, input: `input ${step}` } : {}),
+ ...extra,
+});
+
+describe("timelineRows", () => {
+ test("each step gets the messages since the previous step, in session order", () => {
+ const rows = timelineRows(
+ "s",
+ [record(2, "a2"), record(1, "a1"), { ...record(3, "a3"), session: "other" }],
+ messages,
+ );
+ expect(rows.map((row) => row.record.step)).toEqual([1, 2]);
+ expect(rows[0].messages.map((m) => m.id)).toEqual(["u1", "a1"]);
+ expect(rows[1].messages.map((m) => m.id)).toEqual(["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([[], []]);
+ });
+});
+
+describe("renderTimeline", () => {
+ test("renders verdicts, Advisor outcome, input and escaped messages", () => {
+ const rows = timelineRows(
+ "s",
+ [
+ record(1, "a1", {
+ verdict: "consult",
+ moment: { approach: 0.8, routine: 0.2 },
+ consequence: 2.5,
+ advisor: { model: "mock/exec", ms: 1200, outcome: "ok" },
+ delivered: true,
+ }),
+ record(2, "a2", { verdict: "blocked", blocked: "cooldown" }),
+ ],
+ messages,
+ );
+ const html = renderTimeline("s", rows);
+ expect(html).toStartWith("");
+ expect(html).toContain("consult");
+ expect(html).toContain("cooldown");
+ expect(html).toContain("mock/exec");
+ expect(html).toContain("approach 0.80");
+ expect(html).toContain("input 1");
+ expect(html).toContain("The big plan");
+ expect(html).toContain("Done <b>now</b>");
+ expect(html).not.toContain("now");
+ });
+
+ test("escapes the logged input and renders lines from before it was logged", () => {
+ const rows = timelineRows(
+ "s",
+ [record(1, undefined), { ...record(2, "a1"), input: "" }],
+ messages,
+ );
+ const html = renderTimeline("s", rows);
+ expect(html).toContain("(not logged)");
+ expect(html).toContain("</pre><script>");
+ expect(html).not.toContain("