diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 000000000..eafc262c1
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,173 @@
+name: CI
+
+on:
+ push:
+ branches: [main, dev]
+ pull_request:
+ branches: [main, dev]
+ workflow_dispatch:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+env:
+ CI: true
+
+jobs:
+ typecheck:
+ name: Typecheck
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - uses: ./.github/actions/setup-bun
+ - name: Run typecheck
+ run: bun typecheck
+
+ lint:
+ name: Lint
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - uses: ./.github/actions/setup-bun
+ - name: Run oxlint
+ run: bun lint
+ - name: Check formatting
+ run: npx prettier --check "packages/opencode/src/**/*.ts" "packages/opencode/test/**/*.ts"
+
+ test:
+ name: Test (shard ${{ matrix.shard }})
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ shard: ["1/4", "2/4", "3/4", "4/4"]
+ steps:
+ - uses: actions/checkout@v6
+ - uses: ./.github/actions/setup-bun
+ - name: Configure git identity
+ run: |
+ git config --global user.email "ci@mimo.ai"
+ git config --global user.name "mimo-ci"
+ - name: Run unit tests (shard ${{ matrix.shard }})
+ timeout-minutes: 8
+ working-directory: packages/opencode
+ run: bun run test:ci --shard ${{ matrix.shard }}
+ - name: Upload JUnit
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: junit-shard-${{ strategy.job-index }}
+ path: packages/opencode/.artifacts/unit/junit.xml
+
+ security:
+ name: Security Scan
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - uses: ./.github/actions/setup-bun
+ - name: Install skylos
+ run: pip install skylos
+ - name: Run skylos security scan
+ run: skylos suite packages/opencode/src/plugin --json || true
+ - name: Upload results
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: skylos-results
+ path: packages/opencode/.artifacts/security/
+
+ quality:
+ name: Code Quality
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - uses: ./.github/actions/setup-bun
+ - name: Install repowise
+ run: pip install repowise
+ - name: Run repowise health
+ run: repowise health packages/opencode/src/plugin --json || true
+ - name: Upload results
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: repowise-results
+ path: packages/opencode/.artifacts/quality/
+
+ # Multi-language support: detect and test additional languages
+ python:
+ name: Python (if present)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - name: Check for Python files
+ id: check
+ run: |
+ if find . -name "pyproject.toml" -o -name "setup.py" -o -name "requirements.txt" | grep -v node_modules | head -1; then
+ echo "found=true" >> $GITHUB_OUTPUT
+ fi
+ - name: Setup Python
+ if: steps.check.outputs.found == 'true'
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ - name: Install Python deps
+ if: steps.check.outputs.found == 'true'
+ run: |
+ pip install ruff mypy pytest
+ - name: Run ruff
+ if: steps.check.outputs.found == 'true'
+ run: ruff check .
+ - name: Run mypy
+ if: steps.check.outputs.found == 'true'
+ run: mypy . --ignore-missing-imports
+ - name: Run pytest
+ if: steps.check.outputs.found == 'true'
+ run: python -m pytest tests/ -x --timeout=60 || true
+
+ rust:
+ name: Rust (if present)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - name: Check for Cargo.toml
+ id: check
+ run: |
+ if find . -name "Cargo.toml" | grep -v node_modules | head -1; then
+ echo "found=true" >> $GITHUB_OUTPUT
+ fi
+ - name: Setup Rust
+ if: steps.check.outputs.found == 'true'
+ uses: dtolnay/rust-toolchain@stable
+ - name: Cargo check
+ if: steps.check.outputs.found == 'true'
+ run: cargo check
+ - name: Cargo test
+ if: steps.check.outputs.found == 'true'
+ run: cargo test --no-fail-fast || true
+
+ go:
+ name: Go (if present)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - name: Check for go.mod
+ id: check
+ run: |
+ if find . -name "go.mod" | grep -v node_modules | head -1; then
+ echo "found=true" >> $GITHUB_OUTPUT
+ fi
+ - name: Setup Go
+ if: steps.check.outputs.found == 'true'
+ uses: actions/setup-go@v5
+ with:
+ go-version: '1.22'
+ - name: Go vet
+ if: steps.check.outputs.found == 'true'
+ run: go vet ./...
+ - name: Go test
+ if: steps.check.outputs.found == 'true'
+ run: go test ./... || true
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
deleted file mode 100644
index e68c4803c..000000000
--- a/.github/workflows/lint.yml
+++ /dev/null
@@ -1,21 +0,0 @@
-name: lint
-
-on:
- push:
- branches: [main, dev]
- pull_request:
- branches: [main, dev]
- workflow_dispatch:
-
-jobs:
- lint:
- runs-on: ubuntu-latest
- steps:
- - name: Checkout repository
- uses: actions/checkout@v6
-
- - name: Setup Bun
- uses: ./.github/actions/setup-bun
-
- - name: Run oxlint
- run: bun lint
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
deleted file mode 100644
index c601b0d60..000000000
--- a/.github/workflows/test.yml
+++ /dev/null
@@ -1,47 +0,0 @@
-name: test
-
-on:
- push:
- branches: [main, dev]
- pull_request:
- branches: [main, dev]
- workflow_dispatch:
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
- cancel-in-progress: true
-
-permissions:
- contents: read
-
-jobs:
- unit:
- runs-on: ubuntu-latest
- strategy:
- fail-fast: false
- matrix:
- shard: ["1/4", "2/4", "3/4", "4/4"]
- name: unit (shard ${{ matrix.shard }})
- steps:
- - name: Checkout repository
- uses: actions/checkout@v6
-
- - name: Setup Bun
- uses: ./.github/actions/setup-bun
-
- - name: Configure git identity
- run: |
- git config --global user.email "ci@mimo.ai"
- git config --global user.name "mimo-ci"
-
- - name: Run unit tests (shard ${{ matrix.shard }})
- timeout-minutes: 8
- working-directory: packages/opencode
- run: bun run test:ci --shard ${{ matrix.shard }}
-
- - name: Upload JUnit
- if: always()
- uses: actions/upload-artifact@v7
- with:
- name: junit-shard-${{ strategy.job-index }}
- path: packages/opencode/.artifacts/unit/junit.xml
diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml
deleted file mode 100644
index 903ca0eba..000000000
--- a/.github/workflows/typecheck.yml
+++ /dev/null
@@ -1,21 +0,0 @@
-name: typecheck
-
-on:
- push:
- branches: [main, dev]
- pull_request:
- branches: [main, dev]
- workflow_dispatch:
-
-jobs:
- typecheck:
- runs-on: ubuntu-latest
- steps:
- - name: Checkout repository
- uses: actions/checkout@v6
-
- - name: Setup Bun
- uses: ./.github/actions/setup-bun
-
- - name: Run typecheck
- run: bun typecheck
diff --git a/.gitignore b/.gitignore
index 2a1a4dfa1..da8a5559d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -53,4 +53,9 @@ Thumbs.db
.playwright-cli/
.mimocode/wiki
.mimocode/wikis
-.mimocode/cache
\ No newline at end of file
+.mimocode/cache
+
+# SDK codegen scratch: script/build.ts writes it, consumes it, then removes it.
+# A failed generation leaves it behind, where it is easy to commit by accident.
+/packages/sdk/js/openapi.json
+/packages/sdk/js/openapi-ts-error-*.log
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
index 0a81fbc57..f0f7252c7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -98,6 +98,29 @@ const table = sqliteTable("session", {
})
```
+### Reading a nullable column
+
+Two independent absences meet in one expression, and only one of them is
+`undefined`. `.get()` yields `undefined` when no row matches — Drizzle normalises
+the driver's `null` there — while a nullable column's SQL `NULL` arrives as
+`null`. So `row?.some_column` is `T | null | undefined`.
+
+When a caller only asks "is there a value", flatten to `undefined`, and write the
+flattening as an annotation rather than an `as` cast:
+
+```ts
+// Good — the compiler enforces it; deleting the `?? undefined` is a type error
+const boundary: MessageID | undefined = row?.last_checkpoint_message_id ?? undefined
+
+// Bad — the cast removes `null` from the union without converting anything,
+// so the declared type is untrue at runtime
+return row?.last_checkpoint_message_id as MessageID | undefined
+```
+
+Discriminate a possibly-absent value with truthiness or `== null`, never with
+`=== undefined` / `!== undefined`. Because `null !== undefined` is `true`, such a
+guard typechecks, reads correctly in review, and does nothing.
+
## Testing
- Avoid mocks as much as possible
diff --git a/README.md b/README.md
index 8354434b6..082c51e82 100644
--- a/README.md
+++ b/README.md
@@ -55,6 +55,36 @@ sudo apt install xsel
```
+
+macOS: rendering issues in the default terminal
+
+MiMoCode does not support the built-in macOS Terminal (Terminal.app). If the interface is misaligned, flickers, or has other rendering issues, use [iTerm2](https://iterm2.com/) or the VS Code integrated terminal instead:
+
+```bash
+brew install --cask iterm2
+```
+
+
+
+TUI lag and visual animation issues
+
+If the TUI lags when run directly over SSH, render it locally and run only the MiMoCode server on the remote host. Start the server from the remote project directory:
+
+```bash
+# Remote host
+mimo serve --port 4096
+
+# Local host: create the SSH port forward
+ssh -N -L 4096:127.0.0.1:4096 user@remote-host
+
+# Local host: connect from another terminal
+mimo attach http://127.0.0.1:4096
+```
+
+If decorative animation is causing the lag, run `/vivid`, or configure **Vivid visuals** in the `ctrl+p` command palette, to switch between Vivid and Minimal visuals as needed.
+
+
+
Windows: garbled CJK (Chinese/Japanese/Korean) output in the shell
diff --git a/README.zh.md b/README.zh.md
index 72c3b36af..347843cd5 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -55,6 +55,36 @@ sudo apt install xsel
```
+
+macOS:默认终端渲染异常
+
+MiMoCode 不支持 macOS 自带的“终端”(Terminal.app)。如果界面出现错位、闪烁或其他渲染异常,请改用 [iTerm2](https://iterm2.com/) 或 VS Code 集成终端:
+
+```bash
+brew install --cask iterm2
+```
+
+
+
+TUI 卡顿与视觉动画问题
+
+如果通过 SSH 直接运行 TUI 时卡顿,可以让 TUI 在本地渲染,远端只运行 MiMoCode 服务。先在远端项目目录中启动服务:
+
+```bash
+# 远端主机
+mimo serve --port 4096
+
+# 本地主机:建立 SSH 端口转发
+ssh -N -L 4096:127.0.0.1:4096 user@remote-host
+
+# 本地主机:在另一个终端连接远端 MiMoCode
+mimo attach http://127.0.0.1:4096
+```
+
+如果卡顿来自装饰性动画,可以运行 `/vivid`,或在 `ctrl+p` 命令面板中设置“丰富显示”,根据实际情况在丰富视觉模式和简洁模式间切换。
+
+
+
Windows:shell 输出中文(CJK)乱码
diff --git a/bun.lock b/bun.lock
index 94896eca8..82c3c0512 100644
--- a/bun.lock
+++ b/bun.lock
@@ -27,7 +27,7 @@
},
"packages/app": {
"name": "@mimo-ai/app",
- "version": "0.1.9",
+ "version": "0.1.10",
"dependencies": {
"@kobalte/core": "catalog:",
"@mimo-ai/sdk": "workspace:*",
@@ -81,7 +81,7 @@
},
"packages/console/app": {
"name": "@mimo-ai/console-app",
- "version": "0.1.9",
+ "version": "0.1.10",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -115,7 +115,7 @@
},
"packages/console/core": {
"name": "@mimo-ai/console-core",
- "version": "0.1.9",
+ "version": "0.1.10",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -142,7 +142,7 @@
},
"packages/console/function": {
"name": "@mimo-ai/console-function",
- "version": "0.1.9",
+ "version": "0.1.10",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -166,7 +166,7 @@
},
"packages/console/mail": {
"name": "@mimo-ai/console-mail",
- "version": "0.1.9",
+ "version": "0.1.10",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -190,7 +190,7 @@
},
"packages/desktop": {
"name": "@mimo-ai/desktop",
- "version": "0.1.9",
+ "version": "0.1.10",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -233,7 +233,7 @@
},
"packages/enterprise": {
"name": "@mimo-ai/enterprise",
- "version": "0.1.9",
+ "version": "0.1.10",
"dependencies": {
"@mimo-ai/shared": "workspace:*",
"@mimo-ai/ui": "workspace:*",
@@ -262,7 +262,7 @@
},
"packages/function": {
"name": "@mimo-ai/function",
- "version": "0.1.9",
+ "version": "0.1.10",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -278,7 +278,7 @@
},
"packages/opencode": {
"name": "@mimo-ai/cli",
- "version": "0.1.9",
+ "version": "0.1.10",
"bin": {
"mimo": "./bin/mimo",
},
@@ -433,7 +433,7 @@
},
"packages/plugin": {
"name": "@mimo-ai/plugin",
- "version": "0.1.9",
+ "version": "0.1.10",
"dependencies": {
"@mimo-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -468,7 +468,7 @@
},
"packages/sdk/js": {
"name": "@mimo-ai/sdk",
- "version": "0.1.9",
+ "version": "0.1.10",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -483,7 +483,7 @@
},
"packages/shared": {
"name": "@mimo-ai/shared",
- "version": "0.1.9",
+ "version": "0.1.10",
"bin": {
"opencode": "./bin/opencode",
},
@@ -507,7 +507,7 @@
},
"packages/slack": {
"name": "@mimo-ai/slack",
- "version": "0.1.9",
+ "version": "0.1.10",
"dependencies": {
"@mimo-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -542,7 +542,7 @@
},
"packages/ui": {
"name": "@mimo-ai/ui",
- "version": "0.1.9",
+ "version": "0.1.10",
"dependencies": {
"@kobalte/core": "catalog:",
"@mimo-ai/sdk": "workspace:*",
@@ -591,7 +591,7 @@
},
"packages/web": {
"name": "@mimo-ai/web",
- "version": "0.1.9",
+ "version": "0.1.10",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -636,6 +636,7 @@
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@opentui/core@0.1.101": "patches/@opentui%2Fcore@0.1.101.patch",
+ "@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch",
},
"overrides": {
"@types/bun": "catalog:",
diff --git a/docs/compose/spec/bun-text-import-esm-collision.md b/docs/compose/spec/bun-text-import-esm-collision.md
new file mode 100644
index 000000000..2a2c29270
--- /dev/null
+++ b/docs/compose/spec/bun-text-import-esm-collision.md
@@ -0,0 +1,134 @@
+---
+feature: bun-text-import-esm-collision
+status: delivered
+updated: 2026-08-03
+branch: fix/workflow-script-ext
+commits: 09d03d67..e8f1a8d1
+---
+
+# Built-in workflow scripts collide with the ESM parser
+
+## Report
+
+**What was built** — The four built-in workflow scripts are no longer imported. A Bun macro
+reads the directory at build time and their sources are inlined into the bundle, so the files
+never enter the module graph and nothing can attempt to parse them.
+
+**Verification** — From `packages/opencode`.
+
+| Check | Before | After |
+| ------------------------------------------------------------------------- | ----------------------------- | ------------------------- |
+| `bun test test/cli/tui/plugin-toggle.test.ts test/cli/tui/thread.test.ts` | 3 tests ran, 1 fail, 1 error | 4 pass, 0 fail, 0 error |
+| `bun test test/cli/tui test/cli/cmd/tui` | 261 pass, 1 fail, 1 error | 262 pass, 0 fail, 0 error |
+| `bun test test/workflow` | — | 194 pass, 5 skip, 0 fail |
+| `bun typecheck` | passes with four suppressions | passes with none |
+
+The counts rise by one because the test that previously failed to load now runs.
+`bun run build:local` compiles; `bun build --target=bun src/workflow/builtin.ts` shows the macro
+call site replaced by an object literal, so the development fallback below is unreachable in a
+bundle; `mimo debug agent build` from the compiled binary, run in an empty directory, loads the
+workflow registry. `bun.lock` is unmodified.
+
+**Journey log**
+
+- The first fix renamed the files to `.js.fn` so no loader would try. It worked, and it was the
+ wrong shape: the defect was that the scripts were reachable as modules, not that they were
+ named `.js`. Renaming also took them out of oxlint and editor highlighting, and touched every
+ reference to them. Asking what makes the failure impossible rather than unlikely gave a
+ better answer than iterating on the first one that worked.
+- The macro form was already in this codebase twice for the same job. Searching for precedent
+ before inventing an extension would have found it immediately.
+- Two Bun constraints only surfaced by running into them, and the published pattern in
+ `skill/builtin/extract.ts` already encodes both — reading it properly instead of assuming its
+ shape would have saved two failed builds.
+- An explicit list of the four filenames survived into the first macro version out of habit.
+ Nothing outside this module references those filenames, so it was pure ceremony from the era
+ when static imports forced it.
+
+## [S1] Problem
+
+The four scripts in `src/workflow/builtin/` are workflow **function bodies**, not modules: each
+ends in a top-level `return`, because the sandbox evaluates them inside a function wrapper.
+They were imported as raw text via `with { type: "text" }` so that they would embed into the
+compiled binary, which has no source tree to read at runtime.
+
+That left them reachable as modules, and under `bun test` one was occasionally loaded through
+the ECMAScript parser instead of the text loader, where a top-level `return` is a syntax error:
+
+```
+# Unhandled error between tests
+error: Top-level return cannot be used inside an ECMAScript module
+ at .../src/workflow/builtin/fact-check.js:1:1
+```
+
+No assertion produced it. Bun counts the event once as a failure and once as an error, and one
+test never starts, so a test file silently loses coverage. Reproducible with two files, in
+either order, each of which passes alone:
+
+```
+bun test test/cli/tui/plugin-toggle.test.ts test/cli/tui/thread.test.ts
+```
+
+Continuous integration missed it because `test.yml` shards test files across four processes, so
+the two files that collide are usually not in the same one.
+
+Two measurements pinned it down. An `onResolve` hook showed the importer was always
+`builtin.ts` itself — the legitimate text import, with no second importer anywhere — and that
+`thread.test.ts` alone re-evaluates `builtin.ts` on the order of a hundred times in one
+process, of which a handful took the ESM path. Three explanations were tried and falsified:
+leaked test state (both files restore their spies), a static-plus-dynamic import race, and
+cache exhaustion under concurrent re-imports; neither of the latter two reproduces standalone.
+
+This reads as a Bun defect — a static import carrying `with { type: "text" }` should reach the
+text loader every time — but no minimal standalone reproduction was isolated, so the trigger
+for the re-evaluation remains unexplained.
+
+## [S2] Design
+
+`builtin.macro.ts` reads `builtin/*.js` with `fs.readdirSync` / `fs.readFileSync` and returns
+`{ file, script }[]`. `builtin.ts` consumes it through `with { type: "macro" }`, so the call is
+evaluated at transpile and the sources are inlined as string literals. A file read at build time
+is never in the module graph, whatever it is named, which is why this fixes the cause rather
+than the symptom — and why the scripts keep their `.js` names, stay inside oxlint's coverage,
+and need no changes anywhere else.
+
+The directory is the registry, as it is for built-in skills in `skill/builtin/bundle.macro.ts`.
+Nothing outside this module refers to the filenames; consumers look workflows up by `meta.name`,
+which each script declares itself.
+
+Three consequences follow from that, all accepted because the directory is curated. Losing a
+script is no longer a boot failure — it simply stops registering, and callers get the existing
+unknown-workflow error — so `builtin.test.ts` asserts the registered set to make a deletion
+loud. A stray `.js` dropped there becomes a shipped workflow rather than being inert, and a
+malformed meta in it fails app boot. Two scripts declaring the same `meta.name` silently
+last-wins, unchanged from before.
+
+Two Bun constraints shape the call site, both already encoded in the pattern
+`skill/builtin/extract.ts` established:
+
+- Macros are not expanded in every transpile path. Under `bun test` the macro import is stripped
+ without the call being replaced, surfacing as a `ReferenceError`, so the macro module is also
+ imported normally and the macro form falls back to it. A `try`/`catch` is warranted here
+ against the repository's general preference because a non-expanded macro is not otherwise
+ detectable.
+- Macro arguments must be statically known. A per-filename signature would make a misspelled
+ name a build error, but it cannot pass through the fallback wrapper — the argument stops being
+ static and the build fails with `Cannot convert identifier to JS`.
+
+## [S3] Out of Scope
+
+- Reporting upstream. This removes the repository's exposure, not the loader behaviour. The
+ reproduction and measurements are recorded above so a report can be assembled without
+ repeating the work.
+- Why `thread.test.ts` re-evaluates `builtin.ts` a hundred times. It is the condition that made
+ the collision likely and it presumably still holds.
+- Rejected: renaming to `.js.fn` or `.txt` (treats the extension as the defect, costs lint
+ coverage; `.txt` would also collide with `session/prompt/compose.txt`), and making the scripts
+ valid ESM (the top-level `return` is the sandbox contract that user-authored workflows depend
+ on).
+
+## Tasks
+
+- [x] T1: Read the scripts through a build-time macro instead of importing them — acceptance: the two-file reproduction runs all four tests with no failure or error (covers: S2)
+- [x] T2: Add the dev fallback the macro pattern requires — acceptance: `bun test` loads the registry rather than throwing `ReferenceError`, and `bun typecheck` passes with no suppressions (covers: S2)
+- [x] T3: Confirm the sources still reach a compiled standalone binary — acceptance: the bundler output shows the macro call site replaced by a literal, and a command that loads the registry runs from the binary in an empty directory (covers: S2)
diff --git a/docs/compose/spec/compose-next.md b/docs/compose/spec/compose-next.md
index ea4191e77..6d4d2f0c3 100644
--- a/docs/compose/spec/compose-next.md
+++ b/docs/compose/spec/compose-next.md
@@ -9,6 +9,26 @@ predecessor: compose-slim (draft PR #1850)
# Compose Next
+## Superseded in part (2026-07-31)
+
+The invisibility mechanism described below was replaced by
+`docs/compose/spec/skill-invocation-control.md`, which is the current contract.
+Three statements in this document no longer hold:
+
+1. The exact `"compose-next": "deny"` default-agent skill permission is gone.
+ Permission now means authorization only — a `deny` makes a skill unusable by
+ the user too — so keeping it would have broken the user's own
+ `/compose-next`. Model invisibility moved to `disable-model-invocation: true`
+ in the skill's own frontmatter.
+2. S2's "`SkillTool.execute()` stays permissive… if a model guesses the exact
+ name it may invoke it" is reversed: the skill tool now refuses a
+ `disable-model-invocation` skill and redirects to the user's slash command.
+3. `skill/search.ts` no longer special-cases the name `compose-next`; its
+ exclusion from `skill_search` is carried by the field.
+
+The rest — the skill's content, its presence in `Skill.all()` for slash
+autocomplete, the deprecation touchpoints, and the i18n keys — is unchanged.
+
## Report
**What was built** - One self-contained builtin skill `compose-next` (grill → spec → workspace → implement → verify → review → finalize → finish), invoked from Build as `/compose-next`. Hidden from model auto-discovery via an exact `"compose-next": "deny"` default-agent skill permission plus `skill_search` sourcing from `Skill.available(agent)`; still present in `Skill.all()` so slash autocomplete works. Legacy Compose is untouched functionally and marked deprecated through three additive touchpoints: agent description line, `Compose (legacy)` input-bar label, and a compose-only home-tip display override. Side fix: tips now render for first-time users (first-session gate removed).
diff --git a/docs/compose/spec/context-budget-control.md b/docs/compose/spec/context-budget-control.md
index 6cf0ff869..146c1fbe3 100644
--- a/docs/compose/spec/context-budget-control.md
+++ b/docs/compose/spec/context-budget-control.md
@@ -1,8 +1,8 @@
---
feature: context-budget-control
-status: delivered
-updated: 2026-07-27
-branch: feat/context-budget-control
+status: in-progress
+updated: 2026-08-05
+branch: investigate/context-limit-double-rebuild
commits: 028f3178..3b15062d
---
@@ -12,18 +12,22 @@ commits: 028f3178..3b15062d
**What was built** — `compaction.max_context` lets a user compact earlier than the model's own window, expressed as a token count, a `"300K"` / `"1M"` / `"50%"` shorthand, or a map keyed by `"/"` with wildcards. `Overflow.contextWindow()` is the single place that resolves the provider cap (`limit.input || limit.context`), applies the budget as a clamp, and subtracts the reserves; `usable()` is now a thin wrapper over it, so the compaction trigger, checkpoint thresholds, and pruning all follow the budget without further plumbing. With no budget configured the arithmetic reduces to the previous expression for both model shapes.
-The three TUI surfaces that previously divided by the raw `limit.context` (prompt footer, subagent footer, sidebar) now divide by the trigger and mark a configured budget with `↓`, `/status` gained a Context block (window, budget + source, reserved, compact-at, used), and `mimocode models ` prints the same numbers without `--verbose`. `/context-limit` opens a preset picker (Model default / 200K / 300K / 500K / 1M / Custom…) that writes only the current model's key into the global config, refusing while a session is busy because a config write disposes the instance and cancels in-flight runners.
+The prompt and subagent footers divide usage by the internal trigger and mark a configured budget with `↓`; the sidebar instead shows usage against the user-controlled active limit and compares that setting with the provider cap. `/status` gained a diagnostic Context block (window, budget + source, reserved, compact-at, used), and `mimocode models ` prints the same underlying values without `--verbose`. `/context-limit` opens a preset picker (Model default / 200K / 300K / 500K / 1M / Custom…) that writes only the current model's key into the global config, refusing while a session is busy because a config write disposes the instance and cancels in-flight runners.
+
+The 2026-07-31 follow-up makes `usable()` the only automatic context-switch boundary. Checkpoint thresholds continue to keep the checkpoint fresh, but their final 80%/90% rung no longer triggers an early rebuild. The sidebar presents the user-controlled limit relative to the provider cap, so a 300K budget on a 922K model renders `limit 300K of 922K`; the reserve-adjusted trigger remains an internal detail available in `/status`.
Separately, the merged PR #1926 was corrected: it assigned `limit.context = 300_000` for every `gpt-*` model under Codex OAuth, which *raised* the window for gpt-4o (128K) and gpt-3.5-turbo (16K), broke the `limit.context === 0` sentinel for image models, and never moved the compaction trigger for the 1M-class models it targeted because `usable()` reads `limit.input` when the catalog publishes one. It is now a clamp on both fields at **372,000** — the capacity OpenAI's Codex registry declares and that a 350,317-token request demonstrably reaches — applied only when a window exists and only when `limit.input` already exists. The 272K figure circulating for Codex is the 2x-input billing boundary, not capacity, so it ships as a documented `compaction.max_context` recipe (see S2.5).
**Verification**
- `bun typecheck` (packages/opencode) — PASS, post-rebase.
+- `bun typecheck` (packages/opencode) — PASS for the 2026-07-31 follow-up.
+- `bun test test/session/auto-overflow-writer-first.test.ts test/session/prune.test.ts test/session/prompt-rebuild-reset.test.ts test/session/overflow.test.ts test/session/checkpoint-thresholds.test.ts test/cli/tui/sidebar-context.test.tsx` — 94 pass / 0 fail for the 2026-07-31 follow-up.
- `bun test test/session/overflow.test.ts test/plugin/codex.test.ts test/session/checkpoint-thresholds.test.ts test/session/prune.test.ts` — 96 pass / 0 fail.
- `bun test test/config test/session/checkpoint-thresholds.test.ts` — 188 pass / 4 skip / 0 fail.
- Full `bun test` before the review fixes — 4359 pass / 4 fail; every failure reproduced at base or passed in isolation (`test/util/ssrf.test.ts` DNS fail-closed fails at base; `test/workflow/runtime.test.ts` has 2 fails at base vs 1 here; `test/session/checkpoint-rebuild-unify.test.ts` passes in isolation in both trees). CI runs the full suite.
- CLI: `MIMOCODE_CONFIG_CONTENT='{"compaction":{"max_context":{"openai/gpt-5*":"300K","openai/gpt-5.6":200000}}}' bun run src/index.ts models openai` → `gpt-5.6` window 922K / budget 200K / compacts at 180K; `gpt-5.6-sol` budget 300K / 280K; `gpt-5.3-codex` (272K cap) shows no budget because 300K clamps away; `gpt-4o` and `o3` unchanged.
-- Live TUI (tmux, isolated `MIMOCODE_HOME`, `xiaomi/mimo-v2.5`): picking 300K wrote `"xiaomi/mimo-v2.5": 300000` to the global `mimocode.jsonc`, footer became `33.0K/260K↓ (13%)`, sidebar `compact at 260K of 1.05M`, `/status` `window 1.05M · budget 300K · compacts at 260K`. Custom `"50%"` wrote 524288 (`compacts at 484K`). "Model default" wrote `0` and restored `compacts at 1.01M`. Selecting a tier mid-stream left the config untouched and kept the dialog open; the same action once idle wrote 200000.
+- Live TUI (tmux, isolated `MIMOCODE_HOME`, `xiaomi/mimo-v2.5`): picking 300K wrote `"xiaomi/mimo-v2.5": 300000` to the global `mimocode.jsonc`, footer became `33.0K/260K↓ (13%)`, and `/status` showed `window 1.05M · budget 300K · compacts at 260K`. The sidebar follow-up renders the user setting against the model limit (`limit 300K of 1.05M`) and covers it with a TUI render test. Custom `"50%"` wrote 524288 (`compacts at 484K`). "Model default" wrote `0` and restored `compacts at 1.01M`. Selecting a tier mid-stream left the config untouched and kept the dialog open; the same action once idle wrote 200000.
**Journey log**
@@ -84,19 +88,29 @@ Defect 3 — **not overridable and not per-plan.** The plugin auth loader runs a
Defect 4 — **wrong layer for the general need.** Even a correct provider-layer clamp only serves "the provider lies about its window". It does not serve "I want to compact at 200K on a 1M model" for cost, latency, cache-churn or answer-quality reasons — a request already filed as issue #1837 ("Context occupancy meter (% + tokens) and adjustable auto-compact threshold"), and adjacent to #1840 (switching to a smaller-window model mid-session).
-### S1.3 Existing knobs and why they are insufficient
+### S1.3 Checkpoint thresholds apply a second context-limit discount
+
+Checkpoint percentages use `usable()` as their denominator, and the final 80%/90% threshold also signals the prompt loop to rebuild. A configured 300K active limit therefore reports a 280K trigger but rebuilds at 252K, while a configured `"90%"` budget rebuilds at roughly `90% × 90%` of the provider cap. Checkpoint thresholds are snapshot scheduling policy, not a second context limit, so they must not trigger an active-context rebuild.
+
+### S1.4 Existing knobs and why they are insufficient
- `compaction.reserved` (`config.ts:254`) can be abused as an early-compact dial (`reserved = context - target`), but it is global across all models, is also consumed by `compaction.ts:49-54` and `prune.ts:274-292` as a *safety* buffer, and produces a nonsensical number for anyone reading the config.
- `provider..models..limit.input` (`config/provider.ts:40-46`) *does* already move the trigger, but it is per-model JSON archaeology, it lies about the provider's real cap, and for the Codex case it is overwritten by the plugin (Defect 3).
- `MIMOCODE_DISABLE_AUTOCOMPACT` is all-or-nothing.
-### S1.4 Requirement summary
+### S1.5 Requirement summary
1. A user-settable working budget, distinct from the provider cap, expressible in common tiers (200K / 300K / 500K / 1M) plus "model default" and a custom value.
2. Never exceeds the provider's effective cap — the setting clamps, it never raises.
3. Discoverable from the TUI without editing JSON, and the resulting number must be printable ("what is my current context window, and where will it compact?").
4. The provider-layer Codex bug fixed correctly and independently of (1)–(3).
+### S1.6 A completed high-usage turn is rebuilt twice
+
+`SessionProcessor` marks a successfully completed model turn as `"overflow"` when its reported usage reaches `Overflow.usable()`. The post-process overflow handler rebuilds immediately, but the same completed assistant usage remains visible to later prompt loops. The next user turn can therefore consume that usage again in the preflight overflow check, insert a second checkpoint boundary, re-arm checkpoint thresholds, and run tail microcompaction again.
+
+For a configured 372K budget with the default 20K reserve, the first trigger is 352K (`94.6%` of the configured limit). This is the intended trigger. The defect is processing that one high-water usage record twice, not the trigger percentage.
+
## [S2] Design — route-independent core
### S2.1 Vocabulary
@@ -105,6 +119,7 @@ Defect 4 — **wrong layer for the general need.** Even a correct provider-layer
- `budget(cfg, model, …)` = user-requested working budget, or `undefined`.
- `effectiveCap` = `hardCap === 0 ? 0 : min(hardCap, budget ?? hardCap)`.
- `usable()` keeps its current meaning: `effectiveCap` minus reserves. It stays the single source of truth for "when do we compact".
+- Checkpoint thresholds only schedule checkpoint writers. Crossing the final threshold does not rebuild or compact; `usable()` remains the only automatic context-switch boundary.
### S2.2 `Overflow.usable()` becomes budget-aware
@@ -197,8 +212,8 @@ Three surfaces compute `%` independently against raw `limit.context` today, and
| Surface | Today | After |
| --- | --- | --- |
| prompt footer `packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx:469-486` | `162,000 (15%)` vs raw context | `162.0K/300K (54%)` vs `usable`, with a `↓` marker when a budget is active |
-| sidebar context widget `.../feature-plugins/sidebar/context.tsx:67-95` | `% used` vs raw context | same denominator + `compact at 300K` line |
-| subagent footer `.../routes/session/subagent-footer.tsx:54-63` | raw context | same denominator |
+| sidebar context widget `.../feature-plugins/sidebar/context.tsx:67-95` | `% used` vs raw context | `% used` vs active limit + `limit 300K of 922K` when a 300K budget is active on a 922K model |
+| subagent footer `.../routes/session/subagent-footer.tsx:54-63` | raw context | usage vs internal `usable` trigger, matching the prompt footer |
| `/status` dialog `.../component/dialog-status.tsx` | no context info at all | new **Context** block: model, provider window, budget + its source, reserved, compact-at, current tokens + `%` |
| CLI | `mimo models openai --verbose` dumps the whole model JSON | add a `context` column to non-verbose output, or a `--context` flag printing `hard / effective / usable` |
@@ -206,6 +221,14 @@ The TUI may import `Overflow.window` directly — TUI modules already import fro
Note this intentionally changes an existing user-visible number: the footer `%` will read higher than before for models whose reserves are large, because it is now measured against the value that actually triggers compaction. That is the point of the change and must be called out in the PR description.
+### S2.7 One recovery per assistant usage record
+
+Every overflow recovery path that successfully frees context in the post-process phase sets `skipOverflowCheck` before continuing the current run loop. Across run loops, a checkpoint or compaction boundary with an ascending message ID newer than the completed assistant marks that usage as already recovered. Boundary timestamps are backdated to the checkpoint watermark, so this comparison uses message IDs rather than timestamps.
+
+The next iteration or user turn may call the model on the rebuilt or compacted context, but must not run checkpoint scheduling, preflight overflow, or exit-time pruning against the same completed assistant usage. This applies to main-agent checkpoint rebuilds and subagent/fork compaction paths. If recovery inserts nothing (`insert-failed`), no marker exists and the usage remains eligible because no context was freed.
+
+The invariant is behavioral, not time-based: no cooldown or percentage margin is introduced. A later assistant turn with newly measured high usage may still trigger its own recovery.
+
## [S3] Routes — decision required
Storage location for the user's budget. All routes share S2.1–S2.2 and S2.6; they differ in where the value lives and therefore in scope, persistence, and cost.
@@ -283,6 +306,10 @@ Route B (UI writer):
Display (needed by any route):
-- [x] T9: Switch the prompt footer, sidebar context widget, and subagent footer to `Overflow.contextWindow()` and show `used/compact-at (%)` — acceptance: on a model with an active budget all three show the same denominator, and it equals the value at which compaction actually fires (covers: S2.6; depends: T5)
+- [x] T9: Switch the prompt footer, sidebar context widget, and subagent footer to `Overflow.contextWindow()` — acceptance: prompt/subagent usage uses the internal trigger, while sidebar usage uses the active setting and compares it with the provider cap (covers: S2.6; depends: T5)
- [x] T10: Add a Context block to the `/status` dialog showing provider window, budget + source, reserved, compact-at, current tokens and `%` — acceptance: with and without a configured budget the block renders correct numbers and the correct `source` label (covers: S2.6; depends: T5)
- [x] T11: Surface the context window in the `models` CLI command without `--verbose` — acceptance: `mimocode models openai` prints each model's provider window and compact-at (covers: S2.6; depends: T5)
+- [x] T12: Decouple the final checkpoint threshold from the prompt-loop rebuild condition — acceptance: crossing the final checkpoint threshold below `usable()` writes a checkpoint but inserts no rebuild or compaction boundary; reaching `usable()` still follows the existing rebuild path (covers: S1.3, S2.1; depends: T5)
+- [x] T13: Show the configured active limit relative to the provider hard cap in the sidebar — acceptance: a 300K budget on a 922K model renders `limit 300K of 922K`, while the reserve-adjusted trigger remains internal and available in `/status` (covers: S2.6; depends: T5)
+- [x] T14: Consume each assistant usage at most once during overflow recovery — acceptance: post-process recovery sets the current-loop skip guard; across user turns, a newer boundary prevents the recovered assistant from driving checkpoint scheduling, preflight overflow, or exit-time pruning; equivalent subagent/fork recovery paths set the same guard (covers: S1.6, S2.7; depends: T12)
+- [x] T15: Add regression coverage for duplicate recovery — acceptance: a low-usage initialization turn, a successful high-usage turn, and a following user turn produce two distinct checkpoint boundaries on current `main`, but exactly one boundary and one writer after T14; existing preflight and provider-overflow fallback tests remain green (covers: S1.6, S2.7; depends: T14)
diff --git a/docs/compose/spec/plan-enter-removal.md b/docs/compose/spec/plan-enter-removal.md
new file mode 100644
index 000000000..ed9e2d08a
--- /dev/null
+++ b/docs/compose/spec/plan-enter-removal.md
@@ -0,0 +1,350 @@
+---
+feature: plan-enter-removal
+status: delivered
+updated: 2026-08-03
+branch: plan-enter-removal
+commits: ce124cbd..e28331185884
+---
+
+# Remove the plan_enter tool
+
+## Report
+
+**What was built** — `plan_enter` is gone: tool, description file, registry wiring,
+tool-script exclusion, the three permission rules, the headless deny rule, the TUI
+`plan_enter → plan` switch mapping, and the `tui.question.plan_enter.*` block in
+all seven locales. `plan_exit` and everything else about plan mode are untouched,
+so `build` and `plan` now expose exactly the same mode tool and Tab still
+round-trips between them. The system prompt lost its plan-mode advocacy paragraph
+and gained no replacement instruction; the user-facing answer to "how do I enter
+plan mode" moved to `mimocode-docs`, which loads only when someone asks how
+MiMoCode works.
+
+**Verification** — from `packages/opencode`: `bun typecheck` PASS.
+`bun test test/tool test/cli/tui test/agent test/permission` — 1274 pass / 1 fail
+/ 11 skip, where the single failure is `test/tool/registry.test.ts > loads tools
+from .mimocode/tool (singular)` timing out at 5000ms under parallel load;
+PRE-EXISTING flake, 5 pass / 0 fail when the file runs alone.
+`bun test test/skill` — 79 pass / 0 fail. `bunx prettier --check` on the touched
+files — clean (`i18n/*.ts`, `registry.ts`, `agent.ts` are nonconformant at base
+`ce124cbd` too, verified by stashing). `git diff --check` PASS. Root
+`bun run lint` — 0 errors, ~4040 PRE-EXISTING warnings. Independent reviewer:
+spec compliance met after two fixes (see log), no correctness bugs, style
+consistent.
+
+**Journey log**
+
+1. First attempt gated `plan_enter` behind a default-deny permission rule, then
+ behind a registration flag. Both work mechanically; neither was the question.
+ The question was whether anything justifies keeping the surface at all, and
+ nothing did — the deciding evidence was the entrance table in S2, not any
+ property of the gating mechanism.
+2. The first prompt rewrite replaced the advocacy paragraph with instructions on
+ how to *talk about* plan mode. That is the same interruption in a new costume:
+ a model told how to discuss plan mode will discuss it. The fix was deletion,
+ moving the user-facing answer to an on-demand skill.
+3. `mimocode-docs` routing keys off the frontmatter `description` (BM25 over name
+ + aliases + description, `skill/search.ts:98`). The body can hold a perfect
+ answer and still never load; the mode/keybinding vocabulary had to go into the
+ description.
+4. `rg -rn ` silently means `--replace n`, printing `n` where matches
+ were. Two searches during this change reported false clean states. Use
+ `rg -n`.
+5. No locale key-parity test exists in this package, and `test/cli/tui/i18n` is
+ not a real path — a verification band naming it exercises nothing. Question
+ i18n is fail-soft anyway (`routes/session/question.tsx:24` falls back to the
+ DB-stored text), so deleted keys cannot break historical replay.
+
+## [S1] Problem
+
+Users report that plan mode behaves badly with frontier models, and that the
+model keeps putting itself into plan mode unasked.
+
+Plan mode's workflow was designed for weaker models: a five-phase curriculum
+(parallel `explore` subagents → a `general` design subagent → review → write the
+plan file → `plan_exit`) injected as a ~90-line system-reminder on every entry
+(`packages/opencode/src/session/prompt.ts:991-1073`). Frontier models do not fit
+that shape — they research and weigh alternatives before acting anyway, so the
+phase scaffolding mostly buys tokens and extra turns. A large share of users have
+responded by staying in Build for everything.
+
+For a build-only user, every model-initiated switch into plan mode is pure
+interruption: a Yes/No card they did not ask for, leading either to a mode they
+did not want or to a "No" that the model may still misread. Cutting that
+interruption is the point of this change.
+
+Both complaints share one cause: `plan_enter` exists as a model-callable tool.
+
+Nothing in a system prompt is needed to trigger it. The tool's own description
+is a standing invitation (`packages/opencode/src/tool/plan-enter.txt:5`):
+
+> If the user explicitly mentions wanting to create a plan, ALWAYS call this
+> tool first.
+
+A tool description is part of every request's schema, so any model that reads
+"the user said plan" reaches for it. When it fires, the tool writes a synthetic
+user message carrying `agent: "plan"` (`src/tool/plan.ts:80-96`), which discards
+the mode the user selected and swaps in a read-only agent plus a ~90-line
+workflow system-reminder (`src/session/prompt.ts:991-1073`). The user gets a
+Yes/No card, but the decision was framed by the model, not requested by the
+user.
+
+The value the tool delivers is small, because it is not how anyone actually
+enters plan mode.
+
+## [S2] Design
+
+Delete `plan_enter` outright. Keep `plan_exit`. Keep the `plan` agent, its
+`hardPermission` write-block, the plan file, and the plan workflow prompt
+exactly as they are.
+
+### Why deletion, not a flag
+
+**1. It is not a user-facing entry point.** Plan mode has five other entrances,
+none of which this change touches:
+
+| Entrance | Site | Affected |
+| --- | --- | --- |
+| Tab / shift+tab agent cycle | `config/keybinds.ts:64-65` → `cli/cmd/tui/context/local.tsx:113` | no |
+| Agent dialog | `cli/cmd/tui/component/dialog-agent.tsx:30` | no |
+| Startup `--agent plan` | `cli/cmd/tui/thread.ts` | no |
+| Input-bar / voice switch | `cli/cmd/tui/component/prompt/index.tsx:202` | no |
+| Model calls `plan_enter` | `tool/plan.ts:21` | **removed** |
+
+`plan_enter` has no slash command and no keybinding — a user cannot invoke it
+even deliberately. Removing it removes a model capability, not a user gesture.
+`build`/`plan` remain the free-switch group (`local.tsx:50`), so Tab still
+round-trips between them mid-session.
+
+**2. It is already dead outside the TUI.** `mimo run` denies both plan tools
+unconditionally (`cli/cmd/run.ts:350-365`), so headless sessions have never had
+it. Removal aligns the TUI with the surface that already ships without it.
+
+**3. A registration flag would work but earns nothing.** Gating the tool's
+registration on a config flag (the `experimental.maxMode` / orchestrator pattern)
+is a perfectly serviceable way to default it off. It just buys nothing here: it
+keeps the description, the i18n strings, the TUI switch mapping and the tests in
+the tree to serve a default-off path with no evidence of demand, and it leaves a
+second knob for a decision nobody has asked to reverse. The repository's stance is
+to delete unused code rather than keep a shim. If demand appears, restoring one
+tool from git history is cheap — and restoring it behind a flag then is no harder
+than adding the flag now.
+
+**4. `plan_exit` is not symmetric and stays.** It cannot solicit itself: it
+no-ops unless the session is already in plan mode (`tool/plan.ts:120`), which
+only a user gesture can establish. It is also the approval handshake the plan
+workflow terminates on (`session/prompt.ts:1066-1070`). After this change both
+`build` and `plan` expose exactly `plan_exit`, so switching modes still does not
+mutate the tool list (the invariant from PR #1207).
+
+### Accepted consequences
+
+- **Natural-language planning no longer flips the mode.** "帮我先做个计划" in
+ build now yields planning in the reply, not a read-only agent, and the
+ `hardPermission` write-block does not engage. `prompt/default.txt` is
+ corrected so the model recommends the Tab switch instead of silently losing
+ the affordance (see below). This is the intended trade: the user owns the
+ mode.
+- **One-time prefix-cache invalidation.** `build`'s tool schema loses an entry,
+ so the first request of every pre-existing session after upgrade recomputes
+ its prefix. This is a version-upgrade-level cost, unavoidable for any tool
+ removal, and it does not recur.
+
+### Prompt correction
+
+`prompt/default.txt` is the fallback system prompt (`session/system.ts:49`) —
+i.e. the one MiMo's own models get; `anthropic.txt` / `gpt.txt` / `codex.txt` /
+`gemini.txt` / `beast.txt` / `deepseek.txt` / `glm.txt` / `minimax.txt` /
+`trinity.txt` contain no plan-mode instructions at all, and `kimi.txt:17` only
+mentions plan mode as an example of a system-reminder. So exactly one prompt
+needs editing:
+
+- `default.txt:87` — drop `plan-enter` from the "Mode / safety" tool list.
+- `default.txt:132` (item 5 of "Plan mode in detail") — absorb the entry rule into
+ the existing exit rule: the user switches in and out themselves (`Tab` or the
+ agent dialog); the model cannot enter plan mode; **and the model must not tell
+ the user they could switch manually unless the user raises plan mode first**.
+ The model's one mode tool remains `plan_exit`, which requests approval of a
+ finished plan and the switch back to build.
+- `default.txt:134` — delete the "Enter plan mode for non-trivial implementation
+ work…" paragraph outright. Do not replace it with a paragraph about what to do
+ instead: an instruction that discusses plan mode is itself a prompt to bring
+ plan mode up. Frontier models should just do the work.
+
+The net effect on the prompt is one shortened line and one deleted paragraph — no
+new behavioural instruction, no standing invitation.
+
+`session/prompt/compose.txt` also names `plan_enter` (line 20) but is
+deliberately left byte-identical: it is a model-facing system prompt for the
+deprecated Compose agent, and any change invalidates prefix cache for every
+existing Compose session (constraint carried from `compose-next.md` S5). A stale
+"do not use a tool that no longer exists" sentence is harmless.
+
+### Documentation surfaces
+
+Two audiences need different treatment, and conflating them is what made the
+first draft of the prompt edit wrong.
+
+**The model, always:** nothing. Removing the advocacy paragraph is the whole
+change. It carries no guidance about recommending plan mode, because a model that
+has been told how to talk about plan mode will talk about plan mode.
+
+**The user, on demand:** one genuine question survives — "how do I get into plan
+mode?" / "why don't you switch to plan any more?" That answer belongs in
+`mimocode-docs`, which is loaded exactly when a user asks how MiMoCode itself
+works (`skill_search` BM25 over name + description, or explicit `/mimocode-docs`),
+and costs nothing on every other turn. Users also learn the `Tab` gesture from
+the home tips, so this is a fallback for the confused case, not the primary
+teaching surface.
+
+- `mimocode-docs/SKILL.md` frontmatter `description` — add mode / keybinding
+ vocabulary ("agent modes (build / plan / compose) and how to switch between
+ them", "how to enter or leave plan mode") so the routing actually fires on that
+ question. Without it the skill's description never mentions modes and BM25 has
+ nothing to match.
+- `mimocode-docs/SKILL.md:18` — the Agents / modes row states that only the user
+ enters a mode, that no tool switches into plan, and that `plan_exit` is the
+ agent's one move from inside plan.
+- `mimocode-docs/reference/commands.md:130` — under Keybindings, the concrete
+ answer: `Tab` or the agent dialog to enter; `Tab` or `plan_exit` to leave; and
+ that the agent will not offer plan mode unasked (so the user reads the silence
+ as intended behaviour, not a regression).
+
+`mimocode-docs/reference/guide.md:114` and `config.md:92` mention plan only as a
+Compose-legacy skill name and an agent-config key; both stay accurate and are
+left alone. The localized `tui.skill.mimocode-docs.description` strings are the
+dialog copy, not the routing input, so they are untouched.
+
+## [S3] Implementation
+
+Delete:
+
+- `packages/opencode/src/tool/plan-enter.txt`
+- `PlanEnterTool` in `packages/opencode/src/tool/plan.ts` (keep `getLastModel`
+ and `PlanExitTool`)
+- `packages/opencode/src/tool/registry.ts` — the `plan_enter` import, its
+ `Tool.init` entry, and `tool.planenter` in `builtin`
+- `packages/opencode/src/tool/tool-script-ref.ts:29` — `"plan_enter"` exclusion
+- `packages/opencode/src/agent/agent.ts` — the `plan_enter` rules at `:113`
+ (defaults deny), `:141` (build allow), `:181` (plan allow)
+- `packages/opencode/src/cli/cmd/run.ts:356-360` — the `plan_enter` deny rule
+- `packages/opencode/src/cli/cmd/tui/routes/session/plan-switch.ts:7` — the
+ `plan_enter → "plan"` mapping
+- the `tui.question.plan_enter.*` block (6 keys plus its comment header) from all
+ seven locale files under `packages/opencode/src/cli/cmd/tui/i18n/`. There is no
+ locale key-parity test in this package, so removal is verified by grep for
+ residual keys rather than by a suite. Deleting them cannot break historical
+ replay either way: `routes/session/question.tsx:24` falls back to the
+ DB-stored question text when a `tui.question..*` lookup misses.
+
+Modify:
+
+- `packages/opencode/src/session/prompt/default.txt` — lines 87 and 134 per S2.
+- `packages/opencode/src/skill/builtin/.bundle/mimocode-docs/SKILL.md` and
+ `reference/commands.md` — per S2 Documentation surfaces.
+
+Tests:
+
+- `test/tool/plan.test.ts` — drop the `plan_enter` "No" case; `plan_exit` cases
+ unchanged.
+- `test/cli/tui/plan-switch.test.ts` — drop the `plan_enter → "plan"` cases and
+ keep one inverted assertion: a completed `plan_enter` part must now map to
+ `undefined`. Resumed sessions still hold historical `plan_enter` parts in the
+ DB, and replaying them must not switch the mode.
+- `test/agent/agent.test.ts:160,177` — assert on `plan_exit` only.
+- `test/permission/disabled.test.ts:53-65` — these exercise `Permission.disabled`
+ semantics using tool names as data; rename the subjects to `plan_exit` /
+ `question` so no test references a deleted tool.
+- `test/tool/tool-script.test.ts:557` — drop `plan_enter` from the exclusion-set
+ assertion.
+- New: `test/tool/plan-enter-absent.test.ts` — `registry.ids()` does not contain
+ `plan_enter`, and does contain `plan_exit`. This is the regression guard
+ against a re-add.
+
+Verification, from `packages/opencode`: `bun test test/tool test/cli/tui test/skill
+test/agent test/permission`, `bun typecheck`, `git diff --check`, and
+`bunx prettier --check` on the touched files. Two baseline caveats: root
+`bun run lint` reports ~4040 pre-existing warnings (0 errors), and
+`src/cli/cmd/tui/i18n/*.ts`, `src/tool/registry.ts` and `src/agent/agent.ts` are
+already prettier-nonconformant on `main`, so those files must be compared against
+their own baseline rather than to a clean `prettier --check`.
+
+Do not touch: `session/prompt/compose.txt`, `Session.plan()`, the `plan` agent's
+`hardPermission`, the plan workflow reminder in `session/prompt.ts`, the
+`build`/`plan` free-switch group, or the delivered reports and specs that describe
+past state (`docs/compose/reports/sticky-agent-mode.md`,
+`docs/compose/spec/plan-no-continue.md`) — they document the state at their own
+delivery and stay as written.
+
+## [S4] Roadmap — deliberately not in this change
+
+`compose-next.md` S4.5 already parked "plan-mode dissolution and Tab permission
+presets" as independent work. This section records the intended direction so the
+present change is legible as a first step toward it, and fixes what must not be
+done yet. **None of it is in scope here, and none of it is committed to.**
+
+**Direction: plan mode goes away.** The current lean is to stop shipping plan as
+an agent at all and split it along its two real concerns:
+
+1. **Permission handled by another mechanism** — the only part of plan mode
+ carrying durable value is the `hardPermission` write-block. As a permission
+ preset (read-only / ask / accept-edits / bypass) switchable mid-session, it
+ applies to whatever agent the user is already in instead of forcing them into
+ a different one, and it composes with the existing ruleset machinery
+ (`permission/index.ts`) with no new name-branching. Claude Code's shift+tab
+ cycle is the reference shape. One constraint that design must respect:
+ `Permission.evaluate` is `findLast` over the flattened rulesets with no
+ specificity scoring (`permission/evaluate.ts:9-15`), and
+ `--dangerously-skip-permissions` merges `{"*": "allow"}` into the last `user`
+ layer (`config/config.ts:953`) — so a read-only preset expressed as an ordinary
+ ruleset deny would be silently defeated by allow-all. Today's plan mode dodges
+ this via `hardPermission` being re-appended after the user merge; a preset needs
+ an equivalent last-layer story.
+2. **A plan skill** — the ~90-line workflow injected as a system-reminder
+ (`session/prompt.ts:991-1073`) is curriculum, not policy, and per S1 it is
+ curriculum aimed at weaker models. `compose-next` already established the
+ migration pattern: collapse the curriculum into one compact executable
+ contract, loaded on demand by explicit invocation, instead of taxing every
+ plan turn's prompt.
+
+**Ordering constraint.** The permission mechanism must ship before `plan` leaves
+the Tab cycle. Plan mode is today the only read-only backstop in the TUI;
+removing it first would leave users with no way to get one.
+
+**Keybinding.** shift+tab is `agent_cycle_reverse` today
+(`config/keybinds.ts:65`). It frees up naturally: once `plan` and `compose` exit
+the primary cycle, the cycle holds one or two entries and Tab alone covers it.
+Do not reassign shift+tab before that happens.
+
+**Do not mark Plan "(legacy)" yet.** Compose could carry the label
+(`compose-next.md` S2) because its successor had already shipped and users had
+somewhere to go. Plan has no successor in the tree: no permission preset, no plan
+skill. Labelling it now would announce the mode is going away while it is still
+the only way to get a write-block, which is worse than saying nothing. The label
+belongs in the PR that lands the permission mechanism.
+
+## [S5] Out of scope
+
+- Removing the `plan` agent, its `hardPermission`, `plan_exit`, the plan file,
+ or the plan workflow prompt.
+- Any keybinding change, including reassigning shift+tab.
+- Introducing permission presets or a plan skill (S4 direction only).
+- Marking Plan deprecated in any UI surface.
+- Any byte change to `session/prompt/compose.txt`.
+- Rewriting the plan workflow reminder's content (only `plan_exit` survives in
+ it, and that reference stays valid).
+- Hardening `bash` against writes in plan mode (existing "trust the model,
+ permission is a backstop" stance).
+- `packages/web/src/content/docs/**` — the upstream opencode website, carried in
+ ~12 locales. Per `AGENTS.md` the TUI is the supported surface; syncing that
+ corpus is its own change.
+
+## Tasks
+
+- [x] T1: delete `PlanEnterTool`, `plan-enter.txt`, and its registry / tool-script-ref wiring — acceptance: `registry.ids()` omits `plan_enter` and still contains `plan_exit`; `bun typecheck` clean (covers: S2, S3)
+- [x] T2: remove the three `plan_enter` permission rules in `agent/agent.ts` and the deny rule in `cli/cmd/run.ts` — acceptance: no `plan_enter` string remains in `src/agent` or `src/cli/cmd/run.ts`; build and plan agents both expose exactly `plan_exit` (covers: S2, S3; depends: T1)
+- [x] T3: remove the `plan_enter` branch in `plan-switch.ts` and the `tui.question.plan_enter.*` block from all seven locales — acceptance: `rg "plan_enter" src/cli/cmd/tui` returns nothing; no locale file is left with a dangling comment header or a double blank line; `plan_exit` switch mapping still returns `"build"` (covers: S3; depends: T1)
+- [x] T4: correct `prompt/default.txt` — acceptance: no `plan-enter` in the tool list; item 5 of "Plan mode in detail" states the user switches modes, forbids unprompted suggestions to switch, and keeps `plan_exit` as the model's request path; the "Enter plan mode for non-trivial implementation work" paragraph is gone with no behavioural replacement (covers: S2)
+- [x] T5: make `mimocode-docs` answer "how do I enter/leave plan mode" — acceptance: the frontmatter description carries mode/keybinding vocabulary so the question routes to the skill; `SKILL.md` and `reference/commands.md` both state that entering is a user gesture (`Tab` / agent dialog), that no tool enters plan, that `plan_exit` is the agent's only move, and that the agent will not raise plan mode unasked (covers: S2, S3)
+- [x] T6: update the five affected test files and add `test/tool/plan-enter-absent.test.ts` — acceptance: no test asserts a deleted tool is available; the historical-part guard in `plan-switch.test.ts` proves a replayed `plan_enter` part no longer switches modes; the new test fails if `plan_enter` is re-registered (covers: S3; depends: T1, T2, T3)
+- [x] T7: verification band — acceptance: the S3 test bands, `bun typecheck`, and `git diff --check` all pass from `packages/opencode` (covers: S3; depends: T1-T6)
diff --git a/docs/compose/spec/sidebar-shrink-and-press-gate.md b/docs/compose/spec/sidebar-shrink-and-press-gate.md
new file mode 100644
index 000000000..9c179fefe
--- /dev/null
+++ b/docs/compose/spec/sidebar-shrink-and-press-gate.md
@@ -0,0 +1,272 @@
+---
+feature: sidebar-shrink-and-press-gate
+status: delivered
+updated: 2026-08-03
+branch: fix/sidebar-shrink-and-press-gate
+commits: 6853935c..6dff0b1a
+---
+
+# Sidebar state model & press-gated mouse controls
+
+## Report
+
+**What was built** — The right sidebar's two overlapping state variables collapse into one
+persisted tri-state `SidebarPreference`, normalised on every toggle so that a result the
+terminal width would have chosen anyway is stored as `auto`. The sticky state that kept a
+manually-expanded sidebar visible in terminals too narrow to dock it is now
+unrepresentable, rather than cleared by a resize handler. An expanded sidebar always offers
+a collapse control — the narrow-terminal overlay used to paint over the only one — while a
+collapsed sidebar offers an expand control only where it can dock, and a subagent view
+offers neither and cannot write the preference. `contentWidth` stops reserving the
+sidebar's columns in overlay mode, where the sidebar takes no layout space.
+
+Mouse activation for the sidebar toggle and the voice control moves behind
+`ui/press.ts`'s `createPress`, which takes a **stable click** only: press and release on
+the element with no `out` in between. This replaces `onMouseUp`-only handling, which
+opentui fires on whatever sits under the cursor when a drag captured elsewhere ends there —
+the scrollbar-drag mis-fire that prompted the work. The gate is a narrow opt-in, not a
+migration target: plain `onMouseUp` remains correct for the other ~127 call sites, and its
+entry criterion is a control where an accidental activation is itself the defect.
+
+**Verification** — `bun typecheck` in `packages/opencode` passes. `bun test
+test/cli/tui/press-gate.test.tsx test/cli/tui/sidebar-state.test.ts` — 16 pass. `bun test
+test/cli/tui test/cli/cmd/tui` — 261 pass, 1 fail, 1 error; the same command on base `main`
+gives 245 pass, 1 fail, 1 error, so that failure is `PRE-EXISTING` (`thread.test.ts`,
+independently root-caused: the workflow builtin `.js` files are function bodies with a
+top-level `return`, imported as raw text via `with { type: "text" }`, and Bun sometimes
+loads one through the ESM parser instead; minimal repro `bun test
+test/cli/tui/plugin-toggle.test.ts test/cli/tui/thread.test.ts`, each file green alone).
+Each fix was reproduced as a failing assertion before being made to pass, including the
+baseline mis-fire against a plain `onMouseUp` button, the dropped intra-element click, and
+the toggle's position parity between docked and overlay modes, all proven with throwaway
+`testRender` probes.
+
+**Journey log**
+
+- Three review rounds each found a real CRITICAL, and the third one found a defect the
+ second round's own fix had introduced. When a fix keeps reopening its own area, the
+ problem is usually the model, not the patch: this gate was accumulating disarm hooks to
+ approximate browser click semantics against a dispatcher that does not supply the events
+ for it.
+- The product owner cut that knot by naming the contract instead of the mechanism — stable
+ click, leaving discards, don't care about the rest. That reverted a fix (`d0bb626b` →
+ `50fee2d1`) and turned a reviewer's CRITICAL into an asserted contract. The asymmetry is
+ the whole point: a dropped click is a non-event the user repeats, an unintended one is the
+ bug. Encode such a rule as a test, or the next contributor "fixes" it.
+- The contract then had to be read precisely, because "no `out`" and "never left" are not
+ the same predicate here. Reverting wholesale also discarded clicks that merely drifted
+ inside the control, which the contract never asked for. Restoring the geometric check
+ turned out to cost nothing: the mis-fire it had been reverted for only reproduces when a
+ press arrives with no preceding pointer motion, which no real pointer does. The lesson is
+ that the earlier revert leaned on a reviewer's synthetic repro without asking whether the
+ input sequence was reachable — measure the cost of a guard before paying for it.
+- Two hypotheses died to cheap experiments. Unrestored `spyOn`s looked like the obvious
+ cause of the pre-existing failure until reading the file showed `mockRestore()` in a
+ `finally`; a static-plus-dynamic double import of a text-loaded module looked like the
+ Bun bug until a 4-file standalone repro refused to reproduce. Both cost minutes and
+ saved a wrong fix.
+- `rg -rn` is `--replace`, not recursive — it silently rewrote match output mid-investigation
+ and briefly made `builtin.ts` look like it imported `./builtin/n.js`.
+- The upstream-facing half of that failure (a Bun text-import/ESM loader collision) is
+ parked with a worktree and no commits; CI is green because it shards test files across
+ four processes, so the two files that collide rarely share one.
+
+## [S1] Problem
+
+Two independent mouse/layout defects in the session TUI.
+
+**S1.1 — sidebar survives a shrink it cannot fit into, with no way to close it.**
+`routes/session/index.tsx` carried two overlapping pieces of sidebar state: a persisted
+`sidebar: "auto" | "hide"` (index.tsx:199) and an in-memory `sidebarOpen` signal
+(index.tsx:200). Visibility was
+
+```
+sidebarVisible = agent === "main" && (sidebarOpen || (sidebar === "auto" && wide))
+```
+
+`sidebarOpen` short-circuited ahead of the `wide` term and nothing ever cleared it.
+Collapsing then re-expanding on a wide terminal left `sidebarOpen === true` for the rest
+of the session, so shrinking below the `width > 120` threshold no longer auto-hid the
+sidebar: it flipped to the narrow full-area overlay branch (index.tsx:1497-1509), which
+paints over the 3-column toggle button (index.tsx:1480-1491) because the overlay is the
+later sibling with no `zIndex`. The user saw a sidebar that pops out with no visible
+control. Reproducible every time after one collapse/expand cycle; a fresh session reset
+`sidebarOpen` and looked fine, which is why it read as intermittent.
+
+The overlay buried the toggle in *every* narrow case, not just the sticky one — opening
+via `Ctrl+X B` on a narrow terminal produced the same unclosable-by-mouse sidebar. A
+throwaway `testRender` probe confirmed the layering: without `zIndex` the button's glyph
+is absent from the captured frame entirely.
+
+`contentWidth` (index.tsx:239) also subtracted the sidebar's hardcoded 42 columns even in
+overlay mode, where the sidebar takes no layout space. Below 46 columns that drove it
+non-positive.
+
+**S1.2 — scrollbar drags mis-trigger neighbouring buttons.** The sidebar toggle
+(index.tsx:155) and the voice control (component/prompt/index.tsx:1834-1851) fired on
+`onMouseUp` alone, with no record of where the press began. `@opentui/core`'s renderer
+dispatches a bare `up` to the renderable under the cursor *in addition to* delivering
+`drag-end`/`up` to the captured renderable, because the captured-`up` branch has no
+`return` before the generic dispatch at the end of `handleMouseEvent`. Dragging the
+transcript scrollbar — which becomes the captured renderable via `SliderRenderable`'s
+`onMouseDown`/`onMouseDrag` — and releasing with the cursor drifted onto an adjacent
+button therefore activated that button. The button also receives `over` during the drag,
+so its hover highlight lights up and the mis-fire looks intentional. Confirmed with a
+baseline `testRender` + `mockMouse` probe: an `onMouseUp`-only button fires when a drag
+captured on a neighbour is released over it.
+
+Two dispatch details constrain any fix:
+
+- Releasing inside a captured renderable delivers `up` **twice** (once from the captured
+ branch, once from the generic dispatch), so a press gate must consume its armed state
+ exactly once.
+- A captured renderable never receives `out` (the dispatcher guards with
+ `lastOverRenderable !== capturedRenderable`), so "press the button, drag away, release
+ outside" cannot be detected by hover tracking alone and needs a geometric bounds check
+ at release time.
+
+## [S2] Design
+
+### [S2.1] One tri-state preference, normalised on toggle
+
+`sidebarOpen` is deleted. The persisted preference widens to
+`SidebarPreference = "auto" | "show" | "hide"` and all logic moves into two pure
+functions in `routes/session/sidebar-state.ts`:
+
+```ts
+export function sidebarVisibleFor(preference: SidebarPreference, wide: boolean) {
+ if (preference === "auto") return wide
+ return preference === "show"
+}
+
+export function sidebarToggle(preference: SidebarPreference, wide: boolean): SidebarPreference {
+ const next = !sidebarVisibleFor(preference, wide)
+ if (next === wide) return "auto"
+ return next ? "show" : "hide"
+}
+```
+
+The normalisation is the fix for S1.1: a toggle whose resulting visibility matches what
+the width would have picked anyway stores `auto` rather than an override. A
+collapse/expand round-trip on a wide terminal therefore ends at `auto`, and a later
+shrink hides the sidebar again. No resize effect is needed — the sticky state cannot be
+represented.
+
+An explicit expand on a narrow terminal still yields `show`, which deliberately survives
+further shrinking: the user asked for it, so it stays until they collapse it. Collapsing
+it lands back on `auto` by the same rule. Existing `"auto"` / `"hide"` values on disk
+remain valid, so no kv migration is required.
+
+Both toggle call sites (`sidebar_toggle` command and the button) collapse to
+`setSidebar(() => sidebarToggle(sidebar(), wide()))`, removing the duplicated two-signal
+update and the `batch` it needed.
+
+### [S2.2] Toggle affordance rules
+
+- Expanded → a collapse control at **any** width.
+- Collapsed → an expand control only when wide enough to dock.
+- Subagent views → no control at all, and the `sidebar_toggle` command disabled.
+
+The render condition `sidebarVisible() || wide()` already expressed the first two; what was
+missing is that the narrow overlay painted over the button. Rather than raise the button
+above the overlay, it now rides *inside* it as a right-aligned row sibling placed before the
+panel. That keeps one invariant across both modes — the control sits immediately to the left
+of the sidebar — instead of the control appearing left of the panel when docked and on the
+panel's right edge when overlaid. It also removes the `zIndex` the raised version needed, and
+simplifies the in-flow gate to `sidebarAllowed() && wide()` since the overlay now owns its
+own control. Verified by comparing captured frames: the glyph occupies the same column and
+the sidebar starts at the same column in both modes, with exactly one control rendered.
+
+The third rule is new. `sidebarVisible()` was already gated on `currentAgentID() === "main"`
+while `sidebarToggle` is width-only, so on a subagent view the control offered "expand"
+and a click wrote `"hide"` — persisting a hidden sidebar for the main view. That gate is
+now a named `sidebarAllowed()` memo used by the panel, the button's `Show`, and the
+command's `enabled`. `main` rendered a dead button there (it mutated state but the agent
+gate suppressed any visible effect); the control is removed rather than made to work,
+since the sidebar itself cannot appear on a subagent view.
+
+`contentWidth` now subtracts the sidebar only when docked (`sidebarVisible() && wide()`),
+using a shared `SIDEBAR_WIDTH` constant exported from `sidebar.tsx` instead of a second
+hardcoded `42`. This removes the overlay-mode reflow and lifts the non-positive-width
+threshold from "below 46 columns" to "4 columns or fewer" — not a floor, just narrow
+enough to be unreachable; no clamp was added for a terminal that small. The sidebar itself
+clamps to `Math.min(SIDEBAR_WIDTH, dimensions().width)` so it cannot overflow a terminal
+narrower than itself.
+
+### [S2.3] Stable-click gate
+
+New `ui/press.ts` exporting `createPress(onPress: () => void)`, returning a `hover`
+accessor plus a spreadable prop bag. The contract is a **stable click**: the press and the
+release both land on the element and the pointer never leaves its bounds in between, once
+per press. Movement within the element is fine.
+
+That asymmetry is the design. A dropped click is a non-event the user repeats; an
+unintended activation is the defect this exists to prevent, so every ambiguity resolves
+toward not firing. Browser semantics — where the pointer may leave the element and return
+and still produce a click — are explicitly not the goal.
+
+"Left the element" has to be decided geometrically rather than from the event name, because
+opentui raises `out` and `over` on intra-element hit-target changes as well: a child glyph
+and the box's own cells are separate hit targets, and both events bubble to the parent, so
+the parent sees `out` while the pointer is still inside it. `MouseEvent.target` does not
+settle it either, since an `out` is dispatched to the element being left — which is that
+same child both when the pointer merely crosses an internal boundary and when it exits the
+control entirely. The coordinates do settle it: `out` carries the pointer's new position.
+
+- `onMouseDown` arms only if the press coordinates fall inside the element's rect.
+- `onMouseOut` and `onMouseOver` disarm only when the event's new position is outside the
+ rect; `onMouseDrag` disarms when the drag lands outside; `onMouseDrop` disarms because a
+ `drop` means a drag captured elsewhere ended here.
+- `onMouseUp` returns early when unarmed, disarms before anything else (the duplicate `up`
+ delivered inside a captured renderable is then inert), rejects releases carrying
+ `isDragging` (opentui sets that only on its two selection dispatches, so it identifies a
+ release closing a text selection — which never gets a preceding `drop`), and re-checks
+ the release coordinates against the rect.
+- `onMouseOver`/`onMouseOut` also drive the returned `hover` accessor, because the gate
+ must own `onMouseOut` and callers cannot register a second handler for it.
+
+One limitation is accepted and documented rather than worked around: a press that arrives
+with no preceding pointer movement onto the element cannot be disarmed when it drags away,
+because opentui then delivers the element no event at all for that press — it is too narrow
+to become the capture target, and `lastOverRenderable` was never pointed at it. This was
+measured rather than assumed: with a realistic hover-then-press sequence the drag-off does
+deliver an `out` and disarms, and only a synthetic press with no prior motion reproduces the
+stale arm. Real pointers always generate that movement first.
+
+Consumers must render unselectable content (`selectable={false}` on any ``).
+Otherwise the element's own press starts a text selection, every release arrives with
+`isDragging`, and the control is silently dead. Stated in the exported doc comment.
+
+Bounds come from the renderable captured through `ref`; `Renderable` exposes absolute
+`x`/`y`/`width`/`height` in the same coordinate space as `MouseEvent`'s `x`/`y`.
+
+### [S2.4] Adoption, and where this must NOT spread
+
+`SidebarToggleButton` and the voice control consume `createPress`. The voice control's
+five `Match` branches share one gate instance created outside the `Switch`; the
+non-interactive `finishing` branch keeps no handlers. Both render unselectable glyphs.
+
+The gate is deliberately **not** a general replacement for `onMouseUp`, and the remaining
+~127 `onMouseUp` sites are not queued for migration. Handling only `up` is correct for the
+great majority of controls; routing one of them through the gate buys nothing and costs it
+dropped clicks. The entry criterion is a control where an accidental activation is itself
+the defect — in practice, one adjacent to a drag surface (a scrollbar, selectable
+transcript text) whose action the user cannot casually undo.
+
+## [S3] Out of Scope
+
+- Migrating the other ~127 `onMouseUp` handlers to the press gate. Not a backlog item: see
+ [S2.4] — most controls should keep plain `onMouseUp`.
+- Subtracting the toggle button's 3 columns from `contentWidth` — a pre-existing
+ discrepancy; changing it would reflow every transcript.
+- Patching the upstream `@opentui/core` dispatch bug.
+- The pre-existing `test/cli/tui/thread.test.ts` failure and the workflow-builtin `.js`
+ load error it surfaces when the suite runs as a batch.
+
+## Tasks
+- [x] T1: Replace the two-signal sidebar state with `sidebarVisibleFor`/`sidebarToggle` in `routes/session/sidebar-state.ts` and wire both toggle sites — acceptance: a wide collapse/expand round-trip normalises to `auto` so a later shrink hides the sidebar; an explicit narrow expand persists (covers: S2.1)
+- [x] T2: Add `createPress` in `ui/press.ts` — acceptance: press outside + release inside does not fire; press inside + release inside fires exactly once; press inside + release outside does not fire (covers: S2.3)
+- [x] T3: Raise the toggle above the overlay, share `SIDEBAR_WIDTH`, clamp the sidebar, and adopt `createPress` in the toggle and the voice control — acceptance: the collapse glyph renders and is clickable with the overlay up; `contentWidth` stays positive at every reachable width (covers: S2.2; S2.4; depends: T2)
+- [x] T4: Regression tests plus typecheck — acceptance: `testRender` + `mockMouse` proves the captured-drag mis-fire is gated out, the state model is covered by pure tests, and `bun typecheck` passes (covers: S2.1; S2.3; depends: T1, T3)
+- [x] T5: Gate the control and the `sidebar_toggle` command on `sidebarAllowed()` — acceptance: a subagent view offers no sidebar affordance and cannot write the preference (covers: S2.2)
+- [x] T6: Fix the arm leak and the selection-drag release path, then settle the stable-click contract and record its entry criterion — acceptance: a foreign drag or selection released over a control never fires it; a drifted press is dropped and asserted as contract (covers: S2.3; S2.4; depends: T2)
diff --git a/docs/compose/spec/skill-invocation-control.md b/docs/compose/spec/skill-invocation-control.md
new file mode 100644
index 000000000..6e1208161
--- /dev/null
+++ b/docs/compose/spec/skill-invocation-control.md
@@ -0,0 +1,313 @@
+---
+feature: skill-invocation-control
+status: delivered
+updated: 2026-07-31
+branch: feat/skill-invocation-control
+commits: 6674db7a..6236515e
+---
+
+# Skill Invocation Control
+
+## Report
+
+**What was built** — Model reachability and authorization are now separate
+axes. `permission.skill` means authorization only: a `deny` makes a skill
+unusable by anyone, the user included. A new optional `disable-model-invocation`
+boolean in SKILL.md frontmatter carries reachability: the skill is absent from
+the system-prompt catalog, from the `skill` tool description, and from
+`skill_search`, and the `skill` tool refuses to load it with an error that
+points at the user's slash command instead of dead-ending. `/name` typed by the
+user is untouched. The field name is kebab-case to match Claude Code and the
+agentskills.io standard; internally it is `Info.disable_model_invocation`.
+
+Mechanically this is one new registry accessor, `Skill.modelInvocable(agent?)`
+= `available(agent)` minus the flag, feeding the three model-facing call sites,
+while `available()` and `all()` stay as the user-facing sets. The dead
+`Skill.Info.hidden` field, parsed but never read since PR #1725, is gone.
+`compose-next` graduated onto the new field: its exact `deny` rule is deleted,
+its SKILL.md sets the flag, and both its description and body now state that
+the workflow starts only on explicit user invocation — belt and braces, so it
+still behaves if the flag is ever removed. `skill-creator` and its frontmatter
+reference document the field for skill authors; `mimocode-docs` records that
+`/compose-next` is user-only, which is the channel through which a model learns
+the skill exists at all.
+
+**Verification** — all from `packages/opencode` unless noted:
+
+- `bun typecheck` (packages/opencode) — PASS. `bun typecheck` (packages/sdk/js) — PASS.
+- `bun test test/tool test/skill test/permission test/session/prompt-skill-command-multi.test.ts`
+ — 1123 pass, 11 skip, 0 fail (after the review follow-ups).
+- `bun test test/skill test/tool test/permission test/command` — 1123 pass, 11 skip, 0 fail.
+- `bun test test/session` — 899 pass, 25 skip, 1 todo, 0 fail.
+- The new test in `test/session/prompt-skill-command-multi.test.ts` was
+ confirmed to FAIL on the base commit with the intended symptom: with `src/`
+ stashed, the gated skill appeared in the model's catalog
+ (`skill-gated` present in `available_skills`).
+- `bun lint` (root oxlint) — 0 errors; 4043 warnings is the repo-wide baseline,
+ and the seven changed source files carry 12, all pre-existing rule classes.
+- `git diff --check` — clean.
+- `./packages/sdk/js/script/build.ts` — FAIL, `PRE-EXISTING-SDK-CODEGEN`. See T8.
+- Independent review by a fresh subagent: all eight acceptance criteria met; one
+ critical finding (a stray `packages/sdk/js/openapi.json` build artifact
+ committed by accident) and one correctness nit (the not-found hint duplicating
+ the reachability predicate over `all()`), both fixed in `6236515e`.
+
+**Journey log**
+
+1. The bug was reproduced in the authoring session itself: `/compose-next`
+ delivered no `` block and no error. `git log -L` on the
+ mention scan pinned the regression to `4e2a3cb6`, which swapped `sys.all()`
+ for `sys.available(runtimeAgent)` and deleted the comment recording why the
+ bypass existed. A comment that explains a non-obvious choice is load-bearing;
+ deleting it is how the choice gets undone.
+2. The first design kept `deny` as the hiding mechanism and special-cased the
+ user path. Rejected after reading Claude Code's frontmatter reference: the
+ upstream standard already splits this into `disable-model-invocation` and
+ `user-invocable`, which named the actual defect — one rule serving two
+ questions — rather than patching its symptom.
+3. `user-invocable: false` was deliberately dropped from the port. No in-repo
+ skill needs a model-only skill, and shipping an unused second axis would
+ reintroduce exactly the ambiguity being removed.
+4. An earlier draft kept a `disable-model-invocation` skill listed in the
+ catalog with an annotation, so the model could suggest `/compose-next`.
+ Rejected: obra/superpowers#345 shows what an advertised-but-unloadable skill
+ costs — the model retries the tool and then tells the user the skill does not
+ exist. Documentation skills are the right channel for "this exists, you
+ invoke it".
+5. `git add -A` after a failed SDK generation committed a 16,934-line scratch
+ file. `git status` before staging would have caught it; the reviewer did.
+ It is now gitignored.
+
+## [S1] Problem
+
+A user typing `/compose-next` gets nothing. The visible text `/compose-next …`
+reaches the model, no `` block is ever
+injected, and no error is shown. A model calling `skill(name="compose-next")`
+is hard-rejected instead of loading it.
+
+Both symptoms come from one cause: **"hide from the model" and "forbid
+invocation" are expressed by the same permission rule.** `compose-next` is
+hidden from model auto-discovery by an exact `skill: { "compose-next": "deny" }`
+rule on the default agent (`agent/agent.ts:111`). That rule is then consulted by
+four independent surfaces:
+
+| Surface | Code | Effect of `deny` | Intended |
+| --- | --- | --- | --- |
+| System-prompt catalog | `session/system.ts:181` → `Skill.available` | hidden | yes |
+| `skill_search` BM25 | `tool/skill-search.ts:37` | not searchable | yes |
+| `skill` tool description | `tool/registry.ts:328` `describeSkill` | hidden | yes |
+| `skill` tool execution | `tool/skill.ts:42-47` `ctx.ask` | hard refusal | **no** — `compose-next.md` S2 states execution "stays permissive" |
+| User slash body injection | `session/prompt.ts:864` → `Skill.available` | silent no-op | **no** — user explicitly asked for it |
+
+The slash surface regressed at `4e2a3cb6` ("fix(session): send skill
+instructions as user reminders", 2026-07-30), which changed the mention scan
+from `sys.all()` to `sys.available(runtimeAgent)` and deleted the comment that
+recorded why: *"Use all() to bypass per-agent permission filtering — respect the
+user's explicit /mention action"* (established by PR #1716). Since
+`4e2a3cb6` there has been no way to express "invisible to the model, still
+usable by the user": the only mechanism that hides a skill also disables it.
+
+The registry already carries a field for the visibility half — `Skill.Info.hidden`
+(`skill/index.ts:35`, parsed at `:102`, assigned at `:129`) — but **no code reads
+it**, and no bundled `SKILL.md` sets it. It has been dead since PR #1725.
+
+Separately, `compose-next` has now been through its trial period and should
+graduate: it is no longer an experiment to be kept out of the way, it is the
+recommended entry point for multi-step feature work. What it still must not do
+is start itself.
+
+## [S2] Design
+
+Split the two axes. Permission keeps exactly one meaning; a new frontmatter
+field carries the other.
+
+- **`permission.skill` = authorization.** `deny` means unusable, by anyone,
+ through any surface — model *and* user. Nothing bypasses it.
+- **`disable-model-invocation` = model reachability.** The model cannot see or
+ invoke the skill. A user slash invocation is unaffected.
+
+### Field
+
+`disable-model-invocation`, boolean, optional, default `false`. Kebab-case in
+YAML frontmatter, matching Claude Code and the
+[agentskills.io](https://agentskills.io) open standard so a skill folder is
+portable in both directions. Internally it is `Info.disable_model_invocation`
+(repo snake_case convention); `add()` in `skill/index.ts` maps the kebab
+frontmatter key onto it.
+
+`Skill.Info.hidden` is removed in the same change. It is dead, unset by every
+bundled skill, and keeping a second half-named visibility flag beside the new
+field is the exact ambiguity this feature removes.
+
+The counterpart field in the upstream standard, `user-invocable: false` ("only
+the model may invoke"), is deliberately **not** implemented — see S3.
+
+### Semantics
+
+Behaviour matrix for one skill, given a default-agent `skill: "*": "allow"`:
+
+| frontmatter | model sees it | model may invoke | user `/name` works |
+| --- | --- | --- | --- |
+| (default) | yes | yes | yes |
+| `disable-model-invocation: true` | **no** | **no** | **yes** |
+| any value + `permission.skill` `deny` | no | no | **no** |
+
+"Model sees it" covers every list the model reads: the system-prompt catalog,
+the `skill` tool description, and `skill_search` results. A
+`disable-model-invocation` skill appears in none of them, so the model does not
+learn the name from the harness at all — it learns that `/compose-next` exists
+from documentation skills such as `mimocode-docs`, which also state that the
+model must not start the workflow itself.
+
+### Registry contract
+
+`skill/index.ts` gains one accessor beside the existing `all` / `available`:
+
+- `all()` — unchanged. No filtering. Feeds the command registry
+ (`command/index.ts:264`), the app skills endpoint, and `/skill` autocomplete,
+ so a `disable-model-invocation` skill still autocompletes and still has a
+ slash command.
+- `available(agent?)` — unchanged. Authorization filter only
+ (`Permission.evaluate("skill", name, agent.permission) !== "deny"`). This is
+ the **user** surface: the mention scan in `insertReminders` keeps using it, so
+ a user slash invocation is blocked by `deny` and by nothing else.
+- `modelInvocable(agent?)` — new. `available(agent)` minus
+ `disable_model_invocation`. This is the **model** surface.
+
+Three call sites move from `available` to `modelInvocable`:
+`session/system.ts:181` (catalog), `tool/registry.ts:328` (`describeSkill`),
+`tool/skill-search.ts:37`. `session/system.ts:206` (`SystemPrompt.available`,
+consumed only by the mention scan at `prompt.ts:864`) keeps `available`.
+
+### Skill tool
+
+`tool/skill.ts` refuses a `disable_model_invocation` skill before `ctx.ask`,
+with an error that redirects rather than dead-ends: the model is told the user
+must type `/name` and that retrying the tool will not help. This mirrors Claude
+Code's `cannot be used with Skill tool due to disable-model-invocation`, whose
+bare form is a known dead-end (obra/superpowers#345 — the model retried and then
+gave up instead of telling the user).
+
+The not-found branch's "Available skills: …" hint (`tool/skill.ts:37-39`) is
+filtered by the same predicate, so a typo near a hidden skill's name does not
+leak it back to the model.
+
+### compose-next graduation
+
+- Delete `"compose-next": "deny"` from the default agent's `skill` ruleset
+ (`agent/agent.ts:111`). Permission stops carrying visibility for it. The
+ legacy `"compose:*": "deny"` rule stays exactly as is: those skills are
+ denied on the default agent and allowed on the Compose agent, which is an
+ agent-scoped decision that frontmatter cannot express.
+- Set `disable-model-invocation: true` in
+ `skill/builtin/.bundle/compose-next/SKILL.md`.
+- Add the behavioural rule in two places, so it survives a future flag flip:
+ in `description`, that the model must not use the skill unless the user
+ invoked it or asked for it by name; in the body, that it must not enter the
+ compose workflow without an explicit user request or invocation.
+- Drop `compose-next` from `isComposeSkill` in `skill/search.ts:20-22`. Its
+ exclusion from search is now carried by the field at the caller, and the
+ helper goes back to meaning only `startsWith("compose:")`.
+- `mimocode-docs` records that `/compose-next` is user-invocable only and that
+ the model must not start it — this is the intended channel through which the
+ model learns the skill exists.
+
+### Accepted behaviour changes
+
+- A `deny`'d skill can no longer be loaded by an explicit user slash
+ invocation. Before `4e2a3cb6` it could (PR #1716); since `4e2a3cb6` it cannot.
+ This design keeps the current behaviour and makes it the documented rule:
+ `deny` means unusable. Concretely, `/compose:brainstorm` from Build stays
+ inert; it works from the Compose agent, which allows `compose:*`.
+- The model can no longer invoke `compose-next` by guessing its name.
+ `compose-next.md` S2 previously accepted guessed invocation; this feature
+ makes it a real gate, which is the whole point of the field.
+
+## [S3] Out of Scope
+
+- `user-invocable: false` (model-only skills, hidden from the `/` menu). No
+ in-repo skill needs it, and adding an unused axis reintroduces the ambiguity
+ this change removes. `Skill.all()` therefore remains the single user-facing
+ set.
+- Settings-level overrides equivalent to Claude Code's `skillOverrides`
+ (`on` / `name-only` / `user-invocable-only` / `off`). Per-agent
+ `permission.skill` remains the only config-side control.
+- Migrating `compose:*` off `permission.skill`. Its deny is agent-scoped and
+ disappears with legacy Compose removal.
+- Other frontmatter fields from the upstream standard (`allowed-tools`,
+ `context: fork`, `argument-hint`, `paths`, `model`).
+- The `MAX_AUTOLOAD = 3` budget, the mention regex, and the TUI/ACP
+ leading-slash routing.
+
+### Known gaps left open (surfaced by review, deliberately not fixed here)
+
+- `matchDocumentSkills` (`session/prompt.ts:843`, table at
+ `skill/builtin/extract.ts:75`) recommends document skills to the model from a
+ hardcoded list, consulting neither `available` nor `modelInvocable`. No entry
+ in that table is gated today, so this is latent, not live; it becomes a real
+ leak the day someone sets the flag on a document skill.
+- The entire `tool.skill_search` describe block in
+ `test/tool/skill-search.test.ts` is `it.live.skip`ped on `main`, so the
+ compose-next invisibility assertions there — updated to the new contract in
+ this change — do not run. The mechanism itself is covered by running tests
+ over fixture skills; only the shipped-builtin wiring is inert. Un-skipping
+ that block needs the builtin bundle extracted in the test environment, which
+ is its own change.
+- `./packages/sdk/js/script/build.ts` remains broken (see T8). Fixing the
+ `__schema0` hoisting for `ToolStateCompleted.providerOutput` is a separate
+ change; until then the generated SDK drifts from the API on every schema
+ edit, and `providerOutput` itself is still missing from `types.gen.ts`.
+
+## Tasks
+
+- [x] T1: Replace the dead `hidden` field on `Skill.Info` with
+ `disable_model_invocation`, parsed from the kebab-case
+ `disable-model-invocation` frontmatter key in `skill/index.ts` — acceptance:
+ a SKILL.md with `disable-model-invocation: true` loads with
+ `disable_model_invocation === true`; one without it loads `undefined`; no
+ reference to `Info.hidden` remains in `src` (covers: S2)
+- [x] T2: Add `Skill.modelInvocable(agent?)` and move the three model-facing
+ call sites (`session/system.ts:181`, `tool/registry.ts:328`,
+ `tool/skill-search.ts:37`) onto it, leaving `SystemPrompt.available` and
+ the `prompt.ts:864` mention scan on `available` — acceptance: a
+ `disable-model-invocation` skill is absent from the system-prompt catalog,
+ the `skill` tool description, and `skill_search` results, while
+ `Skill.available` and `Skill.all` still return it (covers: S2; depends: T1)
+- [x] T3: Refuse `disable_model_invocation` skills in `tool/skill.ts` before
+ `ctx.ask`, and filter the not-found "Available skills" hint by the same
+ predicate — acceptance: `skill({name})` on such a skill throws an error
+ naming `disable-model-invocation` and directing the model to have the user
+ type `/name`; the name does not appear in the not-found hint for a
+ mistyped query (covers: S2; depends: T1)
+- [x] T4: Graduate `compose-next`: delete `"compose-next": "deny"` from
+ `agent/agent.ts`, set `disable-model-invocation: true` in its SKILL.md,
+ add the "only on explicit user invocation" rule to both its `description`
+ and body, and drop `compose-next` from `isComposeSkill` in
+ `skill/search.ts` — acceptance: `Permission.evaluate("skill",
+ "compose-next", defaultAgentRules)` is `allow`; `compose:*` still `deny` on
+ the default agent and `allow` on Compose; `searchSkills` no longer
+ special-cases the name (covers: S2; depends: T1)
+- [x] T5: Add a regression test that a user slash invocation of a
+ `disable-model-invocation` skill injects its body, following the real-layer
+ harness in `test/session/prompt-skill-command-multi.test.ts` — acceptance:
+ the test fails on the base commit (no `` part for the
+ invoked skill) and passes after T1-T4 (covers: S1, S2; depends: T2)
+- [x] T6: Update the tests that encode the old deny-as-visibility contract
+ (`test/permission/compose-next-discovery.test.ts`,
+ `test/skill/search.test.ts:101-115`, `test/tool/skill-search.test.ts:196+`)
+ and add coverage for frontmatter parsing plus `modelInvocable` filtering —
+ acceptance: `bun test test/skill test/tool test/permission test/session`
+ shows no failures attributable to this change (covers: S2; depends: T4)
+- [x] T7: Record in `mimocode-docs` that `/compose-next` is user-invocable only
+ and the model must not start it — acceptance: the skill states both facts
+ where it already documents `/compose-next` (covers: S2)
+- [x] T8: Bring the published `AppSkillsResponses` type in
+ `packages/sdk/js/src/v2/gen/types.gen.ts` in line with the new
+ `Skill.Info` shape — acceptance: the skills response type carries
+ `disable_model_invocation?: boolean` and no `hidden?: boolean`.
+ `./packages/sdk/js/script/build.ts` cannot be used: it has failed since
+ `fc74c539` (2026-07-26) because `ToolStateCompleted.providerOutput`
+ serializes to a dangling `$ref: #/components/schemas/__schema0`, and the
+ committed types.gen.ts still has no `providerOutput`, confirming the file
+ predates that commit. Record the field-level hand edit and leave the
+ generator defect to its own change (covers: S2; depends: T1)
diff --git a/docs/compose/spec/tui-quiet-mode.md b/docs/compose/spec/tui-quiet-mode.md
new file mode 100644
index 000000000..7e637c17e
--- /dev/null
+++ b/docs/compose/spec/tui-quiet-mode.md
@@ -0,0 +1,58 @@
+---
+feature: tui-quiet-mode
+status: delivered
+updated: 2026-08-05
+branch: feature/tui-quiet-mode
+commits: 91dc9d14c263d76f7e843eaf6cce3f112ee1ddda..0af69913
+---
+
+# TUI Quiet Mode
+
+## Report
+
+**What was built** — Added a persisted `minimal` / `vivid` visual mode with `vivid` as the default. The command palette and `/vivid` share one localized toggle. Minimal mode removes the default celestial background and uses stable progress markers; vivid mode preserves the existing presentation. The separate animation preference stops high-frequency stars, meteors, Logo motion, and spinners without disabling low-frequency functional updates.
+
+Logo, star field, prompt, task, workflow, and agent states share the same `vivid && animations_enabled` motion contract. Runtime preference changes clean up and restart eligible timers without requiring a TUI restart.
+
+**Verification** — `bun test test/cli/tui/visual-mode.test.ts` passed 4 tests; `bun test test/cli/tui test/cli/cmd/tui` passed 266 tests and 728 assertions; bundled skill tests passed 8 tests and 41 assertions; `bun typecheck` passed; `git diff --check` passed. Isolated development TUI runs confirmed the vivid default, concise localized `/vivid` and `ctrl+p` state/action labels, detailed two-line ON/OFF toasts, switching through both entry points, and KV persistence.
+
+**Journey log**
+
+- Kept home tip rotation because it is a low-frequency functional update, not decorative high-frequency motion.
+- Split visual style from animation accessibility so either presentation can use the independent animation override.
+- A targeted review found and closed an idle Logo timer outside the home route.
+- Kept `/vivid` and `ctrl+p` on one command entry so state, persistence, and feedback cannot diverge.
+- Kept command rows concise by combining current state and next action in one title, while reserving detailed visual-effect explanations for the toast.
+
+## [S1] Problem
+
+The current vivid presentation redraws the home screen for stars, meteors, and logo sweeps and uses elaborate animated progress indicators. The persisted "Disable animations" option only stops some shared spinners, so it cannot represent a quiet default visual style or reliably stop high-frequency cosmetic refreshes.
+
+## [S2] Design
+
+Add an independent KV-backed `visual_mode` preference with `minimal` and `vivid` values. It is switched by the same command from the command palette or `/vivid`, persists across launches, and defaults to `vivid`. A concise command title and completion toast distinguish the enabled and disabled states in every existing TUI-specific locale dictionary; locales without a TUI dictionary use the standard English fallback, matching `/voice`. The existing `animations_enabled` preference remains a separate accessibility and performance override.
+
+In `minimal` mode:
+
+- The default home background is empty: no star field and no meteors. A user-selected static background image remains visible.
+- The home logo does not start automatic sweep or interaction animation timers.
+- Prompt busy state uses a compact static status bar derived from the original opencode-style indicator, with no UFO glyph or timer.
+- In-progress tasks, workflows, and agents use stable status glyphs rather than spinners.
+
+In `vivid` mode, current visuals remain available. When animations are also disabled, vivid visuals become static: the star field may remain, but twinkling, meteors, logo motion, and animated progress indicators stop. Low-frequency functional updates such as home tip rotation and retry countdowns remain active in every combination. Streaming message updates and streaming-only telemetry may continue to redraw while model output is arriving.
+
+The implementation must use the existing theme colors, dimensions, and layout; this is an existing-codebase motion change, not a new visual language.
+
+## [S3] Out of Scope
+
+- Adding a CLI startup flag, a `tui.json` setting, or changing the default value of the existing animation preference.
+- Disabling bounded interaction feedback, home tip rotation, retry behavior, autocomplete polling, or other functional timers.
+- Redesigning the home layout, logo artwork, theme, or sidebar structure.
+
+## Tasks
+
+- [x] T1: Add the persisted visual mode command — acceptance: the command palette and `/vivid` map to the same toggle, show localized enabled/disabled state, persist the choice, and an unset value resolves to `vivid` (covers: S2)
+- [x] T2: Apply visual and animation preferences to passive home motion — acceptance: minimal mode has no default celestial background or logo motion; vivid mode preserves current visuals; disabling animations leaves vivid visuals static and preserves functional tip rotation (covers: S2; depends: T1)
+- [x] T3: Stabilize every in-progress indicator — acceptance: prompt, task, workflow, and agent running states render fixed-width static markers unless both vivid mode and animations are enabled (covers: S2; depends: T1)
+- [x] T4: Add focused regression coverage and verify TUI behavior — acceptance: tests cover preference resolution and relevant package tests and typecheck pass (covers: S2; depends: T1, T2, T3)
+- [x] T5: Document visual mode controls — acceptance: English and Chinese READMEs and the bundled `mimocode-docs` skill describe `/vivid`, the command palette setting, the default, and the independent animation override (covers: S2; depends: T1)
diff --git a/docs/compose/specs/2026-07-14-orchestrator-route-first-redesign.md b/docs/compose/specs/2026-07-14-orchestrator-route-first-redesign.md
new file mode 100644
index 000000000..98bac89e7
--- /dev/null
+++ b/docs/compose/specs/2026-07-14-orchestrator-route-first-redesign.md
@@ -0,0 +1,632 @@
+---
+date: 2026-07-14
+topic: orchestrator-route-first-redesign
+revisions:
+ - date: 2026-07-15
+ change: "AI-route revision: removed tool-level matching (findBestMatch/heuristic/embedding). Route decision is entirely AI-side — harness injects , prompt guides AI to route-first, AI uses existing session send/create directly. No new route tool operation."
+ - date: 2026-07-17
+ change: "User's Agent upgrade: reframed Orchestrator from message-router to user's proxy/agent. Added 3 active-decision duties (permission decisions, answer child questions, proactive audit) mapped to existing session-tool primitives. Route-first becomes the dispatch sub-part of the larger agent identity."
+---
+
+# Orchestrator Redesign: The User's Agent
+
+## Problem Frame
+
+The MiMoCode Orchestrator (`src/agent/agent.ts:231`, gated by `MIMOCODE_EXPERIMENTAL_ORCHESTRATOR`) is an experimental persistent coordinator that delegates work to background child sessions via the `session` tool. Its current architecture suffers from a **create-first default** that causes session explosion.
+
+### Symptom: Session Explosion
+
+In practice, the Orchestrator面对同一条主题的反复工作请求时, 每次都倾向于 `session create` 新建子会话, 而不是复用已有的。一个典型场景:
+
+1. 用户说 "fix the login bug" → Orchestrator creates child A for "fix login bug"
+2. 用户说 "also handle the signup flow" → Orchestrator creates child B (could have been routed to A)
+3. 用户 says "one more thing about auth" → Orchestrator creates child C (again, A or B could handle this)
+
+结果: 三个子会话做本质上同主题的工作, 每个都有独立的上下文和内存, 没有共享任何进展。
+
+### Root Cause: create 耦合了路由和创建
+
+当前 `session create` 命令同时承担两个职责:
+- **路由决策**: 这条任务该交给哪个已存在的会话?
+- **创建行为**: 如果没有合适的, 新建一个
+
+`--topic` 机制是对此的修补 — 它在 create 内部加了一层 find-or-reuse, 但:
+1. **topic 字符串匹配不可靠**: LLM 传什么 topic 取决于 prompt engineering, 语义漂移是必然的 (PR #1727 去掉了严格 topic 字符串匹配, 是止血不是根本解)
+2. **topic 必填只保证"有值"不保证"语义正确"**: Orchestrator 可以给同一个主题传不同的 topic 值, 匹配就失效了
+3. **复用 ≠ 给 create 找一个 key**: 真正的复用是"从现有会话里选一个最合适的发过去", 不是"给新会话打个标签以便下次匹配"
+
+### Why Topic Matching Cannot Work (Any Variant)
+
+| Variant | Why It Fails |
+|---------|-------------|
+| Exact string match | LLM 不可能每次都传完全相同的字符串 |
+| Fuzzy / semantic match | 需要 embedding 或 LLM 判断, 增加延迟和复杂度, 且仍然依赖 LLM 正确提取"主题" |
+| Topic 必填 | 保证有值, 不保证语义正确; LLM 会乱传 |
+| Task-ID 绑定 | task 是廉价的, 一个 session 本该服务多个 task; task↔session 非一一对应 |
+| Topic hierarchy | 过度工程; 真正需要的只是"看一眼活会话列表, 选一个发过去" |
+
+**核心洞察**: 所有 topic 变体都错在同一个假设 — 把复用当成"给 create 找一个 key"。但真正的复用模式是 **人看聊天列表选一个发消息** — 你不会给每个聊天窗口打标签然后按标签匹配, 你看一眼列表就知道该发给谁。
+
+### Why Tool-Level Matching Also Cannot Work
+
+初版设计曾提出 `session route` 操作, 内置 `findBestMatch` (启发式/embedding/LLM-assisted) 做自动匹配。这也是错的:
+
+- **Orchestrator 本身就是 AI** — 它能理解语义、判断相关性、权衡上下文。让工具层用机械匹配替代 AI 的语义判断, 是倒退。
+- **匹配逻辑无法覆盖所有场景**: "这个任务该交给谁" 取决于任务内容、会话历史、用户意图、依赖关系 — 这些是 AI 的强项, 不是算法的强项。
+- **增加一层抽象但没有增加能力**: 工具层匹配只是把 AI 的路由决策权抢走, 然后用一个更差的决策替代。
+
+**正确分工**: 工具层提供 **信息** (活会话清单) 和 **执行** (send/create), AI 做 **决策** (路由到谁)。
+
+## First-Principles Analysis
+
+### Orchestrator 的本质: 用户的代理人
+
+Orchestrator 不是 "decompose → dispatch (create)" 模型, 也不仅仅是一个传声筒/路由器。它的本质是:
+
+> **站在用户的角度, 代替用户做决策**
+
+它不是被动地把消息从 A 搬到 B。它是用户的 **代理人 (agent/proxy)** — 理解用户的意图, 在用户的名义下做判断、做决定、把关质量。Route-first (该发给哪个会话) 只是它的一项职能 — **dispatch (派发)** — 而不是它的全部身份。
+
+Orchestrator 作为用户代理人的三项核心职责:
+
+| 职责 | 含义 | 对应的用户行为 |
+|------|------|---------------|
+| **Dispatch (派发)** | 决定任务交给哪个已有会话, 或是否需要新建 | 用户看聊天列表选一个发消息 |
+| **Act for user (代用户决策)** | 代替用户批准权限请求、回答子会话的问题 | 用户看到权限弹窗点击批准; 用户看到子会话提问直接回答 |
+| **Audit quality (把关质量)** | 主动检查子会话是否真正完成且质量达标, 而非被动等待汇报 | 用户审查交付物, 不盲目相信"做完了" |
+
+这三项职责不是独立的功能列表, 而是 **同一个代理身份的不同表达**:
+- Dispatch 是 **入口**: 把工作送到对的地方
+- Act-for-user 是 **运行中**: 子会话需要用户介入时, 代理人代为决策
+- Audit quality 是 **出口**: 子会话说"做完了"时, 代理人验证是否真的做完了
+
+这个身份不与 route-first 矛盾 — route-first 是 dispatch 的机制; proactive audit 是 quality-gate 的机制; acting-for-the-user 是底层的 agent 本质。三者共同构成 "用户的代理人" 完整身份。
+
+决策的输入是:
+- 活会话清单 (谁在线, 在做什么, 做到哪了)
+- 当前任务的语义
+- 子会话的请求 (权限、问题、完成通知)
+- 用户的意图和偏好
+
+决策的输出是:
+- route-to-existing: 把任务发给某个已有会话 (`session send`)
+- create-as-fallback: 清单里没合适的 → 新建一个, 加入清单
+- approve/answer: 代替用户批准权限、回答子会话问题
+- audit: 验证子会话的交付质量
+
+### 当前模型 vs 目标模型
+
+```
+Current: user task → decompose → create (default) → (maybe topic reuse)
+ ↑ create 是一等操作; 被动等通知; 盲目转发权限
+
+Target: user task → Orchestrator (as user's agent):
+ ├─ Dispatch: read → send or create (route-first)
+ ├─ Act for user: decide permission asks, answer child questions
+ └─ Audit quality: verify completion before declaring done
+ ↑ 主动代理, 不是被动传声筒
+```
+
+### 类比: 人如何管理多会话
+
+一个人面对多个聊天窗口时:
+1. 看一眼所有活跃窗口 (自动注入的清单)
+2. 根据消息内容判断该发给谁 (AI 的语义判断)
+3. 如果没有合适的窗口, 新开一个 (create as fallback)
+
+人不会: 收到消息 → 新建窗口 → 给窗口打标签 → 期望下次能按标签找到。
+人也不会: 收到消息 → 让算法自动匹配 → 发给匹配结果。
+
+人会: 看一眼列表, 自己决定发给谁。
+
+## Target Design
+
+### Core Principle: Orchestrator is the User's Agent
+
+整个设计的核心原则:
+
+> **Orchestrator 是用户的代理人。它不是传声筒, 而是在用户的名义下主动做决策 — 派发工作、代用户回答和批准、把关交付质量。工具层提供信息和执行, AI 做所有决策。**
+
+具体来说:
+- **Dispatch (派发)**: AI 看 `` 清单, 决定 send 给谁或 create 新会话。没有 `findBestMatch`, 没有启发式 — AI 是最好的路由器。
+- **Act for user (代用户决策)**: 子会话的权限请求和提问, Orchestrator 代替用户判断和回答, 而非盲目转发。
+- **Audit quality (把关质量)**: 子会话报告完成时, Orchestrator 主动验证交付质量, 而非被动接受。
+
+这意味着:
+- **不需要新的 tool verb** — 所有操作都映射到现有 session tool primitives
+- **不需要工具层的匹配逻辑** — 所有决策完全在 prompt + AI 层
+- **最小化代码变更** — 核心变更是 (1) context injection, (2) orchestrator.txt 重写
+
+### R1: Harness 注入活会话清单
+
+Orchestrator 的 system prompt 需要注入 **活会话上下文**, 像人看聊天列表一样:
+
+**注入内容** (每次 Orchestrator turn 开始时, 极简摘要格式):
+
+```xml
+
+ ses_abc123 | Fix login bug | build | progressing
+ ses_def456 | Design billing schema | compose | idle
+ ses_ghi789 | Triage repo issues | build | stalled
+
+```
+
+每个会话一行: `id | title | agent | status`。只有 4 个字段, 没有 dir 和最近任务详情。第 3 个字段是子会话的 **agent**(`build`/`plan`/`compose` — 即上面示例里的 `build`/`compose`), **不是**它的 actor `mode`: peer 子会话的 mode 恒为 `peer`, 不携带任何路由信号, 而 agent 才是"这个孩子能做什么"的判断依据。实现见 `packages/opencode/src/session/llm.ts` 里渲染 `actor.agent` 的那一行。AI 需要详情时, 自己调用 `session ask` 或 `session status` 按需查询。详见 R1.1 注入策略。
+
+**注入位置**: `packages/opencode/src/session/llm.ts:240-306` (`buildSystemArray`)。在 agent prompt 组装完成后、plugin transform 前, 注入一个 `` block。这个 block 由 `session list` 的数据自动生成, 不需要 Orchestrator 主动调用。
+
+**内容来源**:
+- `sessions.children(ctx.sessionID)` 获取子会话列表
+- `actorReg.get()` 获取 actor 状态 (mode, agent type)
+- `deriveLiveness()` 计算进度状态 (progressing/stalled/idle/terminal)
+- Terminal 状态 (success/failed/cancelled) 的会话不注入 — 只列活跃会话
+
+
+### R1.1: `` Injection Strategy
+
+R1 描述了注入什么, 但没有回答 **怎么注入** — 特别是: 是每轮全量注入, 还是有更聪明的策略? 这个问题在会话数增长后变得关键。
+
+#### 问题: 全量详情注入的代价
+
+如果每轮 turn 都把完整的 `` (含 dir、最近任务详情等) 注入 system prompt:
+- **Context 膨胀**: N 个会话 × 每个 ~100 tokens = N×100 tokens, 每轮重复。20 个会话就是 ~2000 tokens/轮。
+- **重复浪费**: 大部分 turn (正和某子会话对话、做非路由工作) 根本不需要全量清单。Orchestrator 和 child A 对话时, B/C/D/E 的详情是噪音。
+- **Cache 失效**: prompt cache 依赖 system prompt 前缀稳定; 清单每轮变 (状态/新会话) 导致 cache 频繁失效。
+
+#### 方案对比
+
+| 方案 | 描述 | 优点 | 缺点 |
+|------|------|------|------|
+| **A: 按需拉取** | 不注入, 提供轻量 `session list` 动作让 AI "要路由才查" | 零常驻开销 | 回到靠 LLM 自觉去查 — 用户已批评过依赖自觉; AI 可能忘记查就直接 create |
+| **B: 全量详情注入** | 每轮注入完整清单 (id/title/mode/status/dir/最近任务) | AI 始终有完整信息 | Context 膨胀; 大部分 turn 浪费; cache 失效 |
+| **C: 极简摘要注入** | 每轮注入极简清单 (id/title/mode/status, 一行一会话, 无 dir/详情) | 低成本 (N 行 ≈ N×30 tokens); AI 有足够信息做路由决策; 需要详情时自己 ask | 信息密度低于 B, 但路由决策通常不需要 dir/详情 |
+| **D: 条件注入** | 只在"新工作到达需路由决策"的 turn 注入, 非每轮 | 精准 | 需要判定"何时该注入" — 增加判定逻辑复杂度 |
+| **E: 增量注入** | 只注入变化 (新会话/状态变更), 非每轮全量 | 低带宽 | 需要 diff 逻辑; AI 可能丢失已消失会话的信息; 实现复杂 |
+
+#### 推荐: 极简摘要 + 按需详情 (C 为主, A 为辅)
+
+**默认注入极简摘要** (方案 C), AI 需要详情时 **按需查询** (方案 A 作为补充):
+
+```xml
+
+ ses_abc123 | Fix login bug | build | progressing
+ ses_def456 | Design billing schema | compose | idle
+ ses_ghi789 | Triage repo issues | build | stalled
+
+```
+
+**为什么这组最优**:
+
+1. **极简摘要足够做路由决策**: 路由只需要 "谁在线、在做什么、什么模式"。id + title + mode + status 四个字段覆盖了 90% 的路由判断。Dir 和最近任务详情是 "确认级" 信息, 不是 "决策级" 信息 — AI 先凭摘要选定目标, 需要确认时再 `session ask` 或 `session status` 查详情。
+
+2. **成本可控**: 一行 ~30 tokens。10 个会话 = ~300 tokens, 20 个会话 = ~600 tokens。相比全量详情 (10 个会话 ~1000 tokens) 小一个数量级。即使 50 个会话也只 ~1500 tokens, 可接受。
+
+3. **天然过滤已归档会话**: 只列非 terminal 状态 (progressing/stalled/idle) 的会话。已 success/failed/cancelled 的会话不注入 — 它们不需要路由, 且会无限膨胀清单。需要查询已归档会话时, AI 自己 `session list` 或 `session ask`。
+
+4. **不依赖 LLM 自觉**: 与方案 A 纯按需不同, 极简摘要是 **默认注入** — AI 每轮 turn 都能看到清单, 不需要记住去查。只是清单是精简版, 不是完整版。
+
+5. **Prompt cache 友好**: 极简摘要变化频率低于全量详情 (status 变化 < 详情变化)。且因为体量小, 即使 cache 失效, 重建成本也低。
+
+**AI 需要详情时的按需路径**:
+
+```
+AI 看极简摘要 → 选定目标会话 → 需要确认细节?
+ ├─ 不需要 → session send (直接路由)
+ └─ 需要 → session status 或 session ask (按需查详情)
+```
+
+**实现**: `buildActiveSessionsContext` 函数输出极简格式 (一行一会话, 只含 id/title/mode/status), 过滤 terminal 状态。注入位置不变 (`buildSystemArray`, orchestrator agent 类型)。
+
+
+### R2: orchestrator.txt 决策指引重写
+
+orchestrator.txt 的核心变化 — 让 AI 自己做路由决策:
+
+| Section | Before | After |
+|---------|--------|-------|
+| 核心循环 | decompose → dispatch (create) | understand → **route** (AI reads list, decides send or create) → yield → integrate → report |
+| session tool 参考 | create 是主要操作; approve/grant-approval 未使用 | **send 是主要操作**, create 是 fallback; **approve/grant-approval 代用户决策** |
+| 复用指引 | "reuse a standing session per theme" via topic | "see `` in your context — pick the best match and `session send`" |
+| 新增 Duties | — | Route Decision (dispatch); Permission Decision (act-for-user); Answer Questions (act-for-user); Audit Completion (quality gate) |
+
+**orchestrator.txt 新增 Route Decision section 的内容指引**:
+
+```
+## Routing: route to existing sessions first
+
+Your system prompt contains an block listing your routable
+child sessions in compact format: id | title | agent | status.
+This is your fleet — use it.
+
+When a new task arrives, your FIRST action is to decide: does an existing session
+already own this work? Look at and evaluate:
+- Which session's title/theme matches this task's domain?
+- Which session's agent (build/plan/compose) is appropriate?
+- Is the session idle (ready for new work) or progressing (can accept follow-up)?
+
+If you need more detail about a session (its directory, recent commits, etc.),
+use `session status ` or `session ask ` — the compact list gives you
+enough to route; details are on-demand.
+
+If you find a good match → `session send ` (route to existing).
+If no session fits → `session create ` (create as fallback).
+
+DO NOT create a new session when an existing one can handle the work.
+One session serving multiple related tasks is the norm, not the exception.
+```
+
+### R3: create 降级为 fallback
+
+`session create` 保留但语义变化:
+
+- **之前**: create 是默认操作, Orchestrator 的第一反应
+- **之后**: create 是 "AI 判断没有合适会话时的 fallback"
+- `--topic` 机制保留但降级为可选的 hint, 不再是路由的核心
+
+Orchestrator 的决策流程变为:
+
+```
+1. 收到用户任务
+2. 看 (自动注入, 不需要 list 调用)
+3. AI 判断: 有没有一个现有会话适合处理这个任务?
+ ├─ Yes → session send (AI 自己选 ID)
+ └─ No → session create (AI 自己决定参数)
+4. 返回结果给用户
+```
+
+
+
+## Orchestrator as the User's Agent/Proxy
+
+前文的 route-first + `` injection 覆盖了 **dispatch (派发)** 职责 — 这是 Orchestrator 的入口。但一个真正的用户代理人还需要在 **运行中** 和 **出口** 做决策。本节将 Orchestrator 的完整代理身份映射到现有 session-tool primitives。
+
+### Duty 1: Permission Decisions — 代替用户批准
+
+**场景**: 子会话运行中碰到需要用户授权的权限请求 (访问工作区外目录、读 `.env` 等)。当前行为是盲目转发给用户, 用户需要切进子会话面板手动批准。
+
+**代理人行为**: Orchestrator **代替用户判断**这个权限请求是否合理, 在自己的上下文中批准或拒绝, 而非每次都转发给用户。
+
+**映射到现有 primitives**:
+
+| Primitive | 作用 | 代理人用法 |
+|-----------|------|-----------|
+| `session approve ` | 批准某子会话当前挂起的一个权限请求 | Orchestrator 收到转发的权限请求后, 判断是否合理 → 合理则 `session approve`; 不合理则拒绝 |
+| `session grant-approval ` / `session grant-approval all` | 预授权: 未来权限请求自动批准 | 对已建立信任的子会话, 预授权免每次判断 |
+| `decideAskRouting` (config.ts) | 决定权限请求转发给谁 | 现有逻辑: Orchestrator peer → 转发给 Orchestrator。**不变** — 转发机制已有, 改变的是 Orchestrator 收到后的处理方式 |
+
+**orchestrator.txt 指引**:
+
+```
+## Permission decisions — act on the user's behalf
+
+When a child session sends you a permission request (forwarded ask), you are
+the user's proxy. DO NOT blindly relay every permission prompt to the user —
+that would make you a mere message relay, not an agent.
+
+Instead, judge the request yourself:
+- Is this permission reasonable for the child's stated task? → APPROVE it.
+- Is this suspicious or outside the child's scope? → DENY it.
+- Is this genuinely uncertain or irreversible? → THEN relay to the user.
+
+Use `session approve ` for one-time approvals.
+Use `session grant-approval ` when you trust a child's judgment for its
+entire task scope (e.g. a build child that needs file access across its directory).
+Only escalate to the user for genuinely ambiguous or high-stakes decisions.
+```
+
+### Duty 2: Respond to Child Questions — 代替用户回答
+
+**场景**: 子会话在运行中遇到需要用户输入的问题 (选哪个方案? 确认需求? 提供缺失信息?)。当前行为是把问题转发给用户。
+
+**代理人行为**: Orchestrator **利用自己对用户意图的理解**直接回答子会话的问题, 而非每次都转发。只有真正需要用户亲自判断时才转发。
+
+**映射到现有 primitives**:
+
+| Primitive | 作用 | 代理人用法 |
+|-----------|------|-----------|
+| `session send ` | 向子会话发送消息 (唤醒或追加) | Orchestrator 直接 send 回答给子会话, 代替用户回复 |
+| `session ask ` | 向子会话提只读问题 (不打断其任务) | Orchestrator 可以先 ask 了解子会话的上下文, 再决定如何回答 |
+| `actor_notification` (inbox) | 子会话的通知/问题到达 Orchestrator 的 inbox | **不变** — 通知机制已有; 改变的是 Orchestrator 收到后的处理方式: 从 "转发给用户" 变为 "自己回答或有条件转发" |
+
+**orchestrator.txt 指引**:
+
+```
+## Answer child questions — you know the user's intent
+
+When a child session asks a question upward, you are the user's proxy.
+You know the user's goals, preferences, and constraints from the conversation.
+DO NOT blindly relay every child question to the user — answer it yourself
+when you can, based on your understanding of the user's intent.
+
+- You know the user wants X? Tell the child to do X. Use `session send`.
+- The question is about implementation details you don't know? Let the child
+ decide (it has the context). Use `session send` with "use your judgment".
+- The question is about an irreversible choice you can't decide? THEN relay
+ to the user. But this should be rare.
+
+The user delegated to you because they don't want to be interrupted by every
+sub-decision. Be the buffer, not the conduit.
+```
+
+### Duty 3: Proactive Audit — 主动把关质量
+
+**场景**: 子会话报告 "任务完成"。当前行为是被动接受通知, 假设子会话说完成就是完成。
+
+**代理人行为**: Orchestrator **主动验证**子会话的交付是否真的完成且质量达标, 而非盲目相信。这是 **fan-in/aggregation** 的质量门: 不是子会话说 done 就 done, 而是代理人审查后确认 done。
+
+**映射到现有 primitives**:
+
+| Primitive | 作用 | 代理人用法 |
+|-----------|------|-----------|
+| `session join ` | 等待所有子会话到达 terminal 状态, 返回聚合摘要 | 批量派发后的 fan-in 聚合点 — Orchestrator 收到聚合结果后审查 |
+| `session status ` | 查询子会话的派生 liveness (progressing/stalled/terminal) | 定期或收到通知后, 检查子会话的真实状态 |
+| `session ask ` | 向子会话提只读问题 (基于其历史回答) | 审查: "你的任务完成了吗? 交付物是什么? 有没有遗漏?" — 基于子会话历史的只读查询 |
+| `session dashboard` | 舰队全景 (liveness + worktree 状态) | 宏观审查: 所有子会话的整体进展和健康度 |
+| `git log/diff` (via bash) | 审查 isolated 子会话的提交 | 对 isolated child: 直接审查 git commits 的质量, 而非只看子会话的自我报告 |
+
+**orchestrator.txt 指引**:
+
+```
+## Audit completion — verify, don't trust
+
+When a child session reports completion, you are the quality gate.
+DO NOT blindly accept "I'm done" as final — verify before declaring success.
+
+Verification steps (pick per situation):
+1. `session status ` — is it truly terminal (not just idle-without-reporting)?
+2. `session ask "Summarize what you did and any open items"` — get a
+ self-report from the child's own history
+3. For isolated children: `git log ` / `git diff` — inspect the
+ actual commits, not just the child's claim
+4. `session dashboard` — survey the whole fleet's health before declaring
+ the overall goal done
+
+A child that says "done" but left uncommitted changes, missed acceptance
+criteria, or introduced regressions is NOT done. You catch this; the user
+trusts you to catch this.
+
+Only after YOUR verification passes should you report success to the user.
+```
+
+### Three Duties, One Identity
+
+这三项职责不是三个独立功能, 而是 **同一个代理身份** 的三种表现:
+
+```
+ ┌─────────────────────────┐
+ │ Orchestrator: 用户的代理人 │
+ └────────────┬────────────┘
+ ┌─────────────────┼─────────────────┐
+ ▼ ▼ ▼
+ ┌──────────┐ ┌──────────────┐ ┌──────────────┐
+ │ Dispatch │ │ Act for User │ │ Audit Quality │
+ │ (派发) │ │ (代用户决策) │ │ (把关质量) │
+ └─────┬────┘ └──────┬───────┘ └──────┬───────┘
+ │ │ │
+ session send/create session approve session join/status
+ session send (reply) session ask (verify)
+ injection grant-approval git log/diff (inspect)
+ │ │ │
+ ▼ ▼ ▼
+ 入口: 工作送对 运行中: 代用户判断 出口: 验证质量
+```
+
+**与 route-first 的关系**: route-first 是 dispatch 的实现机制 (入口); proactive audit 是 quality-gate (出口); acting-for-user 是运行中的代理行为 (中间)。三者共同构成完整的用户代理循环: 派发 → 代理决策 → 验证 → 交付。
+
+
+
+
+## Reliability / Liveness Detection
+
+A dependable Orchestrator is a PREREQUISITE of the "user's agent" identity. If the Orchestrator false-alarms "stalled" on healthy children, or silently hangs on truly stuck ones, it is neither a trustworthy agent nor a reliable coordinator. Liveness detection is not an add-on — it is the foundation that makes dispatch, act-for-user, and audit-quality trustworthy.
+
+### Current State: Black-Box Detection (The Defect)
+
+The stall watchdog (`spawn.ts:960`, T40) and `deriveLiveness` (`actor/schema.ts:73`, T39) only read **turn-boundary signals**: `turnCount` and `lastTurnTime`. These update only when a turn ENDS. During a single long turn — running tests, waiting on the LLM stream, reading big files — these timestamps are frozen.
+
+From outside, a healthy long turn looks **identical to a hang**:
+- Child is running `pytest` for 3 minutes → `lastTurnTime` frozen at turn start → `deriveLiveness` returns `stalled` after 90s (`DEFAULT_LIVENESS_STALL_MS`) → watchdog fires.
+- Child is reading a 10MB log file → same pattern → false alarm.
+- Child is waiting for LLM stream → same pattern → false alarm.
+
+This false-alarm flood makes the Orchestrator feel unreliable ("always stalling") — the exact opposite of a dependable agent.
+
+**Root cause**: black-box (turn-boundary timestamps) fundamentally cannot distinguish "long turn doing work" from "real hang". Both look like "no turn boundary for a while."
+
+### Fix: White-Box Detection (Option A)
+
+Add a **turn-internal activity heartbeat** — update a `lastActivityTime` every time the child produces a part (tool call / tool result / LLM token / reasoning), distinct from the turn-boundary `lastTurnTime`.
+
+The watchdog then reads `now - lastActivityTime`:
+- A long turn that's actually working keeps producing parts → activity time stays fresh → **NOT flagged**.
+- A truly hung turn stops producing parts internally too → activity time freezes → **flagged**.
+
+White-box (turn-internal activity) distinguishes "long turn doing work" from "real hang", which black-box (turn-boundary timestamps) fundamentally cannot.
+
+**Implementation sketch**:
+- In the turn execution loop (where parts are streamed/yielded), bump `lastActivityTime` on each part.
+- `deriveLiveness` gains a new signal: `now - lastActivityTime <= stallMs` → progressing; otherwise → stalled.
+- `lastTurnTime` still updates at turn boundaries (for backward compatibility).
+- The watchdog reads `lastActivityTime` (not just `lastTurnTime`) for stall detection.
+
+This aligns with the I1 heartbeat keystone — the heartbeat must fire at turn-INTERNAL activity points, not only at turn boundaries.
+
+### Real-Hang Root Causes (The Other Half)
+
+White-box detection kills **false stalls** (healthy long turns). The remaining question: what are the **true hangs** to fix?
+
+| Root Cause | Status | Description |
+|-----------|--------|-------------|
+| Empty-tool-call loop | Being reverted | The mis-designed guard (auto-retry) caused infinite loops |
+| Provider "call:" preamble leak | Being fixed | LLM provider emits malformed preamble that blocks processing |
+| Cancel-order deadlock | Fixed (be0c322) | Race condition between cancel and turn execution |
+| Orphaned child on old binary | Fixed (#1724, needs rebuild) | Child process stuck on stale binary after upgrade |
+| Checkpoint-writer deadlock | Fixed (historical) | Checkpoint writer blocked on write lock |
+
+With white-box detection, the Orchestrator can accurately distinguish:
+- "This child is doing work but it's slow" → leave it alone (no false alarm)
+- "This child is genuinely stuck" → nudge or cancel and re-dispatch (real hang)
+
+### Unified Identity: Reliability as Agent Quality
+
+The three pillars of the Orchestrator's identity are not independent features — they are expressions of the same principle: **the Orchestrator is the user's dependable agent**.
+
+```
+ ┌─────────────────────────┐
+ │ Orchestrator: 用户的代理人 │
+ └────────────┬────────────┘
+ ┌─────────────────┼─────────────────┐
+ ▼ ▼ ▼
+ ┌──────────┐ ┌──────────────┐ ┌──────────────┐
+ │ Dispatch │ │ Act for User │ │ Reliability │
+ │ (派发) │ │ (代用户决策) │ │ (可信赖) │
+ └─────┬────┘ └──────┬───────┘ └──────┬───────┘
+ │ │ │
+ route-first approve/answer accurate liveness
+ grant-approval white-box heartbeat
+ send/create audit completion no false alarms
+ │ │ │
+ ▼ ▼ ▼
+ 入口: 工作送对 运行中: 代用户判断 基础: 可信赖的状态
+```
+
+Reliability is the **foundation** — without accurate liveness, dispatch (route to a "stalled" child that's actually working) and audit (trust a "completed" report that's actually stuck) break down. A false "stalled" alarm causes the Orchestrator to nudge or cancel a healthy child; a missed stall causes it to wait forever. Either way, the user's agent is not dependable.
+
+### Implementation Roadmap Addition
+
+**Phase 1.5 (between Phase 1 and Phase 2)**: Add `lastActivityTime` heartbeat to turn execution, update `deriveLiveness` to use it, verify false-alarm rate drops. This is a prerequisite for the Orchestrator to reliably act on liveness signals in its dispatch and audit decisions.
+
+
+## Code Impact Analysis
+
+### 1. session 工具: 无新 verb, 仅清理
+
+**File**: `packages/opencode/src/tool/session.ts`
+
+| Current | Change | Impact |
+|---------|--------|--------|
+| `create` (line 613-739) | 保留, 移除 topic find-or-reuse 逻辑 (lines 621-661), 降级为纯创建 | 中等 — topic 逻辑移出 |
+| `send` (line 742-810) | 保留不变, 成为主要操作 | 无 |
+| `list` (line 813-883) | 保留, 新增 `summary` 返回格式供 context 注入使用 | 低 — 新增输出格式 |
+| `topicOf` (line 187) | 保留但标记 deprecated; 不再是路由核心 | 低 |
+| `tagTitle` (line 192) | 保留但标记 deprecated | 低 |
+
+**关键: 没有新的 tool verb**。AI 直接用 `session send` 执行路由, 用 `session create` 作为 fallback。工具层零新增 API。
+
+### 2. Harness 向 Orchestrator 注入活会话清单
+
+**File**: `packages/opencode/src/session/llm.ts:240-306` (`buildSystemArray`)
+
+在 `buildSystemArray` 中, 对 orchestrator agent 类型, 注入 `` block:
+
+```typescript
+// After agent prompt assembly (line 260), before plugin transform (line 292)
+if (input.agent.name === "orchestrator") {
+ const sessionCtx = yield* buildActiveSessionsContext(input.sessionID)
+ if (sessionCtx) system.push(sessionCtx)
+}
+```
+
+`buildActiveSessionsContext` 是一个新函数, 复用 `list` 操作的数据获取逻辑 (lines 820-826), 输出极简 XML 格式 (一行一会话, 只含 id/title/mode/status), 过滤 terminal 状态会话。详见 R1.1 注入策略。
+
+**注入时机**: 每次 Orchestrator 发起 LLM 请求时, system prompt 中包含最新的活会话快照。这意味着 Orchestrator 在做路由决策时, **不需要调用 `session list`** — 清单已经在上下文里了。
+
+### 3. orchestrator.txt 决策指引
+
+**File**: `packages/opencode/src/session/prompt/orchestrator.txt`
+
+核心重写部分:
+
+- **Line 1-5 (Identity)**: 从 "leader who accomplishes goals by delegating" 改为 "the user's agent — you make decisions on the user's behalf, not just relay messages"
+- **Line 22-30 (The loop)**: 循环改为 "understand → route → yield → on notification: **audit + act for user** → integrate → report"
+- **Line 48-59 (session tool reference)**: `send` 提升为主要操作, `create` 标注为 fallback; 新增 `approve`/`grant-approval` 作为代用户决策的核心操作
+- **Line 82-88 (Reuse section)**: 从 "reuse per theme via topic" 改为 "see `` — pick the best match and send"
+- **新增 Route Decision section**: 指导 AI 如何利用 `` 上下文做路由决策 (见 R2)
+- **新增 Permission Decision section**: 指导 AI 代替用户批准/拒绝权限请求 (见 Duty 1)
+- **新增 Answer Child Questions section**: 指导 AI 代替用户回答子会话问题 (见 Duty 2)
+- **新增 Audit Completion section**: 指导 AI 主动验证子会话交付质量 (见 Duty 3)
+
+### 4. 涉及文件汇总
+
+| File | Change Type | Description |
+|------|-------------|-------------|
+| `packages/opencode/src/session/llm.ts` | **修改** | `buildSystemArray` 中注入 `` context |
+| `packages/opencode/src/session/prompt/orchestrator.txt` | **修改** | 身份从 coordinator 升级为 user's agent; 新增 Route/Permission/Answer/Audit 四个 decision sections; send/approve 提升为主要操作 |
+| `packages/opencode/src/tool/session.ts` | **修改** | `create` 中移除 topic find-or-reuse; `list` 新增 summary 格式 |
+| `packages/opencode/src/session/prompt.ts` | **小改** | `buildActiveSessionsContext` 新函数 (可放此处或 llm.ts) |
+
+**注意**: 没有新增 Zod schema, 没有新增 KNOWN_VERBS, 没有新增 tool verb。三项代理职责全部映射到现有 primitives。核心变更是 context injection + orchestrator.txt 重写。
+
+## Implementation Roadmap
+
+### Phase 1: Context Injection (harness 层, 不改产品行为)
+
+**Goal**: Orchestrator 的 system prompt 中自动包含活会话清单, 但不改变任何路由行为。
+
+1. 在 `llm.ts:buildSystemArray` 中, 对 orchestrator agent 注入 `` XML block
+2. 数据来源复用 `sessions.children` + `actorReg.get` + `deriveLiveness` (已有逻辑)
+3. Orchestrator 现在能"看到"活会话列表, 但仍使用旧的 create-first 流程
+4. **验证**: Orchestrator 的回复中能引用具体会话 ID 和状态 (证明它看到了清单)
+
+**风险**: 注入增加 system prompt 大小。需要监控 token 使用。活会话数量通常 <10, 增量 <500 tokens。
+
+### Phase 2: orchestrator.txt 重写 (prompt 层, 改变行为)
+
+**Goal**: 通过 prompt 引导, 让 AI 优先 route-to-existing 而非 create。这是 **主体工作**。
+
+1. 重写 orchestrator.txt 的核心循环和决策指引
+2. 新增 "Route Decision" section: AI 如何从 `` 中选择目标
+3. 将 `send` 提升为主要操作, `create` 标注为 fallback
+4. 移除旧的 topic-based reuse 指引
+5. **验证**: Orchestrator 面对同主题的第二个任务时, 优先 `session send` 到已有会话
+
+**风险**: prompt 引导是"软约束" — LLM 可能仍然偶尔 create。但这是 AI 路由的正确模型: 不是强制, 而是引导。如果引导不够强, 迭代 prompt (加 more explicit examples/constraints) 而非引入工具层匹配。
+
+### Phase 3: 可选加强 (如果 Phase 2 的 prompt 引导不够)
+
+**Goal**: 如果纯 prompt 引导后 Orchestrator 仍然过度 create, 加强引导而非引入匹配。
+
+可能的加强手段 (按优先级):
+1. **更强的 prompt 约束**: 在 orchestrator.txt 中加明确的 "MUST check active-sessions before create" + 反面示例
+2. **create 前拦截**: 在 `session create` 的工具实现中, 如果 `` 中有高度相关的会话, 返回 warning 而非直接创建 (注意: 这仍然是 AI 看到 warning 后自己决定, 不是工具自动匹配)
+3. **指标监控**: 跟踪 create vs send 比率, 如果 create 率过高则迭代 prompt
+
+**不做的事**: 启发式匹配、embedding 相似度、工具层自动路由。这些都违反 "AI routes" 原则。
+
+### Phase 4: 清理 deprecated 路径
+
+1. `--topic` 参数标记 deprecated, 保留向后兼容但不再推荐
+2. `topicOf` / `tagTitle` 辅助函数标记 deprecated
+3. orchestrator.txt 中移除旧的 topic-based reuse 指引
+4. 更新 harness 文档 (`docs/harness/MiMo Orchestrator Mode.md`)
+
+## Scope Boundaries
+
+- **本设计不涉及**: 并发路由冲突处理 (多个 Orchestrator 实例路由到同一会话)、跨 Orchestrator 会话路由、session 持久化 schema 变更
+- **本设计不实现**: 只出设计文档 + 实施路线, 不改产品代码
+- **向后兼容**: `session create` 保持可用, `--topic` 保留但 deprecated, 现有 Orchestrator 行为在 Phase 1-2 期间不变
+
+## Key Decisions
+
+- **Orchestrator 是用户的代理人, 不是传声筒**: 核心身份从 "message router" 升级为 "user's agent/proxy"。三项职责 (dispatch/act-for-user/audit-quality) 共同构成完整的代理身份, 而非独立功能列表。
+- **AI 做所有决策, 工具只提供信息+执行**: 路由决策、权限判断、质量审查全部由 AI 做。工具层不实现任何匹配逻辑, 也不代替用户做判断。
+- **不需要新的 tool verb**: 所有三项职责都映射到现有 session tool primitives (send/create/approve/grant-approval/ask/join/status/dashboard)。最小化代码变更。
+- **context injection 而非 on-demand query**: 活会话清单注入 system prompt, 让 Orchestrator 每次 turn 都能看到全貌 — 降低认知负担。
+- **prompt 引导而非硬编码**: 所有行为 (路由、权限决策、质量审查) 通过 prompt 迭代优化, 而非工具层强制。
+
+## Dependencies / Assumptions
+
+- Orchestrator 当前是 experimental (flag-gated), 本 redesign 在 experimental 阶段实施, 无需 migration
+- `sessions.children` + `actorReg.get` + `deriveLiveness` 已经提供了足够的会话状态数据
+- 活会话数量通常 <20, context injection 的 token 开销可接受
+
+## References
+
+- `packages/opencode/src/tool/session.ts` — session tool 实现 (create/send/list/ask/approve/grant-approval/join/status/dashboard)
+- `packages/opencode/src/session/prompt/orchestrator.txt` — orchestrator 系统提示词
+- `packages/opencode/src/session/llm.ts:240-306` — system prompt 组装 (buildSystemArray)
+- `packages/opencode/src/agent/agent.ts:231-251` — orchestrator agent 定义
+- `packages/opencode/src/agent/config.ts:7-46` — `decideAskRouting` 权限转发决策
+- `packages/opencode/src/permission/permission-forward-ref.ts` — 权限转发/授权 ref + 去重
+- `docs/harness/MiMo Orchestrator Mode.md` — orchestrator 模式文档
+- PR #1727 — 去掉 topic 字符串匹配 (止血, 非本 redesign)
diff --git a/package.json b/package.json
index 8d01a8d4b..38a7572a9 100644
--- a/package.json
+++ b/package.json
@@ -129,6 +129,7 @@
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
- "@opentui/core@0.1.101": "patches/@opentui%2Fcore@0.1.101.patch"
+ "@opentui/core@0.1.101": "patches/@opentui%2Fcore@0.1.101.patch",
+ "@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch"
}
}
diff --git a/packages/app/package.json b/packages/app/package.json
index 047510011..c51b74089 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -1,6 +1,6 @@
{
"name": "@mimo-ai/app",
- "version": "0.1.9",
+ "version": "0.1.10",
"description": "",
"type": "module",
"exports": {
diff --git a/packages/console/app/package.json b/packages/console/app/package.json
index e40fa563e..a753a564c 100644
--- a/packages/console/app/package.json
+++ b/packages/console/app/package.json
@@ -1,6 +1,6 @@
{
"name": "@mimo-ai/console-app",
- "version": "0.1.9",
+ "version": "0.1.10",
"type": "module",
"license": "MIT",
"scripts": {
diff --git a/packages/console/core/package.json b/packages/console/core/package.json
index e99f91a7c..598723a73 100644
--- a/packages/console/core/package.json
+++ b/packages/console/core/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@mimo-ai/console-core",
- "version": "0.1.9",
+ "version": "0.1.10",
"private": true,
"type": "module",
"license": "MIT",
diff --git a/packages/console/function/package.json b/packages/console/function/package.json
index 0a11a7c06..eaa56bea7 100644
--- a/packages/console/function/package.json
+++ b/packages/console/function/package.json
@@ -1,6 +1,6 @@
{
"name": "@mimo-ai/console-function",
- "version": "0.1.9",
+ "version": "0.1.10",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json
index 1bc1e3e7f..231bb2a49 100644
--- a/packages/console/mail/package.json
+++ b/packages/console/mail/package.json
@@ -1,6 +1,6 @@
{
"name": "@mimo-ai/console-mail",
- "version": "0.1.9",
+ "version": "0.1.10",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
diff --git a/packages/desktop/package.json b/packages/desktop/package.json
index bd619ed4f..160a73979 100644
--- a/packages/desktop/package.json
+++ b/packages/desktop/package.json
@@ -1,7 +1,7 @@
{
"name": "@mimo-ai/desktop",
"private": true,
- "version": "0.1.9",
+ "version": "0.1.10",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json
index 1885291eb..2d6550973 100644
--- a/packages/enterprise/package.json
+++ b/packages/enterprise/package.json
@@ -1,6 +1,6 @@
{
"name": "@mimo-ai/enterprise",
- "version": "0.1.9",
+ "version": "0.1.10",
"private": true,
"type": "module",
"license": "MIT",
diff --git a/packages/function/package.json b/packages/function/package.json
index 19bce5d0f..e78eb02a8 100644
--- a/packages/function/package.json
+++ b/packages/function/package.json
@@ -1,6 +1,6 @@
{
"name": "@mimo-ai/function",
- "version": "0.1.9",
+ "version": "0.1.10",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
diff --git a/packages/opencode/migration/20260729000000_actor_registry_last_activity_time/migration.sql b/packages/opencode/migration/20260729000000_actor_registry_last_activity_time/migration.sql
new file mode 100644
index 000000000..cde928534
--- /dev/null
+++ b/packages/opencode/migration/20260729000000_actor_registry_last_activity_time/migration.sql
@@ -0,0 +1 @@
+ALTER TABLE `actor_registry` ADD COLUMN `last_activity_time` integer;
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index 8fc24d151..f15cfdcc9 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
- "version": "0.1.9",
+ "version": "0.1.10",
"name": "@mimo-ai/cli",
"type": "module",
"license": "MIT",
diff --git a/packages/opencode/src/actor/actor.sql.ts b/packages/opencode/src/actor/actor.sql.ts
index 5bf50f41c..17dda3c2b 100644
--- a/packages/opencode/src/actor/actor.sql.ts
+++ b/packages/opencode/src/actor/actor.sql.ts
@@ -24,6 +24,14 @@ export const ActorRegistryTable = sqliteTable(
tools: text({ mode: "json" }).$type(),
last_turn_time: integer().notNull(),
turn_count: integer().notNull().default(0),
+ // Last time ANY part write landed for this actor's (session_id, agent_id)
+ // slice — the last moment something actually succeeded, at per-API-call
+ // granularity instead of per-completed-step. Nullable on purpose: rows that
+ // predate this column, and a row read in the instant between register() and
+ // its first part write, have genuinely recorded no activity, and NULL states
+ // that rather than inventing a timestamp. Written by the PartUpdated
+ // projector (session/projectors.ts), the single writer of `part` rows.
+ last_activity_time: integer(),
last_error: text(),
instance_id: text().notNull(),
time_completed: integer(),
diff --git a/packages/opencode/src/actor/events.ts b/packages/opencode/src/actor/events.ts
index c9c83e680..f48b488f7 100644
--- a/packages/opencode/src/actor/events.ts
+++ b/packages/opencode/src/actor/events.ts
@@ -41,17 +41,21 @@ export const ActorStuck = BusEvent.define(
)
// Emitted by the T40 stall watchdog when a background peer/subagent transitions
-// into `stalled` liveness (running/pending but no turn advance past the stall
-// window) and the one-shot parent notification is pushed. Fires once per stall
-// episode — re-arms only after the child resumes (turnCount advances) or reaches
-// terminal. Observability hook for tests + TUI.
+// into `stalled` liveness (running/pending but nothing has landed for the actor's
+// slice past the stall window) and the one-shot parent notification is pushed.
+// Fires once per stall episode — re-arms only once activity lands again or the
+// child reaches terminal. Observability hook for tests + TUI.
+// `lastActivityTime` is the reference the verdict was actually computed from (the
+// last part write, or spawn time when nothing has landed yet), NOT last_turn_time:
+// a payload reporting a different quantity than its own predicate used is how this
+// signal came to be misread.
export const ActorStalled = BusEvent.define(
"actor.stalled",
z.object({
sessionID: SessionID.zod,
actorID: z.string(),
description: z.string(),
- lastTurnTime: z.number(),
+ lastActivityTime: z.number(),
stalledDuration: z.number(),
}),
)
diff --git a/packages/opencode/src/actor/registry.ts b/packages/opencode/src/actor/registry.ts
index 55f0c87a2..ac27c6280 100644
--- a/packages/opencode/src/actor/registry.ts
+++ b/packages/opencode/src/actor/registry.ts
@@ -3,6 +3,7 @@ import { Database, inArray, eq, and, lte, sql } from "@/storage"
import { Bus } from "@/bus"
import type { SessionID, MessageID } from "@/session/schema"
import { ActorRegistryTable } from "./actor.sql"
+import { SessionTable } from "@/session/session.sql"
import type { Actor, ActorStatus, ActorOutcome, ContextMode, Lifecycle, SpawnMode, ToolWhitelist, Liveness } from "./schema"
import { deriveLiveness } from "./schema"
import * as Events from "./events"
@@ -40,6 +41,7 @@ function fromRow(row: ActorRow): Actor {
tools: row.tools ?? undefined,
lastTurnTime: row.last_turn_time,
turnCount: row.turn_count,
+ lastActivityTime: row.last_activity_time ?? undefined,
lastError: row.last_error ?? undefined,
time: {
created: row.time_created,
@@ -87,6 +89,13 @@ export interface Interface {
readonly listBySession: (sessionID: SessionID) => Effect.Effect
readonly listActive: () => Effect.Effect
readonly listByParent: (sessionID: SessionID, parentActorID: string) => Effect.Effect
+ // Peer CHILD sessions of a parent session, joined to their session title.
+ // Peers key their registry row by their own child session id, so the parent
+ // link lives on the Session row (parent_id) — not on session_id here.
+ readonly listPeerChildren: (
+ parentSessionID: SessionID,
+ parentActorID: string,
+ ) => Effect.Effect<{ actor: Actor; title: string }[]>
readonly renderForAgent: (sessionID: SessionID) => Effect.Effect
readonly agentTypeFor: (sessionID: SessionID, actorID: string) => Effect.Effect
readonly isSystemSpawned: (sessionID: SessionID, actorID: string) => Effect.Effect
@@ -137,6 +146,10 @@ export const layer: Layer.Layer = Layer.effect(
tools: input.tools ?? null,
last_turn_time: now,
turn_count: 0,
+ // No part has landed for this actor yet. NULL, not `now`: deriveLiveness
+ // falls back to time_created when activity is absent, so seeding a fake
+ // activity timestamp here would assert something happened that did not.
+ last_activity_time: null,
last_error: null,
instance_id: instanceID,
time_completed: null,
@@ -322,6 +335,36 @@ export const layer: Layer.Layer = Layer.effect(
return rows.map(fromRow)
})
+ // Peer children register with session_id === actor_id === their OWN child
+ // session id (Actor.spawnPeer), so listByParent — which filters on
+ // session_id === the parent's id — can never match them. The reliable
+ // parent link is the Session row's parent_id. Join on it so a caller with
+ // no Session.Service (e.g. the LLM layer building the orchestrator's
+ // fleet roster) can still enumerate its peer children, and
+ // carry the child's title along since that is the routing signal.
+ const listPeerChildren = Effect.fn("ActorRegistry.listPeerChildren")(function* (
+ parentSessionID: SessionID,
+ parentActorID: string,
+ ) {
+ const rows = yield* Effect.sync(() =>
+ Database.use((db) =>
+ db
+ .select({ actor: ActorRegistryTable, title: SessionTable.title })
+ .from(ActorRegistryTable)
+ .innerJoin(SessionTable, eq(SessionTable.id, ActorRegistryTable.session_id))
+ .where(
+ and(
+ eq(SessionTable.parent_id, parentSessionID),
+ eq(ActorRegistryTable.mode, "peer"),
+ eq(ActorRegistryTable.parent_actor_id, parentActorID),
+ ),
+ )
+ .all(),
+ ),
+ )
+ return rows.map((row) => ({ actor: fromRow(row.actor), title: row.title }))
+ })
+
const renderForAgent = Effect.fn("ActorRegistry.renderForAgent")(function* (sessionID: SessionID) {
const actors = yield* listBySession(sessionID)
const active = actors.filter((actor) => actor.background && (actor.status === "pending" || actor.status === "running"))
@@ -476,6 +519,7 @@ export const layer: Layer.Layer = Layer.effect(
listBySession,
listActive,
listByParent,
+ listPeerChildren,
renderForAgent,
agentTypeFor,
isSystemSpawned,
diff --git a/packages/opencode/src/actor/schema.ts b/packages/opencode/src/actor/schema.ts
index e49337db3..bc5ab437f 100644
--- a/packages/opencode/src/actor/schema.ts
+++ b/packages/opencode/src/actor/schema.ts
@@ -36,6 +36,10 @@ export const Actor = z
tools: ToolWhitelist.optional(),
lastTurnTime: z.number(),
turnCount: z.number(),
+ // Last part write for this actor's slice. Optional because the column is
+ // nullable — see actor.sql.ts. This is the liveness evidence; lastTurnTime
+ // and turnCount are step bookkeeping and are NOT read by deriveLiveness.
+ lastActivityTime: z.number().optional(),
lastError: z.string().optional(),
time: z.object({
created: z.number(),
@@ -47,39 +51,137 @@ export const Actor = z
export type Actor = z.infer
// Derived liveness: a pull-side signal computed from an actor row's honest
-// registry fields (status, lastOutcome, lastTurnTime). It answers the question
-// raw `status` cannot — is a running child PROGRESSING or STALLED?
-// - progressing: running/pending AND its last turn advanced within the
-// staleness window (updateTurn bumps last_turn_time per step, so a recent
-// last_turn_time == recent progress). Also covers a not-yet-started child
-// (turnCount === 0): its last_turn_time is the spawn time, so a slow first
-// turn (queued behind the concurrency gate, model cold-start) must NOT be
-// mistaken for a stall — it has not had the chance to run even once.
-// - stalled: running/pending, HAS run at least one turn, BUT no turn advance
-// for longer than the window.
+// registry fields (status, lastOutcome, lastActivityTime). It answers the
+// question raw `status` cannot — is a running child PROGRESSING or STALLED?
+//
+// The evidence is LAST ACTIVITY, not last completed step. `last_activity_time`
+// advances on every part write for the actor's slice (session/projectors.ts),
+// so a child inside a long tool call, a slow model call or a retry/backoff keeps
+// advancing it; only a child where nothing at all is landing goes quiet. The
+// previous signal was `last_turn_time`, whose sole writer is the per-step
+// heartbeat ActorRegistry.updateTurn — so the finest thing it could see was a
+// COMPLETED step, and a child blocked mid-step was indistinguishable from a dead
+// one. That coarseness is why the bound below used to be 30 minutes.
+// - progressing: running/pending AND activity within the staleness window.
+// - stalled: running/pending, but nothing has landed for longer than the
+// window. Still routable — it means "quiet", not "dead".
// - success | failure | cancelled: terminal, taken straight from lastOutcome.
-// - idle: finished with no recorded outcome (or an unknown state).
+// - idle: finished with no recorded outcome, an unknown state, OR a row whose
+// claim to be running/pending has outlived DEFAULT_LIVENESS_ABANDON_MS — see
+// that constant for why an unbounded claim is not honest.
// Never fabricates: every value maps 1:1 to fields the engine actually wrote.
export const Liveness = z.enum(["progressing", "stalled", "success", "failure", "cancelled", "idle"])
export type Liveness = z.infer
-// Default staleness threshold: a running child with no turn advance for this
-// long is reported `stalled`. 90s sits between the per-step turn cadence and
-// the 5-minute stuck-detection cutoff, so a briefly-thinking child still reads
-// as progressing while a genuinely wedged one flips to stalled well before the
-// watchdog (T40) would fire.
-export const DEFAULT_LIVENESS_STALL_MS = 90_000
+// Default staleness threshold: a running child with no ACTIVITY for this long is
+// reported `stalled` (still routable — a display distinction, not a verdict) and
+// is the condition the T40 watchdog notifies the child's parent about.
+//
+// 6 minutes, not the 90s this was. 90s was picked against the per-step cadence,
+// where it meant "no COMPLETED STEP for 90s". Read against activity the same
+// number means "no PART WRITE for 90s", a far stronger claim of silence, and the
+// measured distribution says that claim is false too often: across 43,120 real
+// inter-activity gaps on a 172-child roster the gaps run p50 994ms / p90 6.7s /
+// p99 38.0s / p99.9 296.8s, so 90s lands strictly BETWEEN p99 and p99.9 —
+// between 0.1% and 1% of gaps produced by perfectly HEALTHY children exceed it.
+// That was not hypothetical: two children sitting inside single long steps (`bun
+// ci`, a full test suite, `git worktree add`) emitted a dozen-plus "appears
+// stalled" notifications at ~90-130s of apparent silence while writing parts
+// continuously. A channel that cries wolf teaches its reader to ignore it, which
+// costs exactly the true stall the channel exists to surface.
+//
+// Bounded from below by two measurements, not by taste:
+// - the deepest measured natural silence, p99.9 = 296,811ms. A threshold at or
+// under that fires on healthy children by construction.
+// - plus ACTIVITY_COALESCE_MS (below). Recorded activity lags reality by up to
+// one coalesce interval, so apparent age is the real gap plus up to 5s; the
+// threshold has to clear p99.9 + 5s = 301,811ms or the coalescing lag ALONE
+// can flip a tail-but-healthy row. That is what disqualifies the otherwise
+// tidy 300_000 (== registry.ts STUCK_THRESHOLD_MS): 300,000 < 301,811.
+// Bounded from above by DEFAULT_LIVENESS_ABANDON_MS (600s), which must stay the
+// larger of the two or `stalled` becomes unreachable.
+//
+// 360_000 is 1.21x p99.9, clears p99.9 + coalesce by 58.2s (11.6 coalesce
+// intervals), is 72x ACTIVITY_COALESCE_MS, and is 0.6x the abandonment bound —
+// leaving a 240s `stalled` band, ~5 scans at WATCHDOG_SCAN_INTERVAL_MS (45s).
+// The cost is accepted deliberately: a genuine stall now surfaces within ~6
+// minutes instead of ~90 seconds. The abandonment bound still catches a row whose
+// owner is actually gone, and a signal believed late beats one not believed.
+export const DEFAULT_LIVENESS_STALL_MS = 6 * 60_000
+
+// Abandonment bound: how long a row may keep CLAIMING `running`/`pending` before
+// we stop believing it. `progressing` and `stalled` both read as "in progress" to
+// every consumer (the orchestrator roster, `session list`, the fleet table) — they
+// mark a child as routable and imply something is already in flight. That claim
+// needs an upper bound, because the only repair for a row whose owner died is
+// ActorRegistry's orphan sweep, and that sweep runs ONCE at process init and only
+// for rows carrying a DIFFERENT instance_id; until it runs — and for any row it
+// cannot reach — the row asserts progress with nothing behind it.
+//
+// 10 minutes. This replaces a 30-minute bound that existed only because the old
+// signal was step-grained: a single legitimate step in this repo can run 20+
+// minutes (a live test run ~1225s), so any bound had to clear that. Activity
+// granularity removes that constraint, and the number is anchored to measurement
+// rather than to the longest possible step:
+// - 2x STUCK_THRESHOLD_MS (registry.ts) — the repo's own existing "stuck" cutoff
+// — rather than a fresh magic number;
+// - ~16x the measured p99 inter-activity gap (38.0s) and ~2x p99.9 (296.8s) over
+// 43,120 real gaps across a 172-child roster;
+// - ~345x the worst measured first-activity latency after spawn (1735ms, n=172,
+// p50 194ms), which is what licensed deleting the old turnCount === 0 case:
+// the "slow first turn queued behind the concurrency gate" it protected is
+// empirically under two seconds, not minutes, because the user message that
+// starts the turn is itself persisted as a part.
+// Direction of error is unchanged and deliberate: prefer a duplicate child
+// (wasteful) over routing into a corpse with re-dispatch suppressed
+// (unrecoverable). Past the bound we report `idle` — "finished with no recorded
+// outcome (or an unknown state)", the honest reading and the only non-routable
+// bucket.
+export const DEFAULT_LIVENESS_ABANDON_MS = 10 * 60_000
+
+// Write-coalescing interval for the `last_activity_time` heartbeat, applied as a
+// staleness guard in the PartUpdated projector's WHERE (session/projectors.ts):
+// the row is touched only when it records no activity yet, or when the activity
+// it records is already older than this. At most one UPDATE per actor per
+// interval. The column's meaning is unchanged — only its resolution is capped.
+//
+// Needed because the heartbeat rides the part-write path, which is unthrottled.
+// `ctx.metadata` in the bash tool (tool/bash.ts, per decoded stdout chunk)
+// reaches Session.updatePart via SessionProcessor.updateToolCall
+// (session/processor.ts) with no interval check anywhere on the way, so a chatty
+// command drove a measured 539-867 registry UPDATEs/sec — each carrying a
+// correlated subquery over `message` — strictly 1:1 with part upserts.
+//
+// 5 seconds, derived from the two consumers of the column, both of which are
+// three orders of magnitude coarser than that write rate:
+// - DEFAULT_LIVENESS_STALL_MS (360s, above): the progressing/stalled display.
+// An actively-writing actor's recorded activity now lags reality by at most
+// this interval, so worst-case apparent age is 5s against a 360s threshold —
+// 72x of margin, and the flip is unreachable by coalescing alone.
+// - DEFAULT_LIVENESS_ABANDON_MS (600s, above): the routable/idle bound. 120x
+// of margin.
+// Also sits above the measured p50 inter-activity gap (994ms) so it actually
+// coalesces the dense traffic it targets, while staying below p90 (6.7s) so an
+// ordinarily-paced child still records very nearly every activity it has.
+// Updating more often than this buys no consumer anything: nothing reads the
+// column at a finer resolution than tens of seconds.
+export const ACTIVITY_COALESCE_MS = 5_000
export function deriveLiveness(
- actor: Pick,
+ actor: Pick,
now: number = Date.now(),
stallMs: number = DEFAULT_LIVENESS_STALL_MS,
+ abandonMs: number = DEFAULT_LIVENESS_ABANDON_MS,
): Liveness {
if (actor.status === "running" || actor.status === "pending") {
- // Not-yet-started child (no turn completed): last_turn_time is the spawn
- // time, so a slow first turn (queued/cold-start) is not a stall.
- if (actor.turnCount === 0) return "progressing"
- return now - actor.lastTurnTime <= stallMs ? "progressing" : "stalled"
+ // One reference for every row, no per-row special case: the last thing that
+ // landed, or — when nothing has landed yet — the spawn time. `?? ` (not
+ // `=== undefined`) because the column is nullable, so this value arrives as
+ // `null` for pre-migration rows and a `!== undefined` guard would silently
+ // pass them through. See AGENTS.md "Reading a nullable column".
+ const since = actor.lastActivityTime ?? actor.time.created
+ if (now - since > abandonMs) return "idle"
+ return now - since <= stallMs ? "progressing" : "stalled"
}
if (actor.lastOutcome === "success") return "success"
if (actor.lastOutcome === "failure") return "failure"
diff --git a/packages/opencode/src/actor/spawn.ts b/packages/opencode/src/actor/spawn.ts
index 734672466..283e4a590 100644
--- a/packages/opencode/src/actor/spawn.ts
+++ b/packages/opencode/src/actor/spawn.ts
@@ -19,6 +19,7 @@ import { SYSTEM_SPAWNED_AGENT_TYPES } from "@/agent/config"
import { Bus } from "@/bus"
import { TuiEvent } from "@/cli/cmd/tui/event"
import { MessageV2 } from "@/session/message-v2"
+import { SessionRetry } from "@/session/retry"
import { Inbox } from "@/inbox"
import { renderActorNotification } from "@/inbox/render"
import { Plugin, HookEvent } from "@/plugin"
@@ -39,10 +40,10 @@ export const MAX_PRE_REACT = 3
/** Cap on postStop ReAct re-entries per spawn. See MAX_PRE_REACT TODO. */
export const MAX_POST_REACT = 3
/**
- * T40 stall watchdog scan cadence. Sits between the per-step turn heartbeat and
- * the DEFAULT_LIVENESS_STALL_MS (90s) window, and just under the registry's own
- * 60s stuck-scan, so a genuinely stalled child is caught within ~one window of
- * flipping to `stalled` without hammering the DB.
+ * T40 stall watchdog scan cadence. Well inside the DEFAULT_LIVENESS_STALL_MS (6m)
+ * window and just under the registry's own 60s stuck-scan, so a genuinely stalled
+ * child is caught within one scan of flipping to `stalled` without hammering the
+ * DB.
*/
export const WATCHDOG_SCAN_INTERVAL_MS = 45_000
const RETURN_FORMAT_INSTRUCTION = `
@@ -120,9 +121,88 @@ export type AgentOutcome =
// only when reportedStatus was downgraded to "partial"/"blocked".
incompleteTasks?: string[]
}
- | { status: "failure"; error: string }
+ | { status: "failure"; error: string; failure?: FailureInfo }
| { status: "cancelled" }
+/**
+ * Coarse, provider-agnostic category of a settled failure. Deliberately small:
+ * a consumer needs to answer "will this recur identically?", not to know which
+ * provider spelled which body which way. The provider-specific taxonomy has
+ * already been collapsed upstream by MessageV2.fromError, which runs
+ * ProviderError.parseAPICallError / isOverflow / isOpenAiErrorRetryable.
+ */
+export type FailureKind = "transient" | "overflow" | "auth" | "aborted" | "other"
+
+/**
+ * Classification carried on a `failure` outcome so a consumer can branch without
+ * string-matching `error`.
+ *
+ * PRESENT only when the failure came from a settled assistant error — i.e. the
+ * child's turn ran and persisted a normalized named error. ABSENT when the work
+ * fiber failed some other way (a defect during teardown, or a hand-built outcome
+ * such as checkpoint's "timeout"): there is no provider taxonomy to report and
+ * asserting one would be a lie. Read it with truthiness / `== null`, never
+ * `=== undefined` (AGENTS.md, "Reading a nullable column").
+ *
+ * `retryable` means "belonged to the retryable class", NOT "please retry". The
+ * child's LLM calls already run through SessionRetry's ladder (session/retry.ts,
+ * consumed by session/llm.ts and session/processor.ts), so any failure reaching
+ * a consumer is ALREADY post-retry.
+ */
+export interface FailureInfo {
+ readonly kind: FailureKind
+ readonly retryable: boolean
+ /** The persisted NamedError name, e.g. "APIError" / "ContextOverflowError". */
+ readonly name: string
+}
+
+/**
+ * Classify a settled assistant error where the typed error still exists.
+ *
+ * Reuses SessionRetry.retryable as the retryability oracle rather than adding a
+ * taxonomy: its input type IS this data shape (`Err` === `NamedError.toObject()`)
+ * and it already folds in isRetryableTransientError plus every 429 / 5xx / quota
+ * special case. `kind` is then read off the named-error identity, which is what
+ * MessageV2.fromError derived from ProviderError.parseAPICallError.
+ */
+function classifyAssistantError(err: SessionRetry.Err): FailureInfo {
+ // Truthiness, not `!== undefined`: retryable() returns a status *message*, and
+ // an empty one is not a usable retry signal.
+ const retryable = !!SessionRetry.retryable(err)
+ // 401/403 read off the statusCode ProviderError.parseAPICallError already
+ // extracted — not a new taxonomy and not a re-parse of prose. MessageV2.AuthError
+ // ("ProviderAuthError") only covers a MISSING key (LoadAPIKeyError); a rejected
+ // one arrives as an APIError, and both are the same thing to a consumer.
+ const status = MessageV2.APIError.isInstance(err) ? err.data.statusCode : undefined
+ const kind: FailureKind = MessageV2.ContextOverflowError.isInstance(err)
+ ? "overflow"
+ : MessageV2.AuthError.isInstance(err) || status === 401 || status === 403
+ ? "auth"
+ : MessageV2.AbortedError.isInstance(err)
+ ? "aborted"
+ : retryable
+ ? "transient"
+ : "other"
+ return { kind, retryable, name: err.name }
+}
+
+/**
+ * Raised by runAgentLoop when the child's turn settled with an assistant error.
+ * Exists solely to carry the classification across the Effect failure channel to
+ * forkWork's onFailure — that is the only place AgentOutcome is built, and the
+ * typed error is not reachable from there. Deliberately a plain Error subclass:
+ * Cause.pretty renders it byte-identically to `new Error(message)`, so the human
+ * `error` string is unchanged.
+ */
+class AssistantSettledError extends Error {
+ constructor(
+ message: string,
+ readonly failure: FailureInfo,
+ ) {
+ super(message)
+ }
+}
+
export interface SpawnInput {
mode: SpawnMode
sessionID: SessionID
@@ -274,7 +354,15 @@ export const layer = Layer.effect(
// duplicating the result downstream. See spec §5.2.
const info = (result as MessageV2.WithParts | undefined)?.info
if (info?.role === "assistant" && info.error) {
- return yield* Effect.fail(new Error(`Actor assistant failed: ${info.error.name}`))
+ // Classify HERE: `info.error` is the persisted, already-normalized named
+ // error. Downstream (forkWork's onFailure) only has Cause.pretty's string,
+ // so re-deriving the class there would mean re-parsing prose.
+ return yield* Effect.fail(
+ new AssistantSettledError(
+ `Actor assistant failed: ${info.error.name}`,
+ classifyAssistantError(info.error),
+ ),
+ )
}
const structured = info?.role === "assistant" ? info.structured : undefined
const finalText =
@@ -642,10 +730,18 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const cancelled = Cause.hasInterruptsOnly(cause)
const error = Cause.pretty(cause)
+ // Recover the classification runAgentLoop attached. Squash is the
+ // established idiom here (see session/prompt.ts, tool/shell-wrap.ts).
+ // A failure raised anywhere else carries none, and the field stays
+ // absent rather than being guessed from `error`.
+ const squashed = Cause.squash(cause)
+ const failure = squashed instanceof AssistantSettledError ? squashed.failure : undefined
yield* notify(cancelled ? "cancelled" : "failed", cancelled ? {} : { error })
yield* Deferred.succeed(
outcome,
- cancelled ? { status: "cancelled" as const } : { status: "failure" as const, error },
+ cancelled
+ ? { status: "cancelled" as const }
+ : { status: "failure" as const, error, ...(failure ? { failure } : {}) },
)
yield* Effect.sync(() => forkContexts.delete(input.actorID))
}),
@@ -750,13 +846,13 @@ export const layer = Layer.effect(
forkContexts.set(actorID, input.forkContext)
}
- // Auto-inject return-format instruction for non-specialized subagents.
- // Excluded: agents with hardcoded `prompt` (explore/title/summary — own
- // contracts), checkpoint-writer (special — task is itself a complete
- // writer-instruction string), and peer mode (routes via spawnPeer).
+ // Auto-inject return-format instruction for lifecycle-managed subagents.
+ // Agents with an explicit completionGate keep this behavior even when
+ // they also provide a dedicated system prompt.
const agentInfo = yield* agents.get(input.agentType)
const gateEligible =
- agentInfo?.mode === "subagent" && !agentInfo?.prompt && input.agentType !== "checkpoint-writer"
+ agentInfo?.mode === "subagent" &&
+ (agentInfo.completionGate === true || (!agentInfo.prompt && input.agentType !== "checkpoint-writer"))
const taskWithFormat = gateEligible ? input.task + RETURN_FORMAT_INSTRUCTION : input.task
const { fiber, outcome } = yield* forkWork({
@@ -864,17 +960,17 @@ export const layer = Layer.effect(
// Event-driven stall detection: a background fiber periodically scans active
// background actors (ActorRegistry.listActive → pending/running + background),
// computes deriveLiveness for each, and when a PEER/subagent flips to
- // `stalled` (running/pending but now-lastTurnTime > DEFAULT_LIVENESS_STALL_MS
- // AND turnCount not advancing — deriveLiveness encodes exactly that) pushes
- // ONE actor_notification{stalled} to its parent. Reuses the notifyTerminal
- // shape (inbox.send actor_notification + renderActorNotification + a TUI
- // toast) so stalled joins completed/failed/cancelled on one contract.
+ // `stalled` (running/pending but nothing has landed for the actor's slice for
+ // longer than DEFAULT_LIVENESS_STALL_MS — deriveLiveness encodes exactly that)
+ // pushes ONE actor_notification{stalled} to its parent. Reuses the
+ // notifyTerminal shape (inbox.send actor_notification + renderActorNotification
+ // + a TUI toast) so stalled joins completed/failed/cancelled on one contract.
//
// Debounce — the crux: `notified` holds the "sessionID:actorID" of actors we
// have ALREADY warned about for their CURRENT stall episode. We emit only on
// the not-yet-notified → stalled edge; while it STAYS stalled across ticks it
// is in `notified` and we skip. We re-arm (delete the key) the moment the
- // actor is no longer stalled — it resumed (turnCount advanced so
+ // actor is no longer stalled — it resumed (activity landed again, so
// deriveLiveness reads `progressing`), went terminal, or vanished — so a
// later re-stall notifies again. One notification per stall episode.
const notified = new Set()
@@ -910,13 +1006,16 @@ export const layer = Layer.effect(
sessionID: actor.sessionID,
actorID: actor.actorID,
description: actor.description,
- lastTurnTime: actor.lastTurnTime,
+ // Same reference the classification used, for the same reason the
+ // notification carries it: an observability payload that reports the
+ // step clock while the predicate read the activity clock is a trap.
+ lastActivityTime: actor.lastActivityTime ?? actor.time.created,
stalledDuration: stalledForMs,
})
.pipe(Effect.ignore)
yield* Effect.promise(() =>
Bus.publish(TuiEvent.ToastShow, {
- message: `Child "${actor.description}" appears stalled`,
+ message: `Child "${actor.description}" appears stalled (no activity for ${Math.floor(stalledForMs / 1000)}s)`,
variant: "info",
}),
).pipe(Effect.ignore)
@@ -933,7 +1032,11 @@ export const layer = Layer.effect(
if (live === "stalled") {
if (notified.has(key)) continue // already warned this episode — debounce
notified.add(key)
- yield* notifyStalled(actor, now - actor.lastTurnTime)
+ // Report the quantity the classification actually used — silence since
+ // the last part write, or since spawn when nothing has landed — not
+ // time since the last completed step, which deriveLiveness no longer
+ // reads. A number that disagrees with its own predicate is a bug.
+ yield* notifyStalled(actor, now - (actor.lastActivityTime ?? actor.time.created))
continue
}
// Not stalled (progressing/terminal) → re-arm so a future re-stall notifies.
@@ -993,12 +1096,12 @@ export const layer = Layer.effect(
// "Cannot access 'defaultLayer' before initialization", breaking every
// it.live test harness. Same pattern session/prompt, session/checkpoint,
// tool/registry, provider, etc. already use.
-export const defaultLayer = Layer.suspend(() =>
+/** App composition variant with SessionPrompt supplied by the root graph. */
+export const appLayer = Layer.suspend(() =>
layer.pipe(
Layer.provide(Session.defaultLayer),
Layer.provide(ActorRegistry.defaultLayer),
Layer.provide(Agent.defaultLayer),
- Layer.provide(SessionPrompt.defaultLayer),
Layer.provide(SessionRunState.defaultLayer),
Layer.provide(Inbox.defaultLayer),
Layer.provide(Plugin.defaultLayer),
@@ -1007,4 +1110,6 @@ export const defaultLayer = Layer.suspend(() =>
),
)
+export const defaultLayer = appLayer.pipe(Layer.provide(SessionPrompt.defaultLayer))
+
export * as Actor from "./spawn"
diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts
index f6fedc0a2..afcd51a1e 100644
--- a/packages/opencode/src/agent/agent.ts
+++ b/packages/opencode/src/agent/agent.ts
@@ -6,10 +6,13 @@ import { ModelID, ProviderID } from "../provider/schema"
import { generateObject, streamObject, type ModelMessage } from "ai"
import { Instance } from "../project/instance"
import { Truncate } from "../tool"
+import { usesGPTToolset } from "../tool/gpt"
import { Auth } from "../auth"
import { ProviderTransform } from "../provider"
import PROMPT_GENERATE from "./generate.txt"
+import PROMPT_GENERATE_GPT from "./prompt/generate-gpt.txt"
+import PROMPT_GENERAL from "./prompt/general.txt"
import PROMPT_EXPLORE from "./prompt/explore.txt"
import PROMPT_DREAM from "./prompt/dream.txt"
import PROMPT_DISTILL from "./prompt/distill.txt"
@@ -52,6 +55,7 @@ export const Info = z
modelRef: z.string().optional(),
variant: z.string().optional(),
prompt: z.string().optional(),
+ completionGate: z.boolean().optional(),
options: z.record(z.string(), z.any()),
steps: z.number().int().positive().optional(),
toolAllowlist: z.array(z.string()).optional(),
@@ -108,9 +112,7 @@ export const layer = Layer.effect(
skill: {
"*": "allow",
"compose:*": "deny",
- "compose-next": "deny",
},
- plan_enter: "deny",
plan_exit: "deny",
external_directory: {
"*": "ask",
@@ -138,7 +140,6 @@ export const layer = Layer.effect(
defaults,
Permission.fromConfig({
question: "allow",
- plan_enter: "allow",
plan_exit: "allow",
}),
user,
@@ -178,7 +179,6 @@ export const layer = Layer.effect(
defaults,
Permission.fromConfig({
question: "allow",
- plan_enter: "allow",
plan_exit: "allow",
external_directory: {
[path.join(Global.Path.data, "plans", "*")]: "allow",
@@ -254,16 +254,13 @@ export const layer = Layer.effect(
general: {
name: "general",
color: "#aac4e1",
- description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
- permission: Permission.merge(
- defaults,
- Permission.fromConfig({
- change_directory: "deny",
- }),
- user,
- ),
+ description:
+ "Full-capability general-purpose subagent for autonomous read/write work, including investigation, implementation, debugging, testing, and multi-step delivery. It inherits the parent's available tool surface and can complete a delegated task end to end.",
+ permission: Permission.merge(defaults, user),
options: {},
mode: "subagent",
+ prompt: PROMPT_GENERAL,
+ completionGate: true,
native: true,
},
explore: {
@@ -492,7 +489,9 @@ export const layer = Layer.effect(
const agent = agents[name]
const globs = whitelistedDirs.filter(
(glob) =>
- !agent.permission.some((r) => r.permission === "external_directory" && r.action === "deny" && r.pattern === glob),
+ !agent.permission.some(
+ (r) => r.permission === "external_directory" && r.action === "deny" && r.pattern === glob,
+ ),
)
if (globs.length === 0) continue
@@ -567,7 +566,7 @@ export const layer = Layer.effect(
? Option.getOrUndefined(yield* Effect.serviceOption(OtelTracer.OtelTracer))
: undefined
- const system = [PROMPT_GENERATE]
+ const system = [PROMPT_GENERATE, ...(usesGPTToolset(resolved.id) ? [PROMPT_GENERATE_GPT] : [])]
yield* plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system })
const existing = yield* InstanceState.useEffect(state, (s) => s.list())
diff --git a/packages/opencode/src/agent/generate.txt b/packages/opencode/src/agent/generate.txt
index 387a7f967..0ab4839c6 100644
--- a/packages/opencode/src/agent/generate.txt
+++ b/packages/opencode/src/agent/generate.txt
@@ -41,12 +41,12 @@ When a user describes what they want an agent to do, you will:
assistant: "Here is the relevant function: "
- Since the user is greeting, use the actor tool to launch the greeting-responder agent to respond with a friendly joke.
+ Since the user is asking for a code review, use the actor tool to launch the code-reviewer agent after the implementation is complete.
assistant: "Now let me use the code-reviewer agent to review the code"
-
- Context: User is creating an agent to respond to the word "hello" with a friendly jok.
+ Context: User is creating an agent to respond to the word "hello" with a friendly joke.
user: "Hello"
assistant: "I'm going to use the actor tool to launch the greeting-responder agent to respond with a friendly joke"
@@ -54,7 +54,7 @@ When a user describes what they want an agent to do, you will:
- If the user mentioned or implied that the agent should be used proactively, you should include examples of this.
-- NOTE: Ensure that in the examples, you are making the assistant use the Agent tool and not simply respond directly to the task.
+- NOTE: Ensure that in the examples, you are making the assistant use the actor tool and not simply respond directly to the task.
Your output must be a valid JSON object with exactly these fields:
{
diff --git a/packages/opencode/src/agent/prompt/explore.txt b/packages/opencode/src/agent/prompt/explore.txt
index 5761077cb..5f1d78294 100644
--- a/packages/opencode/src/agent/prompt/explore.txt
+++ b/packages/opencode/src/agent/prompt/explore.txt
@@ -1,4 +1,6 @@
-You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
+You are a file search specialist working for a parent agent. You excel at thoroughly navigating and exploring codebases.
+
+The delegated search request comes from the parent agent, and your result is returned to that parent agent. Do not address the end user, send user-facing progress updates, ask the end user questions, or offer follow-up work.
Your strengths:
- Rapidly finding files using glob patterns
@@ -6,13 +8,12 @@ Your strengths:
- Reading and analyzing file contents
Guidelines:
-- Use Glob for broad file pattern matching
-- Use Grep for searching file contents with regex
-- Use Read when you know the specific file path you need to read
-- Use Bash for file operations like copying, moving, or listing directory contents
+- Use the file-search, content-search, file-reading, and shell tools exposed in the current turn. Tool names and availability are model-specific; never invent or assume a tool that is not in the current tool list.
+- For broad file pattern matching, use the exposed glob/file-listing capability. For content searches, use the exposed grep/search capability. When those dedicated tools are unavailable, use the shell tool with targeted `rg` or `rg --files` commands.
+- Use the shell tool for read-only file operations such as listing directories or inspecting metadata. Do not modify files or repository state.
- Adapt your search approach based on the thoroughness level specified by the caller
-- Return file paths as absolute paths in your final response
+- Return file paths as absolute paths in your final result to the parent agent
- For clear communication, avoid using emojis
- Do not create any files, or run bash commands that modify the user's system state in any way
-Complete the user's search request efficiently and report your findings clearly.
+Complete the delegated search request efficiently and report your findings clearly to the parent agent.
diff --git a/packages/opencode/src/agent/prompt/general.txt b/packages/opencode/src/agent/prompt/general.txt
new file mode 100644
index 000000000..92f56140d
--- /dev/null
+++ b/packages/opencode/src/agent/prompt/general.txt
@@ -0,0 +1,17 @@
+You are a full-capability general-purpose subagent for MiMoCode, Xiaomi's official CLI for MiMo. A parent agent has delegated a bounded task to you. Own that task and complete it end to end.
+
+You inherit the tool surface available from the parent runtime. The tools exposed in this turn are the source of truth. Use any of them needed for the assignment, including reading and searching, editing or creating files, running commands, inspecting visual assets, and validating the result. Tool names vary by model, so never invent a tool or assume a legacy tool is present.
+
+Work autonomously:
+
+- Inspect the relevant implementation, tests, configuration, instructions, and current workspace state before making consequential changes.
+- For implementation tasks, make the smallest complete change that satisfies the request, preserve unrelated user changes, and follow established project patterns.
+- Carry work through verification. Run focused tests or checks first, broaden them when the change has wider risk, and report any check you could not run.
+- For investigation or review tasks, return concrete evidence with file and line references. Do not modify files unless the delegated task includes implementation or fixes.
+- Use read and write capabilities freely when they are required by the task. Do not stop at recommendations when the assignment asks for a working change.
+- Keep external side effects within the authority granted by the parent task. Do not publish, push, message people, or perform destructive operations unless explicitly authorized.
+- You may delegate genuinely independent, bounded subtasks when that improves throughput, but do not hand your entire assignment to another agent.
+
+The parent agent, not you, communicates with the end user. Do not ask the end user questions or send user-facing progress updates. If essential information is missing, investigate first; if still blocked, explain the exact blocker in your final response to the parent.
+
+When finished, respond with a concise report of the outcome, verification, files changed, and any residual risk or blocker. The caller will relay the relevant parts to the user.
diff --git a/packages/opencode/src/agent/prompt/generate-gpt.txt b/packages/opencode/src/agent/prompt/generate-gpt.txt
new file mode 100644
index 000000000..0377337c7
--- /dev/null
+++ b/packages/opencode/src/agent/prompt/generate-gpt.txt
@@ -0,0 +1,7 @@
+GPT generation compatibility rules:
+
+- The generated agent will run with the tools exposed by its selected model. Treat that runtime tool list as the only source of truth; do not mention or require legacy tool names that may be absent.
+- For delegation examples, use the `actor` tool and its `subagent_type` field. Do not write `Agent tool`, `Agent`, or another invented tool name.
+- For GPT-5-family agents, describe file inspection through `exec` with targeted `rg`, `rg --files`, and `sed -n` commands when a dedicated file tool is not exposed. Describe edits through `apply_patch`, and visual inspection through `view_image` when relevant.
+- Keep generated instructions model-agnostic where possible. Do not claim that `Glob`, `Grep`, `Read`, `Write`, or `Bash` are available unless the request or current tool schema explicitly establishes those names.
+- Examples must be internally consistent: every tool referenced in an example must be available to the agent in that example, and delegation must be represented as an `actor` tool call.
diff --git a/packages/opencode/src/agent/prompt/gpt-tools.txt b/packages/opencode/src/agent/prompt/gpt-tools.txt
deleted file mode 100644
index 96ad055ed..000000000
--- a/packages/opencode/src/agent/prompt/gpt-tools.txt
+++ /dev/null
@@ -1,10 +0,0 @@
-# GPT subagent tools
-
-The tools exposed in this turn are the source of truth. GPT-5-family agents use a model-specific tool set, so do not call legacy file tools that are absent from the tool list.
-
-- Use `exec` as the main composition surface when you need to batch independent tool calls or compactly transform their results. Run independent calls with `Promise.all` or `Promise.allSettled`, keep dependent calls sequential, and return only the evidence needed by the caller. Call one small tool directly instead of wrapping it in `exec`.
-- Code inside `exec` is the body of an async JavaScript/TypeScript function. Use only the declared `tools`, `files`, and `console` globals. Conversation-control tools are unavailable inside `exec` and must be called directly.
-- Use `apply_patch` for project text edits. Provide the complete patch in `patch_text`; do not create or modify project files through `exec` raw file helpers.
-- Use `view_image` to inspect local JPEG, PNG, GIF, or WebP files when visual analysis is needed.
-- When `read`, `grep`, or `glob` are not exposed, use `bash` with targeted `rg`, `rg --files`, and `sed -n` commands for codebase exploration. Keep every command read-only when the subagent's role is read-only.
-- `exec` never broadens permissions: its nested calls have the same model-, agent-, and permission-filtered tool set as direct calls.
diff --git a/packages/opencode/src/cli/cmd/generate.ts b/packages/opencode/src/cli/cmd/generate.ts
index 21b4b31fc..6d70b9820 100644
--- a/packages/opencode/src/cli/cmd/generate.ts
+++ b/packages/opencode/src/cli/cmd/generate.ts
@@ -1,5 +1,6 @@
import { Server } from "../../server/server"
import type { CommandModule } from "yargs"
+import { UI } from "../ui"
export const GenerateCommand = {
command: "generate",
@@ -41,7 +42,7 @@ export const GenerateCommand = {
// Wait for stdout to finish writing before process.exit() is called
await new Promise((resolve, reject) => {
- process.stdout.write(json, (err) => {
+ process.stdout.write(UI.withTrailingEOL(json), (err) => {
if (err) reject(err)
else resolve()
})
diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts
index 137cf1c99..e888227fc 100644
--- a/packages/opencode/src/cli/cmd/run.ts
+++ b/packages/opencode/src/cli/cmd/run.ts
@@ -353,11 +353,6 @@ export const RunCommand = cmd({
action: "deny",
pattern: "*",
},
- {
- permission: "plan_enter",
- action: "deny",
- pattern: "*",
- },
{
permission: "plan_exit",
action: "deny",
diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx
index 4125836b0..8c74b4e7e 100644
--- a/packages/opencode/src/cli/cmd/tui/app.tsx
+++ b/packages/opencode/src/cli/cmd/tui/app.tsx
@@ -56,6 +56,7 @@ import { Session as SessionApi } from "@/session"
import { orchestratorDir } from "@/global"
import { TuiEvent } from "./event"
import { KVProvider, useKV } from "./context/kv"
+import { resolveVisualMode, toggleVisualMode } from "./context/visual"
import { LanguageProvider, UiI18nBridge, useLanguage } from "./context/language"
import type { Locale } from "./i18n/locales"
import { LOCALES } from "./i18n/locales"
@@ -537,23 +538,20 @@ function App(props: { onSnapshot?: () => Promise }) {
sdk.switchDirectory(dir)
await sync.bootstrap()
}
- const existing = sync.data.session
- .toSorted((a, b) => b.time.updated - a.time.updated)
- .find((x) => x.parentID === undefined)?.id
- if (existing) {
- local.orchestrator.setSessionID(existing)
- // A `-s` launch wanted to land IN the orchestrator session; a plain
- // Tab-into-orchestrator from a stale launch-dir session wanted Home
- // (the fresh-entry state). Either way navigate exactly once, AFTER
- // bootstrap, so the switched view resolves directly to its target with
- // no intermediate frame — the root now exists in orchestratorDir.
- if (resumeIntoSession) route.navigate({ type: "session", sessionID: existing })
- else if (switching) route.navigate({ type: "home" })
- } else {
- const res = await sdk.client.session.create({})
- if (res.data?.id) local.orchestrator.setSessionID(res.data.id)
- if (switching) route.navigate({ type: "home" })
- }
+ // Authoritative resolve-or-create against the switched directory. Reading
+ // sync.data.session here raced bootstrap's NON-blocking session list —
+ // bootstrap resolves before the list lands, so the lookup missed the
+ // existing root and minted another one on every entry.
+ const root = await sync.session.resolveRoot()
+ if (root.id) local.orchestrator.setSessionID(root.id)
+ // A `-s` launch wanted to land IN the orchestrator session; a plain
+ // Tab-into-orchestrator from a stale launch-dir session wanted Home
+ // (the fresh-entry state). Either way navigate exactly once, AFTER
+ // bootstrap, so the switched view resolves directly to its target with
+ // no intermediate frame — the root now exists in orchestratorDir. A root
+ // we just created is empty, so resuming into it makes no sense: go Home.
+ if (root.id && !root.created && resumeIntoSession) route.navigate({ type: "session", sessionID: root.id })
+ else if (switching) route.navigate({ type: "home" })
} catch (e) {
toast.show({ message: `Failed to enter Orchestrator: ${e}`, variant: "error" })
} finally {
@@ -922,6 +920,28 @@ function App(props: { onSnapshot?: () => Promise }) {
},
category: "system",
},
+ {
+ title: t(
+ resolveVisualMode(kv.get("visual_mode", "vivid")) === "vivid"
+ ? "tui.command.visual_mode.title_on"
+ : "tui.command.visual_mode.title_off",
+ ),
+ value: "app.toggle.visual_mode",
+ slash: {
+ name: "vivid",
+ },
+ category: "system",
+ onSelect: (dialog) => {
+ const next = toggleVisualMode(kv.get("visual_mode", "vivid"))
+ kv.set("visual_mode", next)
+ toast.show({
+ message: t(next === "vivid" ? "tui.visual_mode.enabled" : "tui.visual_mode.disabled"),
+ variant: "info",
+ duration: 3000,
+ })
+ dialog.clear()
+ },
+ },
{
title: t("tui.command.theme.switch_mode.to_dark"),
value: "theme.switch_mode.dark",
diff --git a/packages/opencode/src/cli/cmd/tui/component/background-image.tsx b/packages/opencode/src/cli/cmd/tui/component/background-image.tsx
index 428bdeb57..3cc0c99d7 100644
--- a/packages/opencode/src/cli/cmd/tui/component/background-image.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/background-image.tsx
@@ -7,6 +7,7 @@ import { PNG } from "pngjs"
import jpeg from "jpeg-js"
import path from "path"
import { allocImageId, detectImageProtocol, kittyClear, kittyDisplay } from "../util/image-protocol"
+import { useVisualMode } from "../context/visual"
const HALF_BLOCK = "▀"
const PROTOCOL = detectImageProtocol()
@@ -99,6 +100,7 @@ function BackgroundImageKitty(props: { path: string }) {
}
function BackgroundImageHalfBlock(props: { path: string }) {
+ const visual = useVisualMode()
const dimensions = useTerminalDimensions()
const { theme } = useTheme()
const [pixels] = createResource(
@@ -141,7 +143,14 @@ function BackgroundImageHalfBlock(props: { path: string }) {
})
return (
- }>
+
+
+
+ }
+ >
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx
index b9f8baec7..cbeed2249 100644
--- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx
@@ -11,6 +11,7 @@ import { useSDK } from "../context/sdk"
import { useLanguage } from "../context/language"
import { Flag } from "@/flag/flag"
import { isSystemSession } from "@/session/auto-dream"
+import { classifySession } from "@/session/visibility"
import { DialogSessionRename } from "./dialog-session-rename"
import { Keybind } from "@/util"
import { createDebouncedSignal } from "../util/signal"
@@ -112,15 +113,40 @@ export function DialogSessionList() {
))
}
+ // A child session is listed only if the render prohibition would allow it to be
+ // opened. The actor rows come from the sync store rather than a fetch on
+ // purpose: a host is only ever IN that store because it was created during this
+ // TUI's lifetime (bootstrap loads roots only, and sync.sync() loads children
+ // with `visible: true`), and the same lifetime delivers its `actor.registered`
+ // event — so the rows this reads are present for exactly the population that
+ // can leak. `undefined` means "no rows", which classifySession renders.
+ const listable = (x: { id: string; parentID?: string }) =>
+ classifySession(x, sync.data.actor?.[x.id]).renderable
+
const options = createMemo(() => {
const today = new Date().toDateString()
const current = currentSessionID()
// Top-level sessions, plus the CURRENT session's children (e.g. Orchestrator
// child sessions) so the user can discover and switch into them. Other
// sessions' children stay hidden to keep the list focused.
+ //
+ // The child arm needs the visibility predicate on top of the parent test.
+ // `sync.data.session` is NOT already filtered: sync.sync() merges children
+ // fetched with `visible: true` (sync.tsx), but `session.updated` inserts
+ // EVERY session it sees (sync.tsx, "session.updated" arm) — and a
+ // checkpoint-writer host is created with its title already set
+ // (`title: "checkpoint-writer: …"`, session/checkpoint.ts), so it arrives on
+ // that path and lands in the store. Filtering only on `parentID === current`
+ // therefore listed one `↳ checkpoint-writer: …` row per checkpoint.
+ //
+ // classifySession is the same predicate the route's render gate uses, so the
+ // list cannot disagree with what opening the entry would do. It fails OPEN
+ // (no actor rows ⇒ listed), which is what keeps orchestrator `session create`
+ // children — including the `[topic:…]` ones — listed: they own a mode "peer"
+ // row and are returned renderable outright.
const isChildOfCurrent = (x: { parentID?: string }) => current !== undefined && x.parentID === current
return sessions()
- .filter((x) => x.parentID === undefined || isChildOfCurrent(x))
+ .filter((x) => x.parentID === undefined || (isChildOfCurrent(x) && listable(x)))
.toSorted((a, b) => {
const updatedDay = new Date(b.time.updated).setHours(0, 0, 0, 0) - new Date(a.time.updated).setHours(0, 0, 0, 0)
if (updatedDay !== 0) return updatedDay
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-worktree.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-worktree.tsx
index 8ae41e5d0..c0d27ce9e 100644
--- a/packages/opencode/src/cli/cmd/tui/component/dialog-worktree.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-worktree.tsx
@@ -5,6 +5,7 @@ import { useSDK } from "../context/sdk"
import { useSync } from "@tui/context/sync"
import { useRoute } from "@tui/context/route"
import { useToast } from "../ui/toast"
+import { isDirectoryDeniedError } from "@/server/routes/instance/access"
import path from "path"
const CREATE_SENTINEL = "__create_worktree__"
@@ -53,9 +54,31 @@ export function DialogWorktree() {
async function switchTo(directory: string) {
setBusy("Switching to worktree...")
+ const previous = sdk.directory
await sdk.client.instance.dispose().catch(() => {})
sdk.switchDirectory(directory)
- await sync.bootstrap()
+ // The server rejects any directory outside its cwd (instance middleware 403).
+ // That used to propagate out of bootstrap into the TUI's fatal-exit path and
+ // kill the whole session; treat it as a recoverable error: point the SDK back
+ // at the directory that was working, re-sync, and tell the user which path was
+ // refused and why.
+ const failure = await sync.bootstrap().then(
+ () => undefined,
+ (e) => e,
+ )
+ if (failure) {
+ if (previous) sdk.switchDirectory(previous)
+ await sync.bootstrap({ fatal: false }).catch(() => {})
+ setBusy(undefined)
+ dialog.clear()
+ toast.show({
+ message: isDirectoryDeniedError(failure)
+ ? `Cannot switch to ${directory}: outside this server's working directory`
+ : `Failed to switch to ${path.basename(directory)}`,
+ variant: "error",
+ })
+ return
+ }
route.navigate({ type: "home" })
dialog.clear()
toast.show({ message: `Switched to ${path.basename(directory)}`, variant: "success" })
diff --git a/packages/opencode/src/cli/cmd/tui/component/logo.tsx b/packages/opencode/src/cli/cmd/tui/component/logo.tsx
index ca8a29b6c..0c8549ae5 100644
--- a/packages/opencode/src/cli/cmd/tui/component/logo.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/logo.tsx
@@ -1,6 +1,7 @@
import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core"
-import { For, createMemo, createSignal, onCleanup, onMount, type JSX } from "solid-js"
+import { For, createEffect, createMemo, createSignal, onCleanup, type JSX } from "solid-js"
import { useTheme, tint } from "@tui/context/theme"
+import { useVisualMode } from "@tui/context/visual"
import * as Sound from "@tui/util/sound"
import { go, logo } from "@/cli/logo"
@@ -576,7 +577,7 @@ function buildIdleState(t: number, ctx: LogoContext): IdleState {
return { cfg, reach, rings, active }
}
-export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean; sweep?: boolean } = {}) {
+export function Logo(props: { shape?: LogoShape; ink?: RGBA; animated?: boolean; idle?: boolean; sweep?: boolean } = {}) {
const ctx = props.shape ? build(props.shape) : DEFAULT
const { theme } = useTheme()
const [rings, setRings] = createSignal([])
@@ -638,8 +639,7 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean; swe
timer = setInterval(tick, 16)
}
- onCleanup(() => {
- stop()
+ const stopSweep = () => {
if (sweepStart) {
clearTimeout(sweepStart)
sweepStart = undefined
@@ -648,21 +648,46 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean; swe
clearInterval(sweepTimer)
sweepTimer = undefined
}
+ setSweep(undefined)
+ }
+
+ createEffect(() => {
+ if (props.animated === false || !props.sweep) {
+ stopSweep()
+ return
+ }
+ if (sweepStart || sweepTimer) return
+ sweepStart = setTimeout(() => {
+ sweepStart = undefined
+ if (!props.sweep) return
+ fireSweep()
+ sweepTimer = setInterval(fireSweep, SWEEP_INTERVAL)
+ }, 1500)
+ })
+
+ createEffect(() => {
+ if (props.animated !== false) {
+ if (props.idle) {
+ setNow(performance.now())
+ start()
+ }
+ return
+ }
+ stopSweep()
+ setRings([])
+ setHold(undefined)
+ setRelease(undefined)
+ setGlow(undefined)
+ stop()
hum = false
Sound.dispose()
})
- onMount(() => {
- if (props.idle) {
- setNow(performance.now())
- start()
- }
- if (props.sweep) {
- sweepStart = setTimeout(() => {
- fireSweep()
- sweepTimer = setInterval(fireSweep, SWEEP_INTERVAL)
- }, 1500)
- }
+ onCleanup(() => {
+ stop()
+ stopSweep()
+ hum = false
+ Sound.dispose()
})
const hit = (x: number, y: number) => {
@@ -883,6 +908,7 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean; swe
}
const mouse = (evt: MouseEvent) => {
+ if (props.animated === false) return
if (!box) return
if ((evt.type === "down" || evt.type === "drag") && evt.button === MouseButton.LEFT) {
const x = evt.x - box.x
@@ -956,6 +982,7 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean; swe
export function GoLogo() {
const { theme } = useTheme()
+ const visual = useVisualMode()
const base = tint(theme.background, theme.text, 0.62)
- return
+ return
}
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/footer.ts b/packages/opencode/src/cli/cmd/tui/component/prompt/footer.ts
new file mode 100644
index 000000000..352d8c160
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/footer.ts
@@ -0,0 +1,22 @@
+import { Locale } from "@/util"
+
+/**
+ * Cell budget for the ephemeral status message in the prompt footer. Sized so
+ * the message plus the spinner still leaves room for `esc interrupt` and the
+ * context counter on an 80-column terminal.
+ */
+export const STATUS_MESSAGE_MAX = 48
+
+/**
+ * The footer packs the spinner + status message onto the same row as the context
+ * counter (`52.4K/960K (5%)`). A long server-supplied status string wrapped over
+ * several lines and squeezed that row until the counter rendered clipped
+ * (`52.4K/96`). Clamp the message — and flatten any newlines — so a status
+ * string can never cost the counter its cells.
+ */
+export function clampStatusMessage(message: string | undefined) {
+ if (!message) return undefined
+ const flat = message.replace(/\s+/g, " ").trim()
+ if (!flat) return undefined
+ return Locale.truncate(flat, STATUS_MESSAGE_MAX)
+}
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
index 2b5e0e789..e79dad5fd 100644
--- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
@@ -18,6 +18,7 @@ import { useKeybind } from "@tui/context/keybind"
import { usePromptHistory, type PromptInfo } from "./history"
import { assign, expandPlaceholders } from "./part"
import { usePromptStash } from "./stash"
+import { clampStatusMessage } from "./footer"
import { DialogStash } from "../dialog-stash"
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
import { useCommandDialog } from "../dialog-command"
@@ -31,7 +32,7 @@ import * as Clipboard from "../../util/clipboard"
import type { AssistantMessage, FilePart, UserMessage } from "@mimo-ai/sdk/v2"
import { TuiEvent } from "../../event"
import { iife } from "@/util/iife"
-import { Locale, Token } from "@/util"
+import { Locale } from "@/util"
import { formatDuration } from "@/util/format"
import { SessionRetry } from "@/session/retry"
import { createColors, createFrames } from "../../ui/spinner.ts"
@@ -40,7 +41,9 @@ import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
import { DialogAlert } from "../../ui/dialog-alert"
import { DialogPrompt } from "../../ui/dialog-prompt"
import { useToast } from "../../ui/toast"
+import { createPress } from "../../ui/press"
import { useKV } from "../../context/kv"
+import { useVisualMode } from "../../context/visual"
import { createFadeIn } from "../../util/signal"
import { useTextareaKeybindings } from "../textarea-keybindings"
import { DialogSkill } from "../dialog-skill"
@@ -133,7 +136,8 @@ export function Prompt(props: PromptProps) {
const renderer = useRenderer()
const { theme, syntax } = useTheme()
const kv = useKV()
- const animationsEnabled = createMemo(() => kv.get("animations_enabled", true))
+ const visual = useVisualMode()
+ const animationsEnabled = visual.motion
const voiceEnabled = createMemo(() => kv.get("voice_enabled", false))
const voiceSendEnabled = createMemo(() => kv.get("voice_send_command", false))
const voiceControlEnabled = createMemo(() => kv.get("voice_control_enabled", false))
@@ -366,6 +370,8 @@ export function Prompt(props: PromptProps) {
setVoiceState("listening")
}
+ const voicePress = createPress(() => void voiceToggle())
+
const list = createMemo(() => props.placeholders?.normal ?? [])
const shell = createMemo(() => props.placeholders?.shell ?? [])
const [auto, setAuto] = createSignal()
@@ -470,25 +476,29 @@ export function Prompt(props: PromptProps) {
const usage = createMemo(() => {
if (!props.sessionID) return
const msg = sync.data.message[props.sessionID]?.["main"] ?? []
+ // Resolve the window from the last measured assistant turn's model, matching
+ // the record `computeContextUsage` reads for the token count.
const last = msg.findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0)
if (!last) return
-
- const tokens =
- last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
- if (tokens <= 0) return
-
const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID]
const win = Model.contextWindow(sync.data.config, model)
- const cost = msg.reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0)
+ // A /rebuild boundary is a message carrying a `checkpoint` part (stored in
+ // sync.data.part, keyed by message id). Its `coveredUpTo` is the watermark it
+ // collapsed up to; computeContextUsage uses that (not message order) to decide
+ // the measured turn is stale and report pending until the next assistant turn.
+ const result = Model.computeContextUsage({
+ messages: msg,
+ window: win,
+ checkpointCoverage: (id) =>
+ (sync.data.part[id] ?? []).find((p) => p.type === "checkpoint")?.coveredUpTo,
+ })
+ if (!result) return
return {
- // Denominator is the compaction trigger, not the raw window — otherwise the
- // percentage never reaches 100% and a configured budget looks ignored.
- context: win
- ? `${Locale.number(tokens)}/${Token.format(win.usable)}${win.source === "config" ? "↓" : ""} (${Math.round(
- (tokens / win.usable) * 100,
- )}%)`
- : Locale.number(tokens),
- cost: cost > 0 ? money.format(cost) : undefined,
+ // computeContextUsage owns the pending placeholder (it renders `—/`
+ // so the footer stops asserting the pre-rebuild fill while keeping the
+ // frame), so `context` is the final string in every case — render it as-is.
+ context: result.context,
+ cost: result.cost > 0 ? money.format(result.cost) : undefined,
}
})
@@ -1826,22 +1836,22 @@ export function Prompt(props: PromptProps) {
- voiceToggle()}>
+
{"[ 🎙 Voice ]"}
- voiceToggle()}>
+
{"[ 🎙 -:-- ]"}
- voiceToggle()}>
+
{`[ 🎙 ${Math.floor(voiceElapsed() / 60)}:${String(voiceElapsed() % 60).padStart(2, "0")} ]`}
- voiceToggle()}>
+
{"[ 🎙 .... ]"}
@@ -1890,18 +1900,20 @@ export function Prompt(props: PromptProps) {
>
- [⋯]}>
+ ⋯}>
{(() => {
const busyMessage = createMemo(() => {
const s = status()
- return s.type === "busy" ? s.message : undefined
+ return s.type === "busy" ? clampStatusMessage(s.message) : undefined
})
return (
- {busyMessage()}
+
+ {busyMessage()}
+
)
})()}
@@ -1992,7 +2004,10 @@ export function Prompt(props: PromptProps) {
{(item) => (
-
+ // flexShrink=0: the context counter is the one number the
+ // footer must never clip (`52.4K/96` instead of
+ // `52.4K/960K`); the hints beside it can give way first.
+
{[item().context, item().cost].filter(Boolean).join(" · ")}
)}
diff --git a/packages/opencode/src/cli/cmd/tui/component/spinner.tsx b/packages/opencode/src/cli/cmd/tui/component/spinner.tsx
index 8dc545550..8ca2198de 100644
--- a/packages/opencode/src/cli/cmd/tui/component/spinner.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/spinner.tsx
@@ -1,6 +1,6 @@
import { Show } from "solid-js"
import { useTheme } from "../context/theme"
-import { useKV } from "../context/kv"
+import { useVisualMode } from "../context/visual"
import type { JSX } from "@opentui/solid"
import type { RGBA } from "@opentui/core"
import "opentui-spinner/solid"
@@ -9,10 +9,10 @@ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "
export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
const { theme } = useTheme()
- const kv = useKV()
+ const visual = useVisualMode()
const color = () => props.color ?? theme.textMuted
return (
- ⋯ {props.children}}>
+ ⋯ {props.children}}>
diff --git a/packages/opencode/src/cli/cmd/tui/component/starry-background.tsx b/packages/opencode/src/cli/cmd/tui/component/starry-background.tsx
index 730f31e91..4e39daa8c 100644
--- a/packages/opencode/src/cli/cmd/tui/component/starry-background.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/starry-background.tsx
@@ -66,7 +66,7 @@ function brailleBit(col: number, row: number): number {
return row === 3 ? 7 : 3 + row
}
-export function StarryBackground(props: { meteor?: () => boolean } = {}) {
+export function StarryBackground(props: { animated?: () => boolean; meteor?: () => boolean } = {}) {
const { theme } = useTheme()
const [field, setField] = createSignal({ grid: [], brightness: [] })
const [size, setSize] = createSignal({ w: 80, h: 24 })
@@ -77,7 +77,7 @@ export function StarryBackground(props: { meteor?: () => boolean } = {}) {
let frameTimer: ReturnType | undefined
let box: BoxRenderable | undefined
let text: TextRenderable | undefined
- let mounted = false
+ const [mounted, setMounted] = createSignal(false)
const sync = () => {
if (!box) return
@@ -88,12 +88,26 @@ export function StarryBackground(props: { meteor?: () => boolean } = {}) {
setField(generateField(next.w, next.h))
}
- onMount(() => {
- mounted = true
- sync()
- box?.on("resize", sync)
+ const stopMotion = () => {
+ if (timer) {
+ clearInterval(timer)
+ timer = undefined
+ }
+ if (meteorTimer) {
+ clearInterval(meteorTimer)
+ meteorTimer = undefined
+ }
+ if (frameTimer) {
+ clearInterval(frameTimer)
+ frameTimer = undefined
+ }
+ setMeteor(undefined)
+ }
+
+ const startMotion = () => {
+ if (timer || meteorTimer) return
timer = setInterval(() => {
- if (!mounted) return
+ if (!mounted()) return
const { w, h } = size()
setField((prev) => {
const next = { grid: prev.grid, brightness: [...prev.brightness.map((r) => [...r])] }
@@ -112,7 +126,7 @@ export function StarryBackground(props: { meteor?: () => boolean } = {}) {
})
}, TWINKLE_INTERVAL)
meteorTimer = setInterval(() => {
- if (!mounted) return
+ if (!mounted()) return
if (props.meteor && !props.meteor()) return
const { w, h } = size()
const startY = Math.floor(Math.random() * 2)
@@ -125,7 +139,7 @@ export function StarryBackground(props: { meteor?: () => boolean } = {}) {
})
if (frameTimer) clearInterval(frameTimer)
frameTimer = setInterval(() => {
- if (!mounted) {
+ if (!mounted()) {
if (frameTimer) clearInterval(frameTimer)
frameTimer = undefined
return
@@ -141,23 +155,26 @@ export function StarryBackground(props: { meteor?: () => boolean } = {}) {
}
}, METEOR_FRAME_INTERVAL)
}, METEOR_INTERVAL)
+ }
+
+ createEffect(() => {
+ if (!mounted() || (props.animated && !props.animated())) {
+ stopMotion()
+ return
+ }
+ startMotion()
+ })
+
+ onMount(() => {
+ sync()
+ box?.on("resize", sync)
+ setMounted(true)
})
onCleanup(() => {
- mounted = false
+ setMounted(false)
box?.off("resize", sync)
- if (timer) {
- clearInterval(timer)
- timer = undefined
- }
- if (meteorTimer) {
- clearInterval(meteorTimer)
- meteorTimer = undefined
- }
- if (frameTimer) {
- clearInterval(frameTimer)
- frameTimer = undefined
- }
+ stopMotion()
})
const isDark = createMemo(() => {
diff --git a/packages/opencode/src/cli/cmd/tui/component/task-item.tsx b/packages/opencode/src/cli/cmd/tui/component/task-item.tsx
index f1daa8b50..bd1d0f071 100644
--- a/packages/opencode/src/cli/cmd/tui/component/task-item.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/task-item.tsx
@@ -1,6 +1,6 @@
import { Show } from "solid-js"
import { useTheme } from "../context/theme"
-import { useKV } from "../context/kv"
+import { useVisualMode } from "../context/visual"
import "opentui-spinner/solid"
// Inlined (not the shared ) so the animated glyph occupies exactly
@@ -18,7 +18,7 @@ export interface TaskItemProps {
export function TaskItem(props: TaskItemProps) {
const { theme } = useTheme()
- const kv = useKV()
+ const visual = useVisualMode()
const running = () => props.status === "in_progress"
const glyph =
props.status === "done"
@@ -47,7 +47,7 @@ export function TaskItem(props: TaskItemProps) {
[•}
>
diff --git a/packages/opencode/src/cli/cmd/tui/context/project.tsx b/packages/opencode/src/cli/cmd/tui/context/project.tsx
index 9e98eabad..ab98b286c 100644
--- a/packages/opencode/src/cli/cmd/tui/context/project.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/project.tsx
@@ -35,11 +35,21 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
async function sync() {
const workspace = store.workspace.current
+ const directory = sdk.directory
const [path, project] = await Promise.all([
sdk.client.path.get({ workspace }),
sdk.client.project.current({ workspace }),
])
+ // A directory switch (worktree dialog, orchestrator entry) disposes the old
+ // instance and bootstraps the new one, and the resulting
+ // server.instance.disposed event fires a SECOND bootstrap whose requests
+ // were built from the pre-switch client. That stale run can resolve last
+ // and describe a directory the client no longer talks to; writing it makes
+ // instance.path disagree with sdk.directory, which silently drops every
+ // live event in useEvent (it filters on instance.directory()).
+ if (sdk.directory !== directory) return
+
batch(() => {
setStore("instance", "path", reconcile(path.data || defaultPath))
setStore("project", "id", project.data?.id)
@@ -47,10 +57,17 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
}
async function syncWorkspace() {
+ const directory = sdk.directory
const listed = await sdk.client.experimental.workspace.list().catch(() => undefined)
if (!listed?.data) return
const status = await sdk.client.experimental.workspace.status().catch(() => undefined)
const next = Object.fromEntries((status?.data ?? []).map((item) => [item.workspaceID, item.status]))
+ // Same generation check as sync() above: this runs unguarded inside
+ // bootstrap's non-blocking Promise.all, so a directory switch landing
+ // during either await would otherwise write the old directory's workspace
+ // list — and worse, clear workspace.current because the pre-switch list
+ // does not contain it.
+ if (sdk.directory !== directory) return
batch(() => {
setStore("workspace", "list", reconcile(listed.data))
diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
index 5c4561557..a24660d5c 100644
--- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
@@ -30,6 +30,8 @@ import { useExit } from "./exit"
import { useArgs } from "./args"
import { batch, onMount } from "solid-js"
import { Log } from "@/util"
+import { isDirectoryDeniedError } from "@/server/routes/instance/access"
+import { useToastOptional } from "../ui/toast"
import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
/**
@@ -155,6 +157,56 @@ export function bucketMessages(
return out
}
+/**
+ * A `session.status` event is authoritative for the WHOLE status object.
+ *
+ * Solid's store setter merges plain objects into the existing node
+ * (`mergeStoreNode` only writes `Object.keys(next)`), so writing a bare
+ * `{ type: "busy" }` — which is what the runner emits at the start of every turn
+ * (session/run-state.ts:74) — inherits the `message` of whatever status was
+ * written before it. That latched `/rebuild` outcome text
+ * (session/prompt.ts:4173) into the following turn's spinner. `reconcile()`
+ * drops the fields the new status omits, so each status stands alone.
+ */
+export function nextSessionStatus(status: SessionStatus) {
+ return reconcile(status)
+}
+
+// Pick the bucket the session view should render. `main` is the normal case; a
+// peer child (spawn.ts) runs its turns under agentID == its own sessionID, so
+// attaching to one lands on agentID "main" with an empty main bucket and must
+// fall back to the self-id bucket. A session whose turns ran under an ACTOR id
+// has neither key — its bucket is "build-1" / "compose-1" / "general-1" — so
+// without the last arm it renders a blank pane over a full transcript.
+//
+// ⚠️Do not delete the last arm again. An earlier revision of this branch removed
+// it on the reasoning that its only population was internal machinery. That
+// inference is now backwards: the route refuses a machinery session BEFORE the
+// transcript is selected (routes/session/index.tsx → session/visibility.ts), so
+// this fallback can no longer be the thing that renders a checkpoint-writer
+// transcript. Everything that still reaches it is a session the product has
+// already decided to show. Measured on the live DB, the 1313 sessions this arm
+// serves split 1302 checkpoint-writer hosts (refused upstream, never arrive
+// here) and 11 `session ask` fork-query hosts whose buckets are build-1 ×7,
+// compose-1 ×3, general-1 ×1 — those 11 are model-spawned read-only transcripts
+// and a blank pane for them is the original bug (#1964). Those counts are one
+// read-only local-DB snapshot and they drift — this arm's population grew
+// 1294 → 1313 across this branch's own revisions — so trust the split's shape,
+// not the absolute numbers.
+export function selectMessages(
+ buckets: Record | undefined,
+ agentID: string,
+ sessionID: string,
+): M[] {
+ if (agentID !== "main" || buckets?.["main"]?.length) return buckets?.[agentID] ?? []
+ if (buckets?.[sessionID]?.length) return buckets[sessionID]
+ const newest = Object.entries(buckets ?? {})
+ .filter(([key, msgs]) => key !== "main" && msgs.length > 0)
+ .sort(([, a], [, b]) => (b.at(-1)?.id ?? "").localeCompare(a.at(-1)?.id ?? ""))
+ .at(0)
+ return newest?.[1] ?? []
+}
+
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
init: () => {
@@ -264,14 +316,31 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const event = useEvent()
const project = useProject()
const sdk = useSDK()
+ const toast = useToastOptional()
+
+ // A bootstrap that nobody awaits still must not fail silently when the
+ // server's directory whitelist is the reason. `bootstrap` rethrows the
+ // recoverable policy rejection so an interactive caller can restore the
+ // previous directory and explain itself; the two fire-and-forget callers
+ // below have no such caller, so without this the TUI would sit with stale
+ // data and no indication why. Genuinely fatal failures already exited
+ // inside bootstrap, and anything else is logged there.
+ const reportDenied = (e: unknown) => {
+ if (!isDirectoryDeniedError(e)) return
+ toast?.show({
+ message: `Cannot use ${sdk.directory ?? "this directory"}: outside this server's working directory`,
+ variant: "error",
+ })
+ }
const fullSyncedSessions = new Set()
let syncedWorkspace = project.workspace.current()
+ let syncedDirectory = sdk.directory
event.subscribe((event) => {
switch (event.type) {
case "server.instance.disposed":
- void bootstrap()
+ void bootstrap().catch(reportDenied)
break
case "permission.replied": {
const requests = store.permission[event.properties.sessionID]
@@ -452,7 +521,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}
case "session.status": {
- setStore("session_status", event.properties.sessionID, event.properties.status)
+ setStore("session_status", event.properties.sessionID, nextSessionStatus(event.properties.status))
break
}
@@ -696,10 +765,35 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
async function bootstrap(input: { fatal?: boolean } = {}) {
const fatal = input.fatal ?? true
const workspace = project.workspace.current()
- if (workspace !== syncedWorkspace) {
+ const directory = sdk.directory
+ // fullSyncedSessions exists to keep a re-entered session from refetching its
+ // whole transcript on every navigation. That cache is scoped to the data
+ // source, so it must be dropped whenever the source changes — a workspace
+ // switch OR a directory switch (sdk.switchDirectory). Without the directory
+ // half, a session synced before the switch can never be re-synced, so any
+ // update missed during the switch window stays invisible for the rest of the
+ // session. An unchanged workspace+directory still short-circuits.
+ if (workspace !== syncedWorkspace || directory !== syncedDirectory) {
fullSyncedSessions.clear()
syncedWorkspace = workspace
+ syncedDirectory = directory
}
+ // A bootstrap can outlive the directory it describes: `dispose +
+ // switchDirectory + bootstrap` ALSO re-fires bootstrap from the
+ // `server.instance.disposed` handler above, and that run built its requests
+ // from the PRE-switch client. Staleness therefore has to be re-checked AFTER
+ // each await rather than once before them — a switch landing while these
+ // requests are in flight must not write the old directory's data into the
+ // store, or the store ends up describing a directory sdk no longer talks to.
+ // When no directory was ever set (single-directory mode) nothing can switch
+ // and this is always false. `directory` above is the captured generation.
+ const stale = () => sdk.directory !== directory
+ // Same check for the NON-blocking writes, which each resolve on their own.
+ const guard = (request: Promise, apply: (value: T) => void) =>
+ request.then((value) => {
+ if (stale()) return
+ apply(value)
+ })
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
// roots: true so child sessions (subagents, workers) don't crowd root
// sessions out of the server-side limit
@@ -743,6 +837,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
configResponse,
...(sessionListResponse ? [sessionListResponse] : []),
]).then((responses) => {
+ if (stale()) return
const providers = responses[0]
const providerList = responses[1]
const consoleState = responses[2]
@@ -762,44 +857,58 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
})
})
.then(() => {
+ if (stale()) return
if (store.status !== "complete") setStore("status", "partial")
// non-blocking
void Promise.all([
- ...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]),
- consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))),
- sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))),
- sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", reconcile(x.data ?? []))),
- sdk.client.mcp.status({ workspace }).then((x) => setStore("mcp", reconcile(x.data ?? {}))),
- sdk.client.experimental.resource
- .list({ workspace })
- .then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
- sdk.client.formatter.status({ workspace }).then((x) => setStore("formatter", reconcile(x.data ?? []))),
- sdk.client.session.status({ workspace }).then((x) => {
+ ...(args.continue
+ ? []
+ : [guard(sessionListPromise, (sessions) => setStore("session", reconcile(sessions)))]),
+ guard(consoleStatePromise, (consoleState) => setStore("console_state", reconcile(consoleState))),
+ guard(sdk.client.command.list({ workspace }), (x) => setStore("command", reconcile(x.data ?? []))),
+ guard(sdk.client.lsp.status({ workspace }), (x) => setStore("lsp", reconcile(x.data ?? []))),
+ guard(sdk.client.mcp.status({ workspace }), (x) => setStore("mcp", reconcile(x.data ?? {}))),
+ guard(sdk.client.experimental.resource.list({ workspace }), (x) =>
+ setStore("mcp_resource", reconcile(x.data ?? {})),
+ ),
+ guard(sdk.client.formatter.status({ workspace }), (x) => setStore("formatter", reconcile(x.data ?? []))),
+ guard(sdk.client.session.status({ workspace }), (x) => {
setStore("session_status", reconcile(x.data ?? {}))
}),
- sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
- sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))),
+ guard(sdk.client.provider.auth({ workspace }), (x) => setStore("provider_auth", reconcile(x.data ?? {}))),
+ guard(sdk.client.vcs.get({ workspace }), (x) => setStore("vcs", reconcile(x.data))),
project.workspace.sync(),
]).then(() => {
+ // A superseded run must not declare the CURRENT directory's sync
+ // complete — that would unblock the UI on data it never wrote.
+ if (stale()) return
setStore("status", "complete")
})
})
.catch(async (e) => {
Log.Default.error("tui bootstrap failed", {
- error: e instanceof Error ? e.message : String(e),
+ error: isDirectoryDeniedError(e) ? e.error : e instanceof Error ? e.message : String(e),
name: e instanceof Error ? e.name : undefined,
stack: e instanceof Error ? e.stack : undefined,
})
- if (fatal) {
+ // The server's directory whitelist rejecting the requested directory is a
+ // recoverable policy decision, not a broken TUI: exiting here would take
+ // the user's whole session down over a mistyped/untrusted path. Always
+ // rethrow so the switch caller can restore the previous directory and show
+ // the error. Genuinely fatal bootstrap failures still exit.
+ if (fatal && !isDirectoryDeniedError(e)) {
await exit(e)
- } else {
- throw e
+ return
}
+ throw e
})
}
onMount(() => {
- void bootstrap()
+ // Errors are already logged (and exited on, when fatal) inside bootstrap; the
+ // rethrown recoverable case has no caller here, so swallow it rather than
+ // emitting an unhandled rejection.
+ void bootstrap().catch(reportDenied)
})
const result = {
@@ -828,6 +937,25 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
setStore("session", reconcile(list))
},
+ // Resolve THE root session of the directory the client currently talks
+ // to, creating one only when the server really has none.
+ //
+ // Reading store.session for this is a race: bootstrap issues session.list
+ // as a NON-BLOCKING request (it only joins blockingRequests for
+ // `--continue`), so `await bootstrap()` resolves BEFORE the list lands. A
+ // caller that reads the store right after it sees an empty (or pre-switch)
+ // list, concludes there is no root, and mints another one — entering
+ // Orchestrator three times produced three roots. Refreshing from the
+ // server first makes the decision depend on data instead of on timing.
+ async resolveRoot() {
+ await result.session.refresh()
+ const existing = store.session
+ .filter((x) => x.parentID === undefined)
+ .toSorted((a, b) => b.time.updated - a.time.updated)
+ .at(0)
+ if (existing) return { id: existing.id, created: false }
+ return { id: (await sdk.client.session.create({})).data?.id, created: true }
+ },
status(sessionID: string) {
const session = result.session.get(sessionID)
if (!session) return "idle"
@@ -842,6 +970,14 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (fullSyncedSessions.has(sessionID)) return
const [session, messages, todo, diff, actors, task, children] = await Promise.all([
sdk.client.session.get({ sessionID }, { throwOnError: true }),
+ // ⚠️`limit` is ONE budget shared across every agent bucket, not a
+ // per-bucket limit. A session whose real `main` history is crowded out
+ // of the newest 100 therefore arrives with an empty `main` and falls
+ // through to a non-main bucket in selectMessages above. Measured on the
+ // live DB: 1 of 4613 sessions with messages. Left as-is deliberately —
+ // a separate concern from the render prohibition — and no server work
+ // is needed to fix it, since this endpoint already returns up to 1000
+ // when `limit` is omitted.
sdk.client.session.messages({ sessionID, limit: 100, agent_id: "*" }),
sdk.client.session.todo({ sessionID }),
sdk.client.session.diff({ sessionID }),
@@ -849,9 +985,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
sdk.client.session.task({ sessionID }),
// children aren't in the root-only session list; fetch them so the
// session dialog can show the current session's child sessions.
- // visible: true hides internal machinery children (checkpoint-writer
- // hosts, ask-tool forks, workflow subagent sessions) — only peer
- // sessions the user should see are returned.
+ // visible: true returns only peer children, dropping the two other
+ // kinds of child session that exist — the checkpoint-writer host
+ // (session/checkpoint.ts:851) and the `session ask` fork-query host
+ // (tool/session.ts:128). See Session.children for why "workflow
+ // subagent sessions" is not a third kind.
sdk.client.session.children({ sessionID, visible: true }).catch(() => undefined),
])
setStore(
@@ -867,6 +1005,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
draft.todo[sessionID] = todo.data ?? []
draft.task[sessionID] = task.data ?? []
const flat = (messages.data ?? []).map((x) => x.info)
+ // Server returns messages id-ordered and message.updated keeps that order; the footer's post-/rebuild pending-detection deliberately does NOT depend on it (it keys off checkpoint coveredUpTo, model.ts), so reordering here won't resurface the stale-context bug.
draft.message[sessionID] = bucketMessages(flat)
for (const message of messages.data ?? []) {
draft.part[message.info.id] = message.parts
diff --git a/packages/opencode/src/cli/cmd/tui/context/visual.ts b/packages/opencode/src/cli/cmd/tui/context/visual.ts
new file mode 100644
index 000000000..0e88b24cd
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/context/visual.ts
@@ -0,0 +1,27 @@
+import { createMemo } from "solid-js"
+import { useKV } from "./kv"
+
+export type VisualMode = "minimal" | "vivid"
+
+export function resolveVisualMode(value: unknown): VisualMode {
+ return value === "minimal" ? "minimal" : "vivid"
+}
+
+export function toggleVisualMode(value: unknown): VisualMode {
+ return resolveVisualMode(value) === "vivid" ? "minimal" : "vivid"
+}
+
+export function visualMotionEnabled(mode: VisualMode, animationsEnabled: boolean) {
+ return mode === "vivid" && animationsEnabled
+}
+
+export function useVisualMode() {
+ const kv = useKV()
+ const mode = createMemo(() => resolveVisualMode(kv.get("visual_mode", "vivid")))
+ const animationsEnabled = createMemo(() => kv.get("animations_enabled", true) === true)
+ return {
+ mode,
+ vivid: createMemo(() => mode() === "vivid"),
+ motion: createMemo(() => visualMotionEnabled(mode(), animationsEnabled())),
+ }
+}
diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx
index 4a91f5ba5..0dcede560 100644
--- a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx
+++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx
@@ -19,6 +19,7 @@ const PRIORITY_WEIGHTS: Record = {
"tui.tips.free_models": 50,
"tui.tips.free_api_sunset": 50,
"tui.tips.background": 50,
+ "tui.tips.vivid": 40,
"tui.tips.login": 40,
"tui.tips.theme_mode": 40,
"tui.tips.tab_agent": 40,
@@ -33,6 +34,7 @@ const TIP_KEYS = [
"tui.tips.multi_skills",
"tui.tips.free_models",
"tui.tips.background",
+ "tui.tips.vivid",
"tui.tips.theme_mode",
"tui.tips.doc",
"tui.tips.attach_file",
diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx
index 397b67bb9..2fd5aa305 100644
--- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx
+++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx
@@ -13,7 +13,7 @@ const money = new Intl.NumberFormat("en-US", {
currency: "USD",
})
-function View(props: { api: TuiPluginApi; session_id: string }) {
+export function ContextSidebar(props: { api: TuiPluginApi; session_id: string }) {
const theme = () => props.api.theme.current
const msg = createMemo(() => props.api.state.session.messages(props.session_id))
const cost = createMemo(() => msg().reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0))
@@ -82,7 +82,7 @@ function View(props: { api: TuiPluginApi; session_id: string }) {
const win = Model.contextWindow(props.api.state.config, model)
return {
tokens,
- percent: win ? Math.round((tokens / win.usable) * 100) : null,
+ percent: win ? Math.round((tokens / win.effective) * 100) : null,
limit: win,
}
})
@@ -97,7 +97,7 @@ function View(props: { api: TuiPluginApi; session_id: string }) {
{(win) => (
- compact at {Token.format(win().usable)}
+ limit {Token.format(win().effective)}
{win().source === "config" ? ` of ${Token.format(win().hard)}` : ""}
)}
@@ -113,7 +113,7 @@ const tui: TuiPlugin = async (api) => {
order: 100,
slots: {
sidebar_content(_ctx, props) {
- return
+ return
},
},
})
diff --git a/packages/opencode/src/cli/cmd/tui/i18n/en.ts b/packages/opencode/src/cli/cmd/tui/i18n/en.ts
index c7ef61c62..54c137abc 100644
--- a/packages/opencode/src/cli/cmd/tui/i18n/en.ts
+++ b/packages/opencode/src/cli/cmd/tui/i18n/en.ts
@@ -61,6 +61,7 @@ export const dict: Record = {
"Looking for a shortcut? Ask {highlight}Which slash commands can I use?{/highlight} directly in chat",
"tui.tips.background":
"Run {highlight}/background{/highlight} to set a custom image as your home background",
+ "tui.tips.vivid": "Run {highlight}/vivid{/highlight} to switch between Vivid and Minimal visuals as needed",
"tui.tips.compose_next":
"Try {highlight}/compose-next{/highlight} instead of the Compose agent for frontier models",
"tui.tips.undo": "Use {highlight}/undo{/highlight} to revert the last message and file changes",
@@ -329,6 +330,10 @@ export const dict: Record = {
"tui.dialog.image.import.success": "Background image imported",
"tui.dialog.image.none": "None (use starry background)",
"tui.command.logo.switch.title": "Switch logo design",
+ "tui.command.visual_mode.title_on": "Vivid visuals - switch to Minimal",
+ "tui.command.visual_mode.title_off": "Minimal visuals - switch to Vivid",
+ "tui.visual_mode.enabled": "Vivid display enabled: star field and logo effects restored; meteors and animated activity follow the animation setting",
+ "tui.visual_mode.disabled": "Vivid display disabled: stars, meteors, and logo effects hidden; activity indicators remain stable",
"tui.dialog.logo.title": "Logo design",
"tui.dialog.logo.option.classic": "Classic (bold)",
"tui.dialog.logo.option.thin": "Thin (half-block)",
@@ -544,14 +549,6 @@ export const dict: Record = {
"tui.dialog.login.flow.invalid_code": "Invalid Code, please retry",
"tui.dialog.login.flow.copied": "Copied",
- // Question i18n — plan_enter
- "tui.question.plan_enter.question": "Would you like to switch to plan mode for structured planning?",
- "tui.question.plan_enter.header": "Plan",
- "tui.question.plan_enter.option.0.label": "Yes",
- "tui.question.plan_enter.option.0.description": "Switch to plan agent for read-only planning",
- "tui.question.plan_enter.option.1.label": "No",
- "tui.question.plan_enter.option.1.description": "Stay in current mode",
-
// Question i18n — plan_exit
"tui.question.plan_exit.question": "Plan at {{plan}} is complete. Would you like to switch to the build agent and start implementing?",
"tui.question.plan_exit.header": "Plan",
@@ -563,6 +560,10 @@ export const dict: Record = {
// Session badges
"tui.session.badge.auto": "Auto",
+ // Context rebuild boundary marker (inserted by /rebuild)
+ "tui.session.rebuild_boundary.label": "context rebuilt",
+ "tui.session.rebuild_boundary.detail": "earlier messages summarized",
+
// Workspace trust
"trust.title": "Accessing workspace:",
"trust.safety_check": "Quick safety check: Is this a project you created or one you trust? (Like your own code, a well-known open source project, or work from your team). If not, take a moment to review what's in this folder first.",
diff --git a/packages/opencode/src/cli/cmd/tui/i18n/es.ts b/packages/opencode/src/cli/cmd/tui/i18n/es.ts
index 67ba7fffd..60805e70e 100644
--- a/packages/opencode/src/cli/cmd/tui/i18n/es.ts
+++ b/packages/opencode/src/cli/cmd/tui/i18n/es.ts
@@ -67,6 +67,8 @@ export const dict = {
"¿Buscas un atajo? Pregunta {highlight}¿Qué comandos slash puedo usar?{/highlight} directamente en el chat",
"tui.tips.background":
"Ejecuta {highlight}/background{/highlight} para usar una imagen personalizada como fondo de inicio",
+ "tui.tips.vivid":
+ "Ejecuta {highlight}/vivid{/highlight} para alternar entre las vistas enriquecida y mínima según sea necesario",
"tui.tips.compose_next":
"Prueba {highlight}/compose-next{/highlight} en vez del agente Compose para modelos avanzados",
"tui.tips.undo":
@@ -372,6 +374,10 @@ export const dict = {
"tui.command.opencode.status.title": "Ver estado",
"tui.command.theme.switch.title": "Cambiar tema",
"tui.command.logo.switch.title": "Cambiar diseño de logo",
+ "tui.command.visual_mode.title_on": "Vista enriquecida activa - cambiar a mínima",
+ "tui.command.visual_mode.title_off": "Vista mínima activa - cambiar a enriquecida",
+ "tui.visual_mode.enabled": "Vista enriquecida activada: se restauraron el cielo estrellado y los efectos del logo; los meteoros y los indicadores animados dependen del ajuste de animación",
+ "tui.visual_mode.disabled": "Vista enriquecida desactivada: se ocultaron estrellas, meteoros y efectos del logo; los indicadores permanecen estables",
"tui.dialog.logo.title": "Diseño de logo",
"tui.dialog.logo.option.classic": "Clásico (negrita)",
"tui.dialog.logo.option.thin": "Fino (medio bloque)",
@@ -558,14 +564,6 @@ export const dict = {
"tui.command.plugins.list.title": "Plugins",
"tui.command.plugins.install.title": "Instalar plugin",
- // Question i18n — plan_enter
- "tui.question.plan_enter.question": "¿Desea cambiar al modo plan para una planificación estructurada?",
- "tui.question.plan_enter.header": "Entrar al plan",
- "tui.question.plan_enter.option.0.label": "Sí",
- "tui.question.plan_enter.option.0.description": "Cambiar al agente plan para planificación de solo lectura",
- "tui.question.plan_enter.option.1.label": "No",
- "tui.question.plan_enter.option.1.description": "Permanecer en el modo actual",
-
// Question i18n — plan_exit
"tui.question.plan_exit.question": "El plan en {{plan}} está completo. ¿Desea cambiar al agente build para comenzar la implementación?",
"tui.question.plan_exit.header": "Salir del plan",
diff --git a/packages/opencode/src/cli/cmd/tui/i18n/fr.ts b/packages/opencode/src/cli/cmd/tui/i18n/fr.ts
index 28b6269ca..8caae5a7a 100644
--- a/packages/opencode/src/cli/cmd/tui/i18n/fr.ts
+++ b/packages/opencode/src/cli/cmd/tui/i18n/fr.ts
@@ -67,6 +67,8 @@ export const dict = {
"Vous cherchez un raccourci ? Demandez {highlight}Quelles commandes slash puis-je utiliser ?{/highlight} directement dans le chat",
"tui.tips.background":
"Exécutez {highlight}/background{/highlight} pour définir une image personnalisée comme fond d'écran d'accueil",
+ "tui.tips.vivid":
+ "Exécutez {highlight}/vivid{/highlight} pour basculer entre les affichages enrichi et minimal selon vos besoins",
"tui.tips.compose_next":
"Essayez {highlight}/compose-next{/highlight} au lieu de l'agent Compose pour les modèles avancés",
"tui.tips.undo": "Utilisez {highlight}/undo{/highlight} pour annuler le dernier message et ses modifications",
@@ -360,6 +362,10 @@ export const dict = {
"tui.command.opencode.status.title": "Voir l'état",
"tui.command.theme.switch.title": "Changer de thème",
"tui.command.logo.switch.title": "Changer le design du logo",
+ "tui.command.visual_mode.title_on": "Affichage enrichi - passer en mode minimal",
+ "tui.command.visual_mode.title_off": "Affichage minimal - passer en mode enrichi",
+ "tui.visual_mode.enabled": "Affichage enrichi activé : ciel étoilé et effets du logo restaurés ; météores et indicateurs animés suivent le réglage des animations",
+ "tui.visual_mode.disabled": "Affichage enrichi désactivé : étoiles, météores et effets du logo masqués ; indicateurs stabilisés",
"tui.dialog.logo.title": "Design du logo",
"tui.dialog.logo.option.classic": "Classique (gras)",
"tui.dialog.logo.option.thin": "Fin (demi-bloc)",
@@ -572,14 +578,6 @@ export const dict = {
"cli.providers.mimo_login.decrypt_retry": "Échec du déchiffrement, veuillez réessayer ({remaining} tentatives restantes)",
"cli.providers.mimo_login.decrypt_exhausted": "Échec du déchiffrement, nombre maximal de tentatives atteint",
- // Question i18n — plan_enter
- "tui.question.plan_enter.question": "Voulez-vous basculer en mode plan pour une planification structurée ?",
- "tui.question.plan_enter.header": "Entrer dans le plan",
- "tui.question.plan_enter.option.0.label": "Oui",
- "tui.question.plan_enter.option.0.description": "Basculer vers l'agent plan pour une planification en lecture seule",
- "tui.question.plan_enter.option.1.label": "Non",
- "tui.question.plan_enter.option.1.description": "Rester dans le mode actuel",
-
// Question i18n — plan_exit
"tui.question.plan_exit.question": "Le plan {{plan}} est terminé. Voulez-vous basculer vers l'agent build pour commencer l'implémentation ?",
"tui.question.plan_exit.header": "Quitter le plan",
diff --git a/packages/opencode/src/cli/cmd/tui/i18n/ja.ts b/packages/opencode/src/cli/cmd/tui/i18n/ja.ts
index 020189a11..c00685813 100644
--- a/packages/opencode/src/cli/cmd/tui/i18n/ja.ts
+++ b/packages/opencode/src/cli/cmd/tui/i18n/ja.ts
@@ -64,6 +64,7 @@ export const dict = {
"tui.tips.ask_slash_commands":
"ショートカットを探すには、チャットで {highlight}使えるスラッシュコマンドは?{/highlight} と直接質問できます",
"tui.tips.background": "{highlight}/background{/highlight} を実行してホーム背景にお好みの画像を設定できます",
+ "tui.tips.vivid": "{highlight}/vivid{/highlight} で必要に応じてリッチ表示とミニマル表示を切り替えます",
"tui.tips.compose_next":
"{highlight}/compose-next{/highlight} を推奨(強力なモデル向け・Compose 代替)",
"tui.tips.undo": "{highlight}/undo{/highlight} で直前のメッセージとファイル変更を取り消します",
@@ -304,6 +305,10 @@ export const dict = {
"tui.command.opencode.status.title": "ステータスを表示",
"tui.command.theme.switch.title": "テーマを切り替え",
"tui.command.logo.switch.title": "ロゴデザインを切り替え",
+ "tui.command.visual_mode.title_on": "リッチ表示中 - ミニマル表示に切り替え",
+ "tui.command.visual_mode.title_off": "ミニマル表示中 - リッチ表示に切り替え",
+ "tui.visual_mode.enabled": "リッチ表示を有効化:星空とロゴ効果を復元しました。流星と進行状況のアニメーションはアニメーション設定に従います",
+ "tui.visual_mode.disabled": "リッチ表示を無効化:星空、流星、ロゴ効果を非表示にし、進行状況表示を固定しました",
"tui.dialog.logo.title": "ロゴデザイン",
"tui.dialog.logo.option.classic": "クラシック(太字)",
"tui.dialog.logo.option.thin": "細字(ハーフブロック)",
@@ -512,14 +517,6 @@ export const dict = {
"cli.providers.mimo_login.decrypt_retry": "復号に失敗しました、再試行してください(残り {remaining} 回)",
"cli.providers.mimo_login.decrypt_exhausted": "復号に失敗しました、最大再試行回数に達しました",
- // Question i18n — plan_enter
- "tui.question.plan_enter.question": "構造化された計画のために plan モードに切り替えますか?",
- "tui.question.plan_enter.header": "計画開始",
- "tui.question.plan_enter.option.0.label": "はい",
- "tui.question.plan_enter.option.0.description": "読み取り専用の計画のために plan エージェントに切り替え",
- "tui.question.plan_enter.option.1.label": "いいえ",
- "tui.question.plan_enter.option.1.description": "現在のモードにとどまる",
-
// Question i18n — plan_exit
"tui.question.plan_exit.question": "{{plan}} の計画が完了しました。build エージェントに切り替えて実装を開始しますか?",
"tui.question.plan_exit.header": "計画終了",
diff --git a/packages/opencode/src/cli/cmd/tui/i18n/ru.ts b/packages/opencode/src/cli/cmd/tui/i18n/ru.ts
index 781c3964d..491345379 100644
--- a/packages/opencode/src/cli/cmd/tui/i18n/ru.ts
+++ b/packages/opencode/src/cli/cmd/tui/i18n/ru.ts
@@ -66,6 +66,8 @@ export const dict = {
"Ищете команду? Спросите {highlight}Какие slash-команды я могу использовать?{/highlight} прямо в чате",
"tui.tips.background":
"Выполните {highlight}/background{/highlight}, чтобы установить произвольное изображение в качестве фона главной страницы",
+ "tui.tips.vivid":
+ "Выполните {highlight}/vivid{/highlight}, чтобы при необходимости переключаться между расширенным и минимальным оформлением",
"tui.tips.compose_next":
"Попробуйте {highlight}/compose-next{/highlight} вместо агента Compose для передовых моделей",
"tui.tips.undo":
@@ -375,6 +377,10 @@ export const dict = {
"tui.command.opencode.status.title": "Посмотреть статус",
"tui.command.theme.switch.title": "Сменить тему",
"tui.command.logo.switch.title": "Сменить дизайн логотипа",
+ "tui.command.visual_mode.title_on": "Расширенное оформление - перейти к минимальному",
+ "tui.command.visual_mode.title_off": "Минимальное оформление - перейти к расширенному",
+ "tui.visual_mode.enabled": "Расширенное оформление включено: звёздный фон и эффекты логотипа восстановлены; метеоры и анимация индикаторов зависят от настройки анимации",
+ "tui.visual_mode.disabled": "Расширенное оформление выключено: звёзды, метеоры и эффекты логотипа скрыты; индикаторы остаются неподвижными",
"tui.dialog.logo.title": "Дизайн логотипа",
"tui.dialog.logo.option.classic": "Классический (жирный)",
"tui.dialog.logo.option.thin": "Тонкий (полублок)",
@@ -583,14 +589,6 @@ export const dict = {
"cli.providers.mimo_login.decrypt_retry": "Ошибка расшифровки, повторите попытку (осталось попыток: {remaining})",
"cli.providers.mimo_login.decrypt_exhausted": "Ошибка расшифровки, превышено максимальное число попыток",
- // Question i18n — plan_enter
- "tui.question.plan_enter.question": "Переключиться в режим plan для структурированного планирования?",
- "tui.question.plan_enter.header": "Вход в план",
- "tui.question.plan_enter.option.0.label": "Да",
- "tui.question.plan_enter.option.0.description": "Переключиться на агента plan для планирования в режиме чтения",
- "tui.question.plan_enter.option.1.label": "Нет",
- "tui.question.plan_enter.option.1.description": "Остаться в текущем режиме",
-
// Question i18n — plan_exit
"tui.question.plan_exit.question": "План {{plan}} завершён. Переключиться на агента build и начать реализацию?",
"tui.question.plan_exit.header": "Выход из плана",
diff --git a/packages/opencode/src/cli/cmd/tui/i18n/zh.ts b/packages/opencode/src/cli/cmd/tui/i18n/zh.ts
index 1f3281ce6..2c85ae35a 100644
--- a/packages/opencode/src/cli/cmd/tui/i18n/zh.ts
+++ b/packages/opencode/src/cli/cmd/tui/i18n/zh.ts
@@ -63,6 +63,7 @@ export const dict = {
"tui.tips.ask_slash_commands":
"想找快捷指令?直接在聊天中问 {highlight}有哪些 slash 快捷指令?{/highlight}",
"tui.tips.background": "运行 {highlight}/background{/highlight} 设置自定义图片作为主页背景",
+ "tui.tips.vivid": "运行 {highlight}/vivid{/highlight},根据需要在丰富视觉模式和简洁模式间切换",
"tui.tips.compose_next":
"推荐前沿模型使用 {highlight}/compose-next{/highlight} 代替 Compose 智能体",
"tui.tips.undo": "使用 {highlight}/undo{/highlight} 撤销最后一条消息及其文件改动",
@@ -352,6 +353,10 @@ export const dict = {
"tui.dialog.image.import.success": "背景图片已导入",
"tui.dialog.image.none": "无(使用星空背景)",
"tui.command.logo.switch.title": "切换 Logo 样式",
+ "tui.command.visual_mode.title_on": "丰富显示中 - 点击使用极简模式",
+ "tui.command.visual_mode.title_off": "极简显示中 - 点击使用丰富模式",
+ "tui.visual_mode.enabled": "已开启丰富显示:星空和标志特效已恢复,流星与动态进行中标记仍受动画设置控制",
+ "tui.visual_mode.disabled": "已关闭丰富显示:星空、流星和标志特效已隐藏,进行中标记将保持稳定",
"tui.dialog.logo.title": "Logo 样式",
"tui.dialog.logo.option.classic": "经典(粗体)",
"tui.dialog.logo.option.thin": "纤细(半块)",
@@ -565,14 +570,6 @@ export const dict = {
"tui.dialog.login.flow.invalid_code": "Code 无效,请重试",
"tui.dialog.login.flow.copied": "已复制",
- // Question i18n — plan_enter
- "tui.question.plan_enter.question": "是否切换到 plan 模式进行结构化规划?",
- "tui.question.plan_enter.header": "进入计划",
- "tui.question.plan_enter.option.0.label": "是",
- "tui.question.plan_enter.option.0.description": "切换到 plan 智能体进行只读规划",
- "tui.question.plan_enter.option.1.label": "否",
- "tui.question.plan_enter.option.1.description": "留在当前模式",
-
// Question i18n — plan_exit
"tui.question.plan_exit.question": "{{plan}} 处的计划已完成。是否切换到 build 智能体开始实现?",
"tui.question.plan_exit.header": "退出计划",
@@ -584,6 +581,10 @@ export const dict = {
// Session badges
"tui.session.badge.auto": "自动",
+ // Context rebuild boundary marker (inserted by /rebuild)
+ "tui.session.rebuild_boundary.label": "上下文已重建",
+ "tui.session.rebuild_boundary.detail": "较早消息已摘要",
+
// Workspace trust
"trust.title": "访问工作区:",
"trust.safety_check": "安全确认:这是你自己创建或信任的项目吗?(如你自己的代码、知名开源项目或团队内部项目)。如果不是,请先检查此目录下的内容。",
diff --git a/packages/opencode/src/cli/cmd/tui/i18n/zht.ts b/packages/opencode/src/cli/cmd/tui/i18n/zht.ts
index 8ce57f130..8ad2c10aa 100644
--- a/packages/opencode/src/cli/cmd/tui/i18n/zht.ts
+++ b/packages/opencode/src/cli/cmd/tui/i18n/zht.ts
@@ -63,6 +63,7 @@ export const dict = {
"tui.tips.ask_slash_commands":
"想找快捷指令?直接在聊天中問 {highlight}有哪些 slash 快捷指令?{/highlight}",
"tui.tips.background": "執行 {highlight}/background{/highlight} 設定自訂圖片作為主頁背景",
+ "tui.tips.vivid": "執行 {highlight}/vivid{/highlight},根據需要在豐富視覺模式和簡潔模式間切換",
"tui.tips.compose_next":
"推薦前沿模型使用 {highlight}/compose-next{/highlight} 代替 Compose 智慧體",
"tui.tips.undo": "使用 {highlight}/undo{/highlight} 復原最後一條訊息及其檔案變更",
@@ -352,6 +353,10 @@ export const dict = {
"tui.dialog.image.import.success": "背景圖片已匯入",
"tui.dialog.image.none": "無(使用星空背景)",
"tui.command.logo.switch.title": "切換 Logo 樣式",
+ "tui.command.visual_mode.title_on": "豐富顯示中 - 點擊使用極簡模式",
+ "tui.command.visual_mode.title_off": "極簡顯示中 - 點擊使用豐富模式",
+ "tui.visual_mode.enabled": "已開啟豐富顯示:星空和標誌特效已恢復,流星與動態進行中標記仍受動畫設定控制",
+ "tui.visual_mode.disabled": "已關閉豐富顯示:星空、流星和標誌特效已隱藏,進行中標記將保持穩定",
"tui.dialog.logo.title": "Logo 樣式",
"tui.dialog.logo.option.classic": "經典(粗體)",
"tui.dialog.logo.option.thin": "纖細(半塊)",
@@ -534,14 +539,6 @@ export const dict = {
"tui.command.plugins.list.title": "外掛",
"tui.command.plugins.install.title": "安裝外掛",
- // Question i18n — plan_enter
- "tui.question.plan_enter.question": "是否切換到 plan 模式進行結構化規劃?",
- "tui.question.plan_enter.header": "進入計劃",
- "tui.question.plan_enter.option.0.label": "是",
- "tui.question.plan_enter.option.0.description": "切換到 plan 智慧代理進行唯讀規劃",
- "tui.question.plan_enter.option.1.label": "否",
- "tui.question.plan_enter.option.1.description": "留在當前模式",
-
// Question i18n — plan_exit
"tui.question.plan_exit.question": "{{plan}} 的計劃已完成。是否切換到 build 智慧代理開始實作?",
"tui.question.plan_exit.header": "退出計劃",
@@ -553,6 +550,10 @@ export const dict = {
// Session badges
"tui.session.badge.auto": "自動",
+ // Context rebuild boundary marker (inserted by /rebuild)
+ "tui.session.rebuild_boundary.label": "上下文已重建",
+ "tui.session.rebuild_boundary.detail": "較早訊息已摘要",
+
// Workspace trust
"trust.title": "存取工作區:",
"trust.safety_check": "安全確認:這是你自己建立或信任的專案嗎?(如你自己的程式碼、知名開源專案或團隊內部專案)。如果不是,請先檢查此目錄下的內容。",
diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx
index 74f618ffa..ee86fee98 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx
@@ -17,6 +17,7 @@ import { useLanguage } from "@tui/context/language"
import { TuiPluginRuntime } from "../plugin"
import { Global } from "@/global"
import { isPlainTerminal } from "../util/terminal"
+import { useVisualMode } from "../context/visual"
let once = false
@@ -31,6 +32,7 @@ export function Home() {
const kv = useKV()
const t = useLanguage().t
const plainTerminal = isPlainTerminal()
+ const visual = useVisualMode()
const bgImagePath = createMemo(() => {
const filename = kv.get("background_image")
if (!filename || typeof filename !== "string") return undefined
@@ -40,8 +42,6 @@ export function Home() {
const key = kv.get("logo_design")
return typeof key === "string" && key in logos ? (key as LogoKey) : "thin"
})
- // 所有 logo 变体(含默认的 thin 纤细半块)都显示流星特效。
- const showMeteor = () => true
const placeholder = {
get normal() {
return [
@@ -83,7 +83,14 @@ export function Home() {
return (
<>
- }>
+
+
+
+ }
+ >
{(p) => }
@@ -96,7 +103,7 @@ export function Home() {
fallback={
- {(k) => }
+ {(k) => }
}
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index c21e6b136..788132fc2 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -1,5 +1,4 @@
import {
- batch,
createContext,
createEffect,
createMemo,
@@ -16,7 +15,7 @@ import { Dynamic } from "solid-js/web"
import path from "path"
import { useCurrentAgentID, useRoute, useRouteData } from "@tui/context/route"
import { useProject } from "@tui/context/project"
-import { useSync } from "@tui/context/sync"
+import { selectMessages, useSync } from "@tui/context/sync"
import { useEvent } from "@tui/context/event"
import { SplitBorder } from "@tui/component/border"
import { Spinner } from "@tui/component/spinner"
@@ -34,6 +33,7 @@ import type {
} from "@mimo-ai/sdk/v2"
import { useLocal } from "@tui/context/local"
import { Locale } from "@/util"
+import { verifySessionRenderable, type SessionActorInput } from "@/session/visibility"
import type { Tool } from "@/tool"
import type { ReadTool } from "@/tool/read"
import type { WriteTool } from "@/tool/write"
@@ -67,7 +67,9 @@ import { DialogPrompt } from "@tui/ui/dialog-prompt"
import { DialogTimeline } from "./dialog-timeline"
import { DialogForkFromTimeline } from "./dialog-fork-from-timeline"
import { DialogSessionRename } from "../../component/dialog-session-rename"
-import { Sidebar } from "./sidebar"
+import { Sidebar, SIDEBAR_WIDTH } from "./sidebar"
+import { sidebarToggle, sidebarVisibleFor, type SidebarPreference } from "./sidebar-state"
+import { createPress } from "../../ui/press"
import { WorkflowTree } from "@tui/component/workflow-tree"
import { SubagentFooter } from "./subagent-footer.tsx"
import { DialogSubagent } from "./dialog-subagent.tsx"
@@ -100,6 +102,7 @@ import { DialogTokenPlan } from "../../component/dialog-token-plan"
import { SessionRetry } from "@/session/retry"
import { getRevertDiffFiles } from "../../util/revert-diff"
import * as Collapse from "../../util/collapse"
+import { planSwitchTarget } from "./plan-switch"
import {
createFreeApiSunsetSignal,
freeApiModelNameKey,
@@ -140,19 +143,19 @@ function use() {
function SidebarToggleButton(props: { visible: boolean; onToggle: () => void }) {
const { theme } = useTheme()
- const [hover, setHover] = createSignal(false)
+ const press = createPress(() => props.onToggle())
return (
setHover(true)}
- onMouseOut={() => setHover(false)}
- onMouseUp={() => props.onToggle()}
+ backgroundColor={press.hover() ? theme.backgroundElement : undefined}
+ {...press.props}
>
- {props.visible ? "▶" : "◀"}
+
+ {props.visible ? "▶" : "◀"}
+
)
}
@@ -172,16 +175,9 @@ export function Session() {
const session = createMemo(() => sync.session.get(route.sessionID))
const currentAgentID = useCurrentAgentID()
const actors = createMemo(() => sync.data.actor[route.sessionID] ?? [])
- const messages = createMemo(() => {
- const buckets = sync.data.message[route.sessionID]
- const agentID = currentAgentID()
- // A peer child runs its own turns under agentID == its own sessionID
- // (spawn.ts), so its messages bucket under [sessionID] not ["main"]. When
- // attaching to such a child at "main", fall back to its own-id bucket so the
- // full session renders instead of an empty "main" view.
- if (agentID === "main" && !buckets?.["main"]?.length) return buckets?.[route.sessionID] ?? []
- return buckets?.[agentID] ?? []
- })
+ const messages = createMemo(() =>
+ selectMessages(sync.data.message[route.sessionID], currentAgentID(), route.sessionID),
+ )
const permissions = createMemo(() => sync.data.permission[route.sessionID] ?? [])
const questions = createMemo(() => sync.data.question[route.sessionID] ?? [])
const visible = createMemo(
@@ -201,8 +197,7 @@ export function Session() {
})
const dimensions = useTerminalDimensions()
- const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "auto")
- const [sidebarOpen, setSidebarOpen] = createSignal(false)
+ const [sidebar, setSidebar] = kv.signal("sidebar", "auto")
const [conceal, setConceal] = createSignal(true)
const thinking = useThinkingMode()
const thinkingMode = thinking.mode
@@ -234,14 +229,14 @@ export function Session() {
const fromWorkflowRunID = createMemo(() => route.fromWorkflowRunID)
const wide = createMemo(() => dimensions().width > 120)
- const sidebarVisible = createMemo(() => {
- if (currentAgentID() !== "main") return false
- if (sidebarOpen()) return true
- if (sidebar() === "auto" && wide()) return true
- return false
- })
+ // Subagent views have no sidebar at all, so neither the panel nor its control belongs there.
+ const sidebarAllowed = createMemo(() => currentAgentID() === "main")
+ const sidebarVisible = createMemo(() => sidebarAllowed() && sidebarVisibleFor(sidebar(), wide()))
+ // Only a docked sidebar consumes layout width; the narrow overlay floats above the transcript.
+ const sidebarDocked = createMemo(() => sidebarVisible() && wide())
+ const toggleSidebar = () => setSidebar(() => sidebarToggle(sidebar(), wide()))
const showTimestamps = createMemo(() => timestamps() === "show")
- const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
+ const contentWidth = createMemo(() => dimensions().width - (sidebarDocked() ? SIDEBAR_WIDTH : 0) - 4)
const providers = createMemo(() => Model.index(sync.data.provider))
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
@@ -260,6 +255,37 @@ export function Session() {
return
}
+ // The prohibition. Every way of reaching this route hands a raw session id
+ // straight to the renderer and bypasses both hiding layers: -s/--session
+ // (thread.ts → app.tsx), `attach --session`, POST /tui/select-session, POST
+ // /tui/event, the session tool's `switch`, MIMOCODE_ROUTE, plugin
+ // navigate("session", …) and the session-list dialog's child injection. This
+ // effect is the one point all of them must pass, so the refusal lives here
+ // rather than on any single entry point. What counts as forbidden lives in
+ // session/visibility.ts: a host for a RUNTIME-spawned agent, which today
+ // means the checkpoint writer. It reads the session's own actor rows, so no
+ // parent round-trip is needed.
+ const verdict = await verifySessionRenderable(result.data, (sessionID) =>
+ // `throwOnError` is load-bearing, not tidiness: without it this client
+ // RESOLVES `{ data: undefined }` on an HTTP error, which the classifier
+ // reads as "this session has no actor rows" and renders. The failure has to
+ // arrive as a rejection for the gate to see it as unverified rather than as
+ // verified-absent.
+ // SessionActorsResponses[200] is generated as `unknown`, so the shape is
+ // asserted here exactly as sync.tsx does for the same endpoint.
+ sdk.client.session
+ .actors({ sessionID }, { throwOnError: true })
+ .then((res) => res.data as SessionActorInput[] | undefined),
+ )
+ if (!verdict.renderable) {
+ toast.show({
+ message: `Cannot open session: ${verdict.reason}`,
+ variant: "error",
+ })
+ navigate({ type: "home" })
+ return
+ }
+
if (result.data.workspaceID !== previousWorkspace) {
project.workspace.set(result.data.workspaceID)
@@ -280,16 +306,12 @@ export function Session() {
const part = evt.properties.part
if (part.type !== "tool") return
if (part.sessionID !== route.sessionID) return
- if (part.state.status !== "completed") return
if (part.id === lastSwitch) return
- if (part.tool === "plan_exit" && part.state.metadata?.switched) {
- local.agent.set("build")
- lastSwitch = part.id
- } else if (part.tool === "plan_enter") {
- local.agent.set("plan")
- lastSwitch = part.id
- }
+ const agent = planSwitchTarget(part)
+ if (!agent) return
+ local.agent.set(agent)
+ lastSwitch = part.id
})
let seeded = false
@@ -769,12 +791,9 @@ export function Session() {
value: "session.sidebar.toggle",
keybind: "sidebar_toggle",
category: "session",
+ enabled: sidebarAllowed(),
onSelect: (dialog) => {
- batch(() => {
- const isVisible = sidebarVisible()
- setSidebar(() => (isVisible ? "hide" : "auto"))
- setSidebarOpen(!isVisible)
- })
+ toggleSidebar()
dialog.clear()
},
},
@@ -1455,17 +1474,8 @@ export function Session() {
-
- {
- batch(() => {
- const isVisible = sidebarVisible()
- setSidebar(() => (isVisible ? "hide" : "auto"))
- setSidebarOpen(!isVisible)
- })
- }}
- />
+
+
@@ -1473,15 +1483,19 @@ export function Session() {
+ {/* The control rides inside the overlay so it keeps the same position
+ relative to the sidebar as when docked: immediately to its left. */}
+
@@ -1537,7 +1551,16 @@ function UserMessage(props: {
return parsed ? [parsed] : []
})[0]
})
+ // A context rebuild (`/rebuild`) inserts a single user message carrying a
+ // `checkpoint` part plus `synthetic: true` text parts (the rendered context
+ // and index). Neither renders — `checkpoint` has no PART_MAPPING entry and
+ // synthetic text is excluded from `text()` above — so the boundary used to be
+ // completely invisible in the transcript, unlike compaction which at least
+ // leaves a visible summary message behind. Surface it as a one-line marker
+ // row so the user can see that a rebuild happened and where.
+ const rebuildBoundary = createMemo(() => props.parts.some((x) => x.type === "checkpoint"))
const { theme } = useTheme()
+ const t = useLanguage().t
const [hover, setHover] = createSignal(false)
const queued = createMemo(() => props.pending && props.message.id > props.pending)
const color = createMemo(() => local.agent.color(props.message.agent))
@@ -1604,6 +1627,17 @@ function UserMessage(props: {
)
}}
+
+
+
+
+ {" "}
+ ⟲ {t("tui.session.rebuild_boundary.label")}{" "}
+
+ {t("tui.session.rebuild_boundary.detail")}
+
+
+ ) {
)
}
-// Renderer for the `exec` batch-orchestration tool, shaped like : once the
-// script source has streamed in it lives in a BlockTool, and collapsing only caps
-// how much of the script and its output are shown (head + "…") instead of hiding
-// both behind a one-line summary. The title carries the live aggregated call
-// counts published through ctx.metadata.
+// Renderer for the `exec` batch-orchestration tool. Collapsed view is a compact
+// BlockTool: summary title (spinner + live aggregated call counts published
+// through ctx.metadata) plus the last few sub-calls — one bordered clickable
+// unit, visible while running and kept after completion. Clicking swaps to the
+// full BlockTool with code, result, logs and trace. Before any sub-call lands
+// it stays a one-line InlineTool.
function ToolScript(props: ToolProps) {
const { theme } = useTheme()
- const ctx = use()
const [expanded, setExpanded] = createSignal(false)
const isRunning = createMemo(() => props.part.state.status === "running")
const meta = createMemo(() =>
@@ -2316,48 +2350,75 @@ function ToolScript(props: ToolProps) {
if (isRunning()) return base
return failed() ? `${status()} · ${base}` : base
})
-
- const code = createMemo(() => ((props.input.code as string | undefined) ?? "").trim())
+ // Per-call trace tail published live via ctx.metadata (see publishProgress
+ // in tool-script.ts). Shown under the summary line while running AND after
+ // completion — the terminal returns re-publish it (completeToolCall replaces
+ // part metadata) so the trace doesn't vanish the moment a run finishes.
+ type RecentCall = { name: string; status: string; durationMs: number; error?: string }
+ const recent = createMemo(() => {
+ const r = meta().recent as RecentCall[] | undefined
+ return Array.isArray(r) ? r : []
+ })
+ const recentLines = createMemo(() =>
+ recent()
+ .slice(-5)
+ .map(
+ (t) =>
+ ` ${t.status === "error" ? "✗" : "✓"} ${t.name} [${t.durationMs}ms]${t.error ? ` ${t.error.slice(0, 80)}` : ""}`,
+ ),
+ )
// exec embeds nested tool output (a `bash` call's stdout) into
// and , so escape sequences reach this renderer raw.
const output = createMemo(() => stripAnsi(props.output?.trim() ?? ""))
- const columns = createMemo(() => Collapse.columns(ctx.width))
- const overflow = createMemo(
- () =>
- Collapse.rows(code(), columns()) > TOOL_BLOCK_COLLAPSE_MAX_ROWS ||
- Collapse.rows(output(), columns()) > TOOL_BLOCK_COLLAPSE_MAX_ROWS,
- )
- const clip = (content: string) => {
- if (expanded()) return content
- return Collapse.clip(content, columns(), TOOL_BLOCK_COLLAPSE_MAX_ROWS)
- }
return (
-
-
- setExpanded((prev) => !prev) : undefined}
+ 0}
+ fallback={
+ setExpanded(true)}
+ >
+ exec {summary()}
+
+ }
>
-
- {clip(code())}
-
- {clip(output())}
-
-
- {expanded() ? "Click to collapse" : "Click to expand"}
-
-
-
-
-
-
- exec
-
-
-
+ setExpanded(true)}
+ >
+ {recentLines().join("\n")}
+ Click to expand
+
+
+ }
+ >
+ setExpanded(false)}>
+
+ {((props.input.code as string | undefined) ?? "").trim()}
+ 0}>
+ {recentLines().join("\n")}
+
+
+ {output()}
+
+ Click to collapse
+
+
+
)
}
@@ -2508,7 +2569,7 @@ function WorkflowPanel(props: {
>
⚡}>
-
+
{props.name}
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/plan-switch.ts b/packages/opencode/src/cli/cmd/tui/routes/session/plan-switch.ts
new file mode 100644
index 000000000..8482029cc
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/plan-switch.ts
@@ -0,0 +1,8 @@
+import type { ToolPart } from "@mimo-ai/sdk/v2"
+
+export function planSwitchTarget(part: Pick): "build" | undefined {
+ if (part.state.status !== "completed") return undefined
+ if (part.state.metadata.switched !== true) return undefined
+ if (part.tool === "plan_exit") return "build"
+ return undefined
+}
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar-state.ts b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar-state.ts
new file mode 100644
index 000000000..5291912e6
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar-state.ts
@@ -0,0 +1,21 @@
+/**
+ * Sidebar visibility preference. `auto` follows the terminal width; `show`/`hide` are
+ * explicit user overrides that outlive a resize.
+ */
+export type SidebarPreference = "auto" | "show" | "hide"
+
+export function sidebarVisibleFor(preference: SidebarPreference, wide: boolean) {
+ if (preference === "auto") return wide
+ return preference === "show"
+}
+
+/**
+ * Toggling normalises back to `auto` whenever the requested state is what the width
+ * would have picked anyway. That keeps a collapse/expand round-trip on a wide terminal
+ * from leaving behind a `show` override that survives a shrink.
+ */
+export function sidebarToggle(preference: SidebarPreference, wide: boolean): SidebarPreference {
+ const next = !sidebarVisibleFor(preference, wide)
+ if (next === wide) return "auto"
+ return next ? "show" : "hide"
+}
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
index 6d92752ef..cd532d8f8 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
@@ -1,6 +1,7 @@
import { useProject } from "@tui/context/project"
import { useSync } from "@tui/context/sync"
import { createMemo, Show } from "solid-js"
+import { useTerminalDimensions } from "@opentui/solid"
import { useTheme } from "../../context/theme"
import { useTuiConfig } from "../../context/tui-config"
import { InstallationChannel, InstallationVersion } from "@/installation/version"
@@ -8,11 +9,14 @@ import { TuiPluginRuntime } from "../../plugin"
import { getScrollAcceleration } from "../../util/scroll"
+export const SIDEBAR_WIDTH = 42
+
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const project = useProject()
const sync = useSync()
const { theme } = useTheme()
const tuiConfig = useTuiConfig()
+ const dimensions = useTerminalDimensions()
const session = createMemo(() => sync.session.get(props.sessionID))
const workspaceStatus = () => {
const workspaceID = session()?.workspaceID
@@ -32,7 +36,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
`),
+ * otherwise its own press starts a text selection and every release is discarded as a
+ * selection drag — a silently dead control.
+ */
+export function createPress(onPress: () => void) {
+ const [hover, setHover] = createSignal(false)
+ let node: Renderable | undefined
+ let armed = false
+
+ const inside = (evt: MouseEvent) =>
+ !!node &&
+ evt.x >= node.x &&
+ evt.x < node.x + node.width &&
+ evt.y >= node.y &&
+ evt.y < node.y + node.height
+
+ return {
+ hover,
+ props: {
+ ref: (r: Renderable) => {
+ node = r
+ },
+ // opentui raises out/over on intra-element hit changes too — a child glyph and the
+ // box's own cells are separate hit targets, and both events bubble here — so only a
+ // pointer whose new position is outside our bounds counts as having left.
+ onMouseOver: (evt: MouseEvent) => {
+ setHover(true)
+ if (inside(evt)) return
+ armed = false
+ },
+ onMouseOut: (evt: MouseEvent) => {
+ if (inside(evt)) return
+ setHover(false)
+ armed = false
+ },
+ onMouseDrag: (evt: MouseEvent) => {
+ if (inside(evt)) return
+ armed = false
+ },
+ onMouseDrop: () => {
+ armed = false
+ },
+ onMouseDown: (evt: MouseEvent) => {
+ armed = inside(evt)
+ },
+ onMouseUp: (evt: MouseEvent) => {
+ if (!armed) return
+ // Consume first: a release inside a captured renderable is dispatched twice.
+ armed = false
+ // A release closing a text-selection drag arrives with no preceding `drop`; it is
+ // never a click on us.
+ if (evt.isDragging) return
+ if (!inside(evt)) return
+ onPress()
+ },
+ },
+ }
+}
diff --git a/packages/opencode/src/cli/cmd/tui/ui/toast.tsx b/packages/opencode/src/cli/cmd/tui/ui/toast.tsx
index 5b441ec06..18a506f75 100644
--- a/packages/opencode/src/cli/cmd/tui/ui/toast.tsx
+++ b/packages/opencode/src/cli/cmd/tui/ui/toast.tsx
@@ -100,3 +100,10 @@ export function useToast() {
}
return value
}
+
+// For contexts that want to surface a toast when one is available but must not
+// REQUIRE the toast stack (theme + terminal dimensions + border) as a dependency
+// — a data context should not be untestable because of a presentation concern.
+export function useToastOptional() {
+ return useContext(ctx)
+}
diff --git a/packages/opencode/src/cli/cmd/tui/util/model.ts b/packages/opencode/src/cli/cmd/tui/util/model.ts
index ffb3cf70f..3dd46e174 100644
--- a/packages/opencode/src/cli/cmd/tui/util/model.ts
+++ b/packages/opencode/src/cli/cmd/tui/util/model.ts
@@ -1,5 +1,6 @@
-import type { Config, Model, Provider } from "@mimo-ai/sdk/v2"
+import type { AssistantMessage, Config, Message, Model, Provider } from "@mimo-ai/sdk/v2"
import { contextWindow as overflowWindow } from "@/session/overflow"
+import { Locale, Token } from "@/util"
type Selection = {
providerID: string
@@ -64,3 +65,90 @@ export function contextWindow(config: Config | undefined, model: Model | undefin
// compaction.reserved). Callers divide by it, so treat that as "unknown window".
return result.hard === 0 || result.usable === 0 ? undefined : result
}
+
+/** Window shape from `contextWindow` / the server's overflow arithmetic. */
+export type ContextWindow = ReturnType
+
+/**
+ * Compute the footer's context-fill readout and cumulative cost from the main
+ * message list. Pure and render-free so it can be unit-tested below the SolidJS
+ * memo in prompt/index.tsx (which has no render harness).
+ *
+ * The context number reads the LAST completed assistant turn's usage record —
+ * the same source the server's overflow/compaction TRIGGER uses
+ * (session/overflow.ts `isOverflow` over `MessageV2.Assistant["tokens"]`, fed by
+ * prompt.ts `lastFinished.tokens`). There is deliberately no second estimator:
+ * a manual /rebuild inserts only a checkpoint-boundary message and produces no
+ * new usage record, so re-tokenizing the trimmed transcript here would show a
+ * number that disagrees with the trigger and then jumps to a different measured
+ * value on the next turn. Instead, when the last measured assistant turn falls
+ * inside a region a rebuild collapsed, the measured figure is stale, so
+ * `pending` is true and `context` blanks only the unmeasured numerator while
+ * keeping the window frame (`—/960K`), since the window is still known and a
+ * percentage of an unknown numerator is meaningless. The number refreshes for
+ * real on the next assistant turn (which is created after the boundary). Cost is
+ * a cumulative sum over all assistant turns and is unaffected by the boundary —
+ * the whole point of /rebuild is to drop context, not cost.
+ *
+ * Staleness is decided from each rebuild's `coveredUpTo` (the watermark message
+ * id it collapsed up to), NOT from the boundary marker's own id or its array
+ * position. This matters: the boundary marker message is created with a fresh
+ * ascending id but a deliberately backdated `time.created` (checkpoint.ts, so it
+ * renders next to the region it summarizes), so its id and time disagree by
+ * design. Comparing the marker's own id — or trusting `findLast` to return the
+ * newest boundary in array order — would silently reintroduce the stale-figure
+ * bug the moment the caller ordered messages by time, or ran a second rebuild.
+ * `coveredUpTo` is an ordinary watermark message id (a real prior turn), so
+ * `coveredUpTo >= last.id` is an honest "was this measured turn collapsed?" test
+ * that holds under any caller ordering and any number of rebuilds.
+ *
+ * `context` is the final display string in every case: the pure function is the
+ * sole owner of the pending placeholder (it is where the "figure is stale"
+ * decision is made and where the tests live), so the renderer shows `context`
+ * unconditionally and never has to reinterpret `pending`.
+ */
+export function computeContextUsage(input: {
+ messages: Message[]
+ window: ContextWindow | undefined
+ /**
+ * For a message carrying a `checkpoint` (rebuild) part, the `coveredUpTo`
+ * watermark id that rebuild collapsed up to; `undefined` for any other
+ * message. Ordering-independent: the readout never inspects message order.
+ */
+ checkpointCoverage: (messageID: string) => string | undefined
+}): { context: string; cost: number; pending: boolean } | undefined {
+ const { messages, window: win, checkpointCoverage } = input
+ const last = messages.findLast(
+ (m): m is AssistantMessage => m.role === "assistant" && m.tokens.output > 0,
+ )
+ if (!last) return undefined
+
+ const tokens =
+ last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
+ if (tokens <= 0) return undefined
+
+ const cost = messages.reduce((sum, m) => sum + (m.role === "assistant" ? m.cost : 0), 0)
+
+ // The window frame is `` plus the `↓` config-budget marker. Denominator
+ // is the compaction trigger, not the raw window — otherwise the percentage never
+ // reaches 100% and a configured budget looks ignored.
+ const frame = win ? `${Token.format(win.usable)}${win.source === "config" ? "↓" : ""}` : undefined
+
+ // The measured turn is stale if ANY rebuild collapsed a region reaching it or
+ // past it — i.e. some checkpoint's coveredUpTo id is >= the last measured turn's
+ // id. `some` (not `findLast`) so the result never depends on message order.
+ const pending = messages.some((m) => {
+ const coveredUpTo = checkpointCoverage(m.id)
+ return coveredUpTo !== undefined && coveredUpTo >= last.id
+ })
+ if (pending) {
+ // Blank only the unmeasured numerator; keep the frame when we have one so the
+ // footer reads as deliberately-unknown (`—/960K`) rather than broken. With no
+ // window there is no frame to keep, so a bare placeholder is correct. No
+ // percentage either way — a percentage of an unknown numerator is meaningless.
+ return { context: frame ? `—/${frame}` : "—", cost, pending: true }
+ }
+
+ const context = frame ? `${Locale.number(tokens)}/${frame} (${Math.round((tokens / win!.usable) * 100)}%)` : Locale.number(tokens)
+ return { context, cost, pending: false }
+}
diff --git a/packages/opencode/src/cli/ui.ts b/packages/opencode/src/cli/ui.ts
index 1c8607d84..5ac4140d8 100644
--- a/packages/opencode/src/cli/ui.ts
+++ b/packages/opencode/src/cli/ui.ts
@@ -39,6 +39,10 @@ export function print(...message: string[]) {
process.stderr.write(message.join(" "))
}
+export function withTrailingEOL(text: string) {
+ return text.replace(/[\r\n]+$/, "") + EOL
+}
+
let blank = false
export function empty() {
if (blank) return
diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts
index 0d7025afe..12915c85d 100644
--- a/packages/opencode/src/command/index.ts
+++ b/packages/opencode/src/command/index.ts
@@ -296,10 +296,16 @@ export const layer = Layer.effect(
}),
)
-export const defaultLayer = layer.pipe(
+/**
+ * Application composition variant. The process-wide AppLayer supplies the
+ * MCP service so Command and SessionPrompt share one client set instead of
+ * each hiding a separately scoped transport layer.
+ */
+export const appLayer = layer.pipe(
Layer.provide(Config.defaultLayer),
- Layer.provide(MCP.defaultLayer),
Layer.provide(Skill.defaultLayer),
)
+export const defaultLayer = appLayer.pipe(Layer.provide(MCP.defaultLayer))
+
export * as Command from "."
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 8236b30fc..6e8725799 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -275,10 +275,6 @@ const InfoSchema = Schema.Struct({
reserved: Schema.optional(NonNegativeInt).annotate({
description: "Token buffer reserved for checkpoint operations. Default: 20000.",
}),
- max_writer_failures: Schema.optional(PositiveInt).annotate({
- description:
- "Maximum consecutive writer failures per session before checkpointing stops retrying until process restart. Default: 3.",
- }),
fork: Schema.optional(Schema.Boolean).annotate({
description:
"Whether to fork the parent agent's message prefix into the writer session for prefix-cache reuse. Requires provider cache-breakpoint support. Default: false.",
@@ -345,6 +341,10 @@ const InfoSchema = Schema.Struct({
),
memory: Schema.optional(
Schema.Struct({
+ disable_write: Schema.optional(Schema.Boolean).annotate({
+ description:
+ "Stop WRITING new memory. Default: false (memory is written). When true, no new memory is produced — session checkpoint.md, project MEMORY.md, notes.md and per-task progress.md are never written, the high-pressure 'save your learnings to memory' nudge is suppressed, and automatic dream/distill runs are skipped. READING is deliberately unaffected: existing memory still loads into session-rebuild context and the builtin `memory` search tool keeps working. Nothing is ever deleted — set it back to false to resume writing on top of the existing files.",
+ }),
cc_index: Schema.optional(Schema.Boolean).annotate({
description:
"Index Claude Code memory (~/.claude/projects//memory) and expose under scope='cc'. Default: false. Note: when enabled, every mimocode agent (build/explore/subagents) can search these memories via the builtin `memory` tool — including CC's `type: user` (your role/preferences) and `type: feedback` (your guidance) categories. CC originally writes them for future CC sessions; flipping this on widens the consumer set to mimocode agents on the same machine. Leave disabled (default) if you don't want personal context recallable from a prompt-injection-vulnerable agent.",
diff --git a/packages/opencode/src/config/mcp.ts b/packages/opencode/src/config/mcp.ts
index 1a28bbc51..67d95ad2c 100644
--- a/packages/opencode/src/config/mcp.ts
+++ b/packages/opencode/src/config/mcp.ts
@@ -3,6 +3,16 @@ import { isRecord } from "@/util/record"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
+export const Sampling = Schema.Literals(["deny", "ask", "allow"])
+ .annotate({ identifier: "McpSamplingPolicy" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Sampling = Schema.Schema.Type
+
+const samplingField = Schema.optional(Sampling).annotate({
+ description:
+ "Policy for MCP client-side sampling (`sampling/createMessage`) from this server: deny, ask (default), or allow.",
+})
+
export class Local extends Schema.Class("McpLocalConfig")({
type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }),
command: Schema.mutable(Schema.Array(Schema.String)).annotate({
@@ -17,6 +27,7 @@ export class Local extends Schema.Class("McpLocalConfig")({
timeout: Schema.optional(Schema.Number).annotate({
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
}),
+ sampling: samplingField,
}) {
static readonly zod = zod(this)
}
@@ -51,6 +62,7 @@ export class Remote extends Schema.Class("McpRemoteConfig")({
timeout: Schema.optional(Schema.Number).annotate({
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
}),
+ sampling: samplingField,
}) {
static readonly zod = zod(this)
}
diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts
index 637ffb389..083648a92 100644
--- a/packages/opencode/src/effect/app-runtime.ts
+++ b/packages/opencode/src/effect/app-runtime.ts
@@ -62,7 +62,7 @@ import * as BashInteractive from "@/tool/bash-interactive"
import { memoMap } from "./memo-map"
// Wrapped in Layer.suspend so the cross-module `.defaultLayer` reads defer to
-// first use instead of running at module load — same TDZ fix as Actor.defaultLayer.
+// first use instead of running at module load — same TDZ fix as Actor.appLayer.
export const AppLayer = Layer.suspend(() =>
Layer.mergeAll(
Npm.defaultLayer,
@@ -95,15 +95,12 @@ export const AppLayer = Layer.suspend(() =>
SessionPrune.defaultLayer,
SessionRevert.defaultLayer,
SessionSummary.defaultLayer,
- SessionPrompt.defaultLayer,
CronBridgeDefaultLayer,
SessionCheckpoint.defaultLayer,
Instruction.defaultLayer,
LLM.defaultLayer,
LSP.defaultLayer,
- MCP.defaultLayer,
McpAuth.defaultLayer,
- Command.defaultLayer,
Truncate.defaultLayer,
ToolRegistry.defaultLayer,
Format.defaultLayer,
@@ -116,11 +113,18 @@ export const AppLayer = Layer.suspend(() =>
SessionShare.defaultLayer,
ActorRegistry.defaultLayer,
ActorWaiter.defaultLayer,
- Actor.defaultLayer,
TaskRegistry.defaultLayer,
WorkflowRuntime.defaultLayer,
Memory.defaultLayer,
History.defaultLayer,
+ // MCP, Command, SessionPrompt, and Actor form one ownership chain. Their
+ // standalone default layers remain convenient for focused tests, while
+ // the application graph deliberately provides each stateful service once.
+ Actor.appLayer.pipe(
+ Layer.provideMerge(SessionPrompt.appLayer.pipe(
+ Layer.provideMerge(Command.appLayer.pipe(Layer.provideMerge(MCP.defaultLayer))),
+ )),
+ ),
).pipe(Layer.provideMerge(Observability.layer), Layer.provideMerge(BashInteractive.defaultLayer)),
)
diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts
index f78d0ac0f..94fbcff3c 100644
--- a/packages/opencode/src/flag/flag.ts
+++ b/packages/opencode/src/flag/flag.ts
@@ -95,10 +95,6 @@ export const Flag = {
get MIMOCODE_FORCE_ANTHROPIC_REASONING_CONTENT() {
return truthy("MIMOCODE_FORCE_ANTHROPIC_REASONING_CONTENT")
},
- // Empty/no-op tool-call loop guard: number of soft nudges (remind → replan)
- // before the harness hard-halts the turn. N consecutive empty steps beyond
- // this many recovery attempts terminates the turn. Mirrors TEXT_NGRAM_MAX_RECOVERY.
- MIMOCODE_EMPTY_STEP_MAX_RECOVERY: number("MIMOCODE_EMPTY_STEP_MAX_RECOVERY") ?? 2,
// Consecutive-block repetition detection for streamed reasoning + text.
// A block of at least N tokens repeating REPEAT_THRESHOLD times consecutively
@@ -220,6 +216,9 @@ export const Flag = {
MIMOCODE_EXPERIMENTAL_OXFMT: MIMOCODE_EXPERIMENTAL || truthy("MIMOCODE_EXPERIMENTAL_OXFMT"),
MIMOCODE_EXPERIMENTAL_LSP_TY: truthy("MIMOCODE_EXPERIMENTAL_LSP_TY"),
MIMOCODE_EXPERIMENTAL_LSP_TOOL: MIMOCODE_EXPERIMENTAL || truthy("MIMOCODE_EXPERIMENTAL_LSP_TOOL"),
+ // Defaults to OFF: exec (tool_script orchestration) is registered only for
+ // GPT-toolset models. Opt in here to expose it to every model.
+ MIMOCODE_ENABLE_EXEC_TOOL: truthy("MIMOCODE_ENABLE_EXEC_TOOL"),
// Defaults to OFF for non-GPT models. GPT models enable MCP Tool Search in
// SessionPrompt regardless of this flag. Opt in here to enable it for every
// function-calling model.
diff --git a/packages/opencode/src/inbox/inbox.ts b/packages/opencode/src/inbox/inbox.ts
index e55c1f6d6..2d0eb99ba 100644
--- a/packages/opencode/src/inbox/inbox.ts
+++ b/packages/opencode/src/inbox/inbox.ts
@@ -248,6 +248,44 @@ export const layer: Layer.Layer<
return 0
}
+ // Render BEFORE writing anything, and drop any row that renders blank.
+ // The drain is the one user-message producer that does NOT go through
+ // SessionPrompt.createUserMessage, so `hasSubstantiveContent` never sees
+ // it — a blank render would persist a user message whose only part is
+ // {type:"text",text:""}. The AI SDK's user branch filters empty text
+ // parts out with NO backfill, so that message reaches the provider as
+ // `content: []` and is rejected ("user messages must have non-empty
+ // content"). renderInboxRow already substitutes a placeholder for a
+ // blank body; this is the structural invariant that keeps the shape
+ // unreachable no matter what any future row type renders.
+ const rendered = rows.flatMap((row) => {
+ const text = renderInboxRow(row)
+ if (text.trim().length > 0) return [{ row, text }]
+ log.warn("inbox.drain: dropping row that rendered blank (would produce an empty user text part)", {
+ sessionID,
+ actorID,
+ rowID: row.id,
+ type: row.type,
+ })
+ return []
+ })
+
+ // Every row rendered blank: consume them (they carry no information and
+ // must not be re-drained forever) without writing a message at all. A
+ // zero-part user message would be skipped downstream anyway, so writing
+ // one is pure litter.
+ if (rendered.length === 0) {
+ yield* Effect.sync(() =>
+ Database.use((db) =>
+ db
+ .delete(InboxTable)
+ .where(inArray(InboxTable.id, rows.map((r) => r.id)))
+ .run(),
+ ),
+ )
+ return 0
+ }
+
// Non-transactional crash window: updateMessage + updatePart commit
// before the inbox DELETE. A crash between them re-renders the same
// rows on next drain — LLM sees duplicated notifications. Tolerable;
@@ -265,14 +303,14 @@ export const layer: Layer.Layer<
agent: seed.agent,
model: seed.model,
})
- for (const row of rows) {
+ for (const entry of rendered) {
yield* sessions.updatePart({
id: PartID.ascending(),
messageID: msgID,
sessionID,
type: "text" as const,
synthetic: true,
- text: renderInboxRow(row),
+ text: entry.text,
})
}
yield* Effect.sync(() =>
@@ -284,7 +322,7 @@ export const layer: Layer.Layer<
),
)
- return rows.length
+ return rendered.length
})
const impl = Service.of({ send, drain })
diff --git a/packages/opencode/src/inbox/render.ts b/packages/opencode/src/inbox/render.ts
index aa4c2df2c..1fd05fed2 100644
--- a/packages/opencode/src/inbox/render.ts
+++ b/packages/opencode/src/inbox/render.ts
@@ -1,11 +1,21 @@
import type { InboxRow } from "./inbox.sql"
+// A blank body must fall back to the placeholder, not just a missing one.
+// `?? placeholder` only catches null/undefined, but a blank body is stored as
+// "" (or whitespace) — and for actor_notification the body is passed through
+// RAW, so "" would become a user text part with text:"". The AI SDK's user
+// branch filters empty text parts out with no backfill, leaving `content: []`
+// and a provider 400 ("user messages must have non-empty content").
+function blankTo(text: string | undefined, placeholder: string) {
+ return text !== undefined && text.trim().length > 0 ? text : placeholder
+}
+
export function renderInboxRow(row: InboxRow): string {
if (row.type === "actor_notification") {
// Pre-rendered notification text — sender produced the full
// ... wrapper.
const content = row.content as { text?: string }
- return content.text ?? "(no notification body)"
+ return blankTo(content.text, "(no notification body)")
}
// Default: type === "text" or unknown — wrap as element so
// the LLM can route by sender; the wrapper format mirrors the
@@ -15,7 +25,7 @@ export function renderInboxRow(row: InboxRow): string {
? `${row.sender_session_id}:${row.sender_actor_id ?? "?"}`
: "system"
const sentAt = new Date(row.created_at).toISOString()
- return `\n${content.text ?? "(empty)"}\n`
+ return `\n${blankTo(content.text, "(empty)")}\n`
}
export function renderActorNotification(event: {
@@ -26,7 +36,10 @@ export function renderActorNotification(event: {
error?: string
reportedStatus?: string
reportedSummary?: string
- // For a stalled notification: how long (ms) since the child's last turn advanced.
+ // For a stalled notification: how long (ms) the child has been SILENT — nothing
+ // has landed for its slice. NOT time since the last completed step: the T40
+ // watchdog classifies on last_activity_time (actor/schema.ts deriveLiveness),
+ // and a child inside one long step keeps writing parts, so it never lands here.
stalledForMs?: number
}): string {
const header = `Background sub-session "${event.description}" (actor_id: ${event.actorID})`
@@ -58,9 +71,14 @@ export function renderActorNotification(event: {
return `\n${header} failed.\nError: ${event.error ?? "unknown"}\n`
}
if (event.status === "stalled") {
+ // "no activity", not "no turn advance". The quantity is silence since the last
+ // part write; saying "turn" described a signal the derivation stopped reading
+ // and made healthy children inside long steps look wedged. Likewise "nothing
+ // has landed" rather than "has made no progress" — a long step IS progress,
+ // and we cannot see inside one, only whether anything is coming out.
const forLine =
- event.stalledForMs !== undefined ? ` (no turn advance for ${Math.floor(event.stalledForMs / 1000)}s)` : ""
- return `\n${header} appears stalled${forLine}. It is still running but has made no progress. Consider checking on it, sending it a nudge, or cancelling it.\n`
+ event.stalledForMs !== undefined ? ` (no activity for ${Math.floor(event.stalledForMs / 1000)}s)` : ""
+ return `\n${header} appears stalled${forLine}. It is still running, but nothing has landed for it in that time. Consider checking on it, sending it a nudge, or cancelling it.\n`
}
return `\n${header} was cancelled.\n`
}
diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts
index ec991edd7..532f2deb9 100644
--- a/packages/opencode/src/index.ts
+++ b/packages/opencode/src/index.ts
@@ -64,10 +64,10 @@ function show(out: string) {
const text = out.trimStart()
if (!text.startsWith("mimo ")) {
process.stderr.write(UI.logo() + EOL + EOL)
- process.stderr.write(text)
+ process.stderr.write(UI.withTrailingEOL(text))
return
}
- process.stderr.write(out)
+ process.stderr.write(UI.withTrailingEOL(out))
}
const cli = yargs(args)
diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts
index ef7c571a4..c2e95b148 100644
--- a/packages/opencode/src/mcp/index.ts
+++ b/packages/opencode/src/mcp/index.ts
@@ -30,6 +30,8 @@ import { EffectBridge } from "@/effect"
import { InstanceState } from "@/effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
+import { McpSampling } from "./sampling"
+import { SessionID } from "@/session/schema"
const log = Log.create({ service: "mcp" })
const DEFAULT_TIMEOUT = 30_000
@@ -69,6 +71,176 @@ export const Failed = NamedError.create(
type MCPClient = Client
+export const TURN_LIFECYCLE_CAPABILITY = "com.xiaomi.mimo/turn-lifecycle"
+export const TURN_LIFECYCLE_NOTIFICATION = `notifications/${TURN_LIFECYCLE_CAPABILITY}`
+export const TURN_LIFECYCLE_VERSION = 1
+export const TURN_LIFECYCLE_NOTIFICATION_TIMEOUT = 1_000
+// A send that has already outlived the per-turn budget can never be useful to wait
+// on again, so later turns abandon it instead of queueing behind it forever.
+export const TURN_LIFECYCLE_STUCK_TIMEOUT = TURN_LIFECYCLE_NOTIFICATION_TIMEOUT
+
+/**
+ * Capabilities MiMoCode declares in `initialize`. Exported so tests assert on the
+ * SAME object the client is constructed with rather than a copy that could drift.
+ */
+export const CLIENT_OPTIONS = {
+ capabilities: {
+ // Declared because we register a `sampling/createMessage` request handler
+ // below; the SDK's assertRequestHandlerCapability refuses the registration
+ // without it. Intentionally an empty object: `sampling.tools` and
+ // `sampling.context` are NOT implemented, and declaring them would invite
+ // servers to send `tools`/`includeContext` payloads we would have to reject.
+ sampling: {},
+ experimental: {
+ [TURN_LIFECYCLE_CAPABILITY]: { version: TURN_LIFECYCLE_VERSION },
+ },
+ },
+}
+
+interface PendingTurnLifecycleNotification {
+ readonly promise: Promise
+ readonly waiters: Set<() => void>
+ readonly startedAt: number
+}
+
+const pendingTurnLifecycleNotifications = new WeakMap()
+
+export interface TurnContext {
+ [key: string]: unknown
+ sessionId: string
+ turnId: string
+ actorId?: string
+}
+
+export type TurnStatus = "completed" | "cancelled" | "error"
+
+function supportsTurnLifecycle(client: MCPClient) {
+ const capability = client.getServerCapabilities()?.experimental?.[TURN_LIFECYCLE_CAPABILITY]
+ return (
+ typeof capability === "object" &&
+ capability !== null &&
+ "version" in capability &&
+ capability.version === TURN_LIFECYCLE_VERSION
+ )
+}
+
+function startTurnLifecycleNotification(client: MCPClient, context: TurnContext, status: TurnStatus) {
+ if (pendingTurnLifecycleNotifications.has(client)) return undefined
+ const promise = Promise.resolve().then(() =>
+ client.notification({
+ method: TURN_LIFECYCLE_NOTIFICATION,
+ params: { ...context, status },
+ } as Parameters[0]),
+ )
+ const notification: PendingTurnLifecycleNotification = { promise, waiters: new Set(), startedAt: Date.now() }
+ pendingTurnLifecycleNotifications.set(client, notification)
+ const clear = () => {
+ if (pendingTurnLifecycleNotifications.get(client) === notification) {
+ pendingTurnLifecycleNotifications.delete(client)
+ }
+ const waiters = [...notification.waiters]
+ notification.waiters.clear()
+ for (const waiter of waiters) waiter()
+ }
+ // Attached at creation so an orphaned send's eventual rejection is always swallowed.
+ void promise.then(clear, clear)
+ return notification
+}
+
+// A send that outlives the per-turn budget is treated as stuck: drop it from the
+// pending map so the next turn sends immediately instead of paying the timeout
+// forever. The orphaned promise is never awaited again; its settlement still runs
+// `clear`, which no-ops because the map entry has been replaced.
+function releaseStuckTurnLifecycleNotification(
+ client: MCPClient,
+ notification: PendingTurnLifecycleNotification,
+ clientName: string,
+) {
+ if (pendingTurnLifecycleNotifications.get(client) !== notification) return
+ pendingTurnLifecycleNotifications.delete(client)
+ log.warn("abandoning stuck MCP turn lifecycle notification", {
+ clientName,
+ elapsed: Date.now() - notification.startedAt,
+ })
+ const waiters = [...notification.waiters]
+ notification.waiters.clear()
+ for (const waiter of waiters) waiter()
+}
+
+function waitForTurnLifecycleNotification(client: MCPClient, notification: PendingTurnLifecycleNotification) {
+ return Effect.tryPromise({
+ try: (signal) =>
+ new Promise((resolve, reject) => {
+ let done = false
+ const cleanup = () => {
+ notification.waiters.delete(onSettled)
+ signal.removeEventListener("abort", onAbort)
+ }
+ const finish = (complete: () => void) => {
+ if (done) return
+ done = true
+ cleanup()
+ complete()
+ }
+ const onSettled = () => finish(resolve)
+ const onAbort = () =>
+ finish(() => reject(signal.reason instanceof Error ? signal.reason : new Error("Lifecycle wait aborted")))
+
+ notification.waiters.add(onSettled)
+ signal.addEventListener("abort", onAbort, { once: true })
+
+ if (signal.aborted) onAbort()
+ else if (pendingTurnLifecycleNotifications.get(client) !== notification) onSettled()
+ }),
+ catch: (error) => (error instanceof Error ? error : new Error(String(error))),
+ })
+}
+
+function sendTurnLifecycleNotification(
+ client: MCPClient,
+ context: TurnContext,
+ status: TurnStatus,
+ clientName: string,
+) {
+ return Effect.gen(function* () {
+ while (true) {
+ const pending = pendingTurnLifecycleNotifications.get(client)
+ if (pending) {
+ if (Date.now() - pending.startedAt >= TURN_LIFECYCLE_STUCK_TIMEOUT) {
+ releaseStuckTurnLifecycleNotification(client, pending, clientName)
+ continue
+ }
+ yield* waitForTurnLifecycleNotification(client, pending)
+ continue
+ }
+
+ const notification = startTurnLifecycleNotification(client, context, status)
+ if (!notification) continue
+ return yield* Effect.tryPromise({
+ try: () => notification.promise,
+ catch: (error) => (error instanceof Error ? error : new Error(String(error))),
+ })
+ }
+ })
+}
+
+export function notifyTurnLifecycle(clients: Record, context: TurnContext, status: TurnStatus) {
+ return Effect.forEach(
+ Object.entries(clients),
+ ([clientName, client]) => {
+ if (!supportsTurnLifecycle(client)) return Effect.void
+ return sendTurnLifecycleNotification(client, context, status, clientName).pipe(
+ Effect.timeout(TURN_LIFECYCLE_NOTIFICATION_TIMEOUT),
+ Effect.tapError((error) =>
+ Effect.sync(() => log.warn("failed to notify MCP turn lifecycle", { clientName, status, error })),
+ ),
+ Effect.ignore,
+ )
+ },
+ { concurrency: "unbounded", discard: true },
+ )
+}
+
export const Status = z
.discriminatedUnion("status", [
z
@@ -137,7 +309,7 @@ function isMcpConfigured(entry: McpEntry): entry is ConfigMCP.Info {
const sanitize = (s: string) => s.replace(/[^a-zA-Z0-9_-]/g, "_")
// Convert MCP tool definition to AI SDK Tool type
-function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Tool {
+function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number, context?: TurnContext): Tool {
const inputSchema = mcpTool.inputSchema
// Spread first, then override type to ensure it's always "object"
@@ -151,15 +323,22 @@ function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number
return dynamicTool({
description: mcpTool.description ?? "",
inputSchema: jsonSchema(schema),
- execute: async (args: unknown) => {
+ execute: async (args: unknown, options) => {
+ const metadata =
+ context && supportsTurnLifecycle(client) ? { _meta: { [TURN_LIFECYCLE_CAPABILITY]: context } } : {}
+ // Recorded before the call so a `sampling/createMessage` arriving WHILE
+ // this call is in flight can address its approval prompt at this session.
+ if (context) McpSampling.setActiveSession(client, SessionID.make(context.sessionId))
return client.callTool(
{
name: mcpTool.name,
arguments: (args || {}) as Record,
+ ...metadata,
},
CallToolResultSchema,
{
resetTimeoutOnProgress: true,
+ signal: options.abortSignal,
timeout,
},
)
@@ -228,7 +407,7 @@ interface State {
export interface Interface {
readonly status: () => Effect.Effect>
readonly clients: () => Effect.Effect>
- readonly tools: () => Effect.Effect>
+ readonly tools: (context?: TurnContext) => Effect.Effect>
readonly prompts: () => Effect.Effect>
readonly resources: () => Effect.Effect>
readonly add: (name: string, mcp: ConfigMCP.Info) => Effect.Effect<{ status: Record | Status }>
@@ -260,6 +439,8 @@ export const layer = Layer.effect(
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const auth = yield* McpAuth.Service
const bus = yield* Bus.Service
+ const createClient = () =>
+ new Client({ name: "mimocode", version: InstallationVersion }, CLIENT_OPTIONS)
type Transport = StdioClientTransport | StreamableHTTPClientTransport | SSEClientTransport
@@ -273,7 +454,7 @@ export const layer = Layer.effect(
(t) =>
Effect.tryPromise({
try: () => {
- const client = new Client({ name: "mimocode", version: InstallationVersion })
+ const client = createClient()
return withTimeout(client.connect(t), timeout).then(() => client)
},
catch: (e) => (e instanceof Error ? e : new Error(String(e))),
@@ -491,6 +672,7 @@ export const layer = Layer.effect(
s.defs[name] = listed
await bridge.promise(bus.publish(ToolsChanged, { server: name }).pipe(Effect.ignore))
})
+ McpSampling.serve(name, client, bridge)
}
const state = yield* InstanceState.make(
@@ -546,6 +728,7 @@ export const layer = Layer.effect(
} catch {}
}
}
+ yield* McpSampling.cancelAll(client)
yield* Effect.tryPromise(() => client.close()).pipe(Effect.ignore)
}),
{ concurrency: "unbounded" },
@@ -562,7 +745,12 @@ export const layer = Layer.effect(
const client = s.clients[name]
delete s.defs[name]
if (!client) return Effect.void
- return Effect.tryPromise(() => client.close()).pipe(Effect.ignore)
+ // Interrupt sampling still running for this client first: once the
+ // transport is gone its response can never be delivered, so the fiber
+ // would otherwise keep a model call alive with nowhere to send the result.
+ return McpSampling.cancelAll(client).pipe(
+ Effect.andThen(Effect.tryPromise(() => client.close()).pipe(Effect.ignore)),
+ )
}
const storeClient = Effect.fnUntraced(function* (
@@ -637,7 +825,7 @@ export const layer = Layer.effect(
s.status[name] = { status: "disabled" }
})
- const tools = Effect.fn("MCP.tools")(function* () {
+ const tools = Effect.fn("MCP.tools")(function* (context?: TurnContext) {
const result: Record = {}
const s = yield* InstanceState.get(state)
@@ -664,7 +852,12 @@ export const layer = Layer.effect(
const timeout = entry?.timeout ?? defaultTimeout
for (const mcpTool of listed) {
- result[sanitize(clientName) + "_" + sanitize(mcpTool.name)] = convertMcpTool(mcpTool, client, timeout)
+ result[sanitize(clientName) + "_" + sanitize(mcpTool.name)] = convertMcpTool(
+ mcpTool,
+ client,
+ timeout,
+ context,
+ )
}
}),
{ concurrency: "unbounded" },
@@ -777,7 +970,7 @@ export const layer = Layer.effect(
return yield* Effect.tryPromise({
try: () => {
- const client = new Client({ name: "mimocode", version: InstallationVersion })
+ const client = createClient()
return client
.connect(transport)
.then(() => ({ authorizationUrl: "", oauthState, client }) satisfies AuthResult)
diff --git a/packages/opencode/src/mcp/sampling.ts b/packages/opencode/src/mcp/sampling.ts
new file mode 100644
index 000000000..f497eaf52
--- /dev/null
+++ b/packages/opencode/src/mcp/sampling.ts
@@ -0,0 +1,1029 @@
+import { Effect, Cause, Exit, Fiber } from "effect"
+import { streamText, type ModelMessage } from "ai"
+import { CreateMessageRequestSchema, ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js"
+import { Config } from "@/config"
+import { Permission } from "@/permission"
+import { Provider, ProviderTransform, ModelCapability } from "@/provider"
+import { InstallationVersion } from "@/installation/version"
+import { Log } from "@/util"
+import type { SessionID } from "@/session/schema"
+
+const log = Log.create({ service: "mcp.sampling" })
+
+/**
+ * MCP client-side sampling (`sampling/createMessage`).
+ *
+ * An MCP server asks US to run a model call on its behalf, so the server never
+ * needs its own API key. Everything about which model runs, whether the user
+ * agreed, and what the payload may contain is decided here — the server only
+ * expresses preferences.
+ *
+ * Spec: https://modelcontextprotocol.io/specification/2025-11-25/client/sampling
+ */
+
+/**
+ * SAMPLING INVENTS NO TIMEOUT NUMBERS. There is no total bound on the request, no
+ * bound on the model call, and no bound on the human approval wait. The one
+ * remaining silence bound is inherited from the provider layer; the one remaining
+ * interval is a deliberate choice about keepalive cadence. Everything below is why,
+ * because deleting guards obliges naming what stops being caught.
+ *
+ * THREE BOUNDS WERE REMOVED, ALL FOR THE SAME REASON: each was a number with no
+ * precedent in this repo, tighter than anything comparable, presented as policy.
+ * Two rounds of documentation had made them honest about lacking a derivation
+ * without ever asking the prior question — does a bound belong here at all? For
+ * each one the repo already had an answer, and in each case the answer was no.
+ *
+ * 1. THE TOTAL BOUNDS (a 120 s ceiling on the model call, and in `serve` an
+ * absolute ceiling equal to the sum of the phase bounds). `src/session/llm.ts`
+ * settles this for a real conversation: grep it for `Effect.timeout`,
+ * `AbortSignal.timeout` or `Schedule.upTo` and nothing comes back, and its retry
+ * schedule says so in words — "Intentionally NOT capped via Schedule.upTo() —
+ * retry persistence under brief upstream outages is the design goal. Bounding
+ * per-attempt latency via chunkTimeout is the primary lever for hang-time
+ * control" — with a worst case it states as ~97 minutes. For a streaming model
+ * call this repo's position is that ELAPSED TOTAL IS NOT A HEALTH SIGNAL,
+ * SILENCE IS. Sampling calls the same provider through the same SDK; a 2-minute
+ * total budget made it ~48x more impatient than the main path for no stated
+ * reason.
+ *
+ * 2. THE APPROVAL BOUND (30 s). `src/permission/index.ts` settles this too, and the
+ * other way round from how it was assumed: THE ORDINARY INTERACTIVE ASK HAS NO
+ * TIMEOUT AT ALL. It awaits the Deferred raced against the caller's abort signal,
+ * so a human takes as long as they take. Only two special cases are bounded — a
+ * FORWARDED ask (`FORWARD_DENY_TIMEOUT_MS`, :24) and a forced-ask under skip-all
+ * (`skipAllForcedAskTimeoutMs`, :29, env-overridable). Sampling's ask is neither:
+ * it passes no `forward`, and `mcp_sampling` is not in `FORCED_ASK` (:195, which
+ * holds only `bash_delete`). So a TUI chat prompt waits indefinitely while
+ * sampling used to give up at 30 s on the same kind of prompt.
+ *
+ * WHAT NO LONGER GETS CAUGHT, stated rather than glossed. The stall detector covers
+ * a provider that goes quiet, on any request. Three things it does not cover:
+ * a. A PATHOLOGICAL-BUT-ALIVE STREAM — trickling just often enough never to look
+ * stalled and never finishing. `llm.ts` accepts exactly this risk: `chunkTimeout`
+ * is also a bound on the GAP, so a single trickling attempt is unbounded there
+ * too.
+ * b. THE PRE-MODEL STRETCH — content conversion, provider listing, model
+ * selection, adapter initialisation — which only the `serve` ceiling covered.
+ * `llm.ts` calls the same `provider.getLanguage` (llm.ts:417) unbounded.
+ * c. AN APPROVAL NOBODY ANSWERS. Four things still release it: the operator
+ * replying; the peer cancelling (`extra.signal` is composed in, and a rejection
+ * settles promptly since the `raceFirst` fix); the peer's OWN request timeout,
+ * which is what produces that cancellation; and `cancelAll` when the client
+ * closes or the Instance tears down (`mcp/index.ts:731`, `:751`). The one
+ * residual is narrow and is named here rather than bounded: for the FIRST
+ * server-initiated request of a connection the SDK drops the cancellation (see
+ * the id-0 describe block in `test/mcp/sampling-e2e.test.ts`), so if that peer
+ * also holds the connection open and the operator never answers, the prompt
+ * pends until the client closes. That is a pending prompt a human can see and
+ * answer, which is the state this repo already accepts for every other ask —
+ * not a silent hang — and the peer is protected by its own timeout regardless.
+ * The same id-0 gap is why the stall detector is NOT gated on the peer having
+ * asked for progress: for that one request it is the only reaper.
+ */
+
+/**
+ * How often a liveness notification goes out while the model call is in flight.
+ *
+ * ITS JOB IS TO KEEP THE CONNECTION AND THE PEER'S TIMER FROM GOING IDLE, and that
+ * is now the whole of it. This value used to be justified against the silence bound
+ * as well — "3x below it, so a peer sees at least two beats before a stall is
+ * declared" — and that ratio is void: the silence bound is now the provider's
+ * `chunkTimeout` (8 minutes by default), against which 15 s is ~32x rather than 3x.
+ * The surviving reason is the one that never depended on the stall bound: a beat
+ * every 15 s sits well inside the MCP SDK's 60 s DEFAULT_REQUEST_TIMEOUT_MSEC, so
+ * several land within one peer timeout window instead of one landing near its edge,
+ * and an intermediary that drops idle connections sees continuous traffic.
+ *
+ * It is the ONE timeout-shaped number sampling still chooses for itself.
+ */
+export const DEFAULT_LIVENESS_INTERVAL = 15_000
+
+/**
+ * How long the model may produce NOTHING before we call it stalled — resolved from
+ * the SAME per-provider `chunkTimeout` the main conversation path uses, not from a
+ * number this module invented.
+ *
+ * IT USED TO BE OURS: `DEFAULT_SAMPLING_STALL_TIMEOUT = 45_000`, justified only as
+ * 3x the liveness interval. The repo already had this exact concept — "no output
+ * for this long means the stream is dead" — as `chunkTimeout`, wired in
+ * `provider.ts:wrapSSE`, configurable per provider in `mimocode.json`, default
+ * `DEFAULT_CHUNK_TIMEOUT` = 8 minutes. Carrying a second, tighter, differently
+ * named silence bound in the same repo for the same question was the defect; 45 s
+ * against 480 s is not a divergence to justify but a 10x disagreement about the
+ * same fact.
+ *
+ * AND THE REPO'S NUMBER IS THE ARGUED ONE. `DEFAULT_CHUNK_TIMEOUT`'s comment
+ * records a real observation — "mimo-v2.5-pro on MiMo Router whose cold-path TTFT
+ * after context rebuild can dip to ~5 minutes silent" — which is the only
+ * statement anywhere here about how long LEGITIMATE provider silence lasts. Our
+ * 45 s was 10x tighter than a value tuned to tolerate a real 5-minute silent cold
+ * path, so it would have declared a stall on calls the main path is explicitly
+ * built to survive. The false-positive risk the old constant's comment listed as
+ * hypothetical was in fact already measured, elsewhere, against us.
+ *
+ * WHY THE DETECTOR ITSELF IS STILL OURS, rather than deleted in favour of
+ * `wrapSSE`. The two observe at different points and ours sees strictly more:
+ * - `wrapSSE` bounds gaps between HTTP BYTES on an already-resolved
+ * `text/event-stream` Response, and keep-alive comments count as activity.
+ * - `stallWatch` bounds gaps between AI-SDK STREAM PARTS that carry model
+ * output, having excluded LIFECYCLE_PARTS after measuring that a
+ * never-answering provider still yields `start`.
+ * So `wrapSSE` is blind to three shapes ours catches: a fetch that never resolves
+ * at all (there is no Response to wrap yet), a stream that emits only keep-alive
+ * comments and never a token, and any provider whose adapter is not SSE-over-HTTP
+ * (`wrapSSE` returns the Response untouched unless the content type matches). Ours
+ * is blind to nothing `wrapSSE` catches. The single cost of the more sensitive
+ * observation point is a false positive on long legitimate silence — which is
+ * precisely the risk the magnitude controls, and precisely why the magnitude is
+ * now the tuned one rather than one we picked.
+ *
+ * `0` OR NEGATIVE DISABLES IT, because that is what the value already means to
+ * `provider.ts` (`chunkAbortCtl` is not created, so no bound is installed). An
+ * operator who turned the silence bound off for a provider turned it off for
+ * sampling too; second-guessing that here would make one documented switch mean
+ * two different things.
+ */
+export function chunkTimeoutFor(
+ config: { provider?: Record } | undefined> },
+ providerID: string,
+): number {
+ const configured = config.provider?.[providerID]?.options?.["chunkTimeout"]
+ // Same test provider.ts:1525 applies, so a non-number (including null) falls
+ // back rather than being treated as "configured".
+ return typeof configured === "number" ? configured : Provider.DEFAULT_CHUNK_TIMEOUT
+}
+
+/** How much of a prompt is shown in the approval dialog and in logs. */
+const PREVIEW_LENGTH = 200
+
+export type Policy = "deny" | "ask" | "allow"
+
+export const PERMISSION = "mcp_sampling"
+
+/**
+ * Non-standard JSON-RPC code for "a human refused". Distinct from InvalidParams
+ * so a server can tell "you asked wrong" from "the user said no" and stop
+ * retrying. -1 is the code the MCP reference servers already expect for this.
+ */
+export const REJECTED_CODE = -1
+
+/** The SDK's own RequestTimeout code, reused so servers see a familiar value. */
+export const TIMEOUT_CODE = ErrorCode.RequestTimeout
+
+export interface AudioSummary {
+ readonly mimeType: string
+ readonly bytes: number
+}
+
+export interface RequestSummary {
+ readonly server: string
+ readonly contentTypes: ReadonlyArray
+ readonly audio: ReadonlyArray
+ readonly systemPrompt?: string
+ readonly textPrompt?: string
+}
+
+interface SamplingContentText {
+ type: "text"
+ text: string
+}
+
+interface SamplingContentMedia {
+ type: "image" | "audio"
+ data: string
+ mimeType: string
+}
+
+type SamplingContent = SamplingContentText | SamplingContentMedia
+
+export interface SamplingMessage {
+ role: "user" | "assistant"
+ content: SamplingContent | ReadonlyArray
+}
+
+export interface CreateMessageParams {
+ messages: ReadonlyArray
+ systemPrompt?: string
+ includeContext?: "none" | "thisServer" | "allServers"
+ maxTokens: number
+ temperature?: number
+ stopSequences?: ReadonlyArray
+ metadata?: Record
+ modelPreferences?: {
+ hints?: ReadonlyArray<{ name?: string }>
+ costPriority?: number
+ speedPriority?: number
+ intelligencePriority?: number
+ }
+ tools?: unknown
+ toolChoice?: unknown
+}
+
+export interface CreateMessageResult {
+ role: "assistant"
+ content: SamplingContentText
+ model: string
+ stopReason: string
+}
+
+/**
+ * A structured failure that maps 1:1 onto a JSON-RPC error. Carried on the
+ * Effect FAILURE channel (never thrown inside Effect.fn, which would make it a
+ * defect that Effect.catch cannot see — see tool/session.ts:801-807).
+ */
+export class SamplingError extends Error {
+ readonly code: number
+ readonly data: Record | undefined
+ constructor(code: number, message: string, data?: Record) {
+ super(message)
+ this.name = "SamplingError"
+ this.code = code
+ this.data = data
+ }
+ toMcpError(): McpError {
+ return new McpError(this.code, this.message, this.data)
+ }
+}
+
+function invalidParams(message: string, data?: Record) {
+ return new SamplingError(ErrorCode.InvalidParams, message, data)
+}
+
+/**
+ * Base64 with no whitespace, correct padding, and a length that is a multiple of
+ * 4. Deliberately strict: a lenient decode would let malformed audio reach the
+ * provider and fail there with a far worse error.
+ */
+const BASE64 = /^[A-Za-z0-9+/]*={0,2}$/
+
+export function decodedByteLength(data: string): number | undefined {
+ if (data.length === 0) return 0
+ if (data.length % 4 !== 0) return undefined
+ if (!BASE64.test(data)) return undefined
+ const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0
+ // Padding may only appear in the final quantum.
+ if (data.slice(0, -4).includes("=")) return undefined
+ return (data.length / 4) * 3 - padding
+}
+
+const MIME = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/i
+
+export function normalizeMime(mimeType: string, modality: "image" | "audio") {
+ const value = mimeType.trim().split(";", 1)[0]?.trim().toLowerCase() ?? ""
+ if (!MIME.test(value)) return undefined
+ if (!value.startsWith(`${modality}/`)) return undefined
+ return value
+}
+
+interface Converted {
+ readonly messages: ModelMessage[]
+ readonly requirements: ModelCapability.ContentRequirement[]
+ readonly summary: Omit
+}
+
+function toArray(content: SamplingContent | ReadonlyArray): ReadonlyArray {
+ return Array.isArray(content) ? content : [content as SamplingContent]
+}
+
+/**
+ * Validate the server's content and convert it into ai-sdk `ModelMessage`s.
+ *
+ * Media becomes a real `file` part carrying raw bytes with its media type — the
+ * same shape the session multimodal path produces (see message-v2.ts and the
+ * `mediaType` routing in tool-attachment.ts). Audio is NEVER stringified into a
+ * text part; a model that cannot take audio must be rejected, not fed a lie.
+ */
+export function convertMessages(params: CreateMessageParams): Converted | SamplingError {
+ if (params.tools !== undefined || params.toolChoice !== undefined) {
+ // Spec: the client MUST error when `tools` is present without having
+ // declared `sampling.tools`, which we deliberately do not declare yet.
+ return invalidParams("this client does not declare sampling.tools; remove tools/toolChoice", {
+ declaredCapabilities: { sampling: {} },
+ })
+ }
+ if (!Array.isArray(params.messages) || params.messages.length === 0) {
+ return invalidParams("messages must be a non-empty array")
+ }
+ if (!Number.isInteger(params.maxTokens) || params.maxTokens <= 0) {
+ return invalidParams("maxTokens must be a positive integer")
+ }
+ if (params.temperature !== undefined && (typeof params.temperature !== "number" || !isFinite(params.temperature))) {
+ return invalidParams("temperature must be a finite number")
+ }
+
+ const messages: ModelMessage[] = []
+ const requirements: ModelCapability.ContentRequirement[] = []
+ const contentTypes = new Set()
+ const audio: AudioSummary[] = []
+ let textPrompt: string | undefined
+
+ const systemBytes = Buffer.byteLength(params.systemPrompt ?? "", "utf8")
+ if (systemBytes > ModelCapability.DEFAULT_MAX_TEXT_BYTES) {
+ return invalidParams("systemPrompt exceeds the maximum size", {
+ bytes: systemBytes,
+ maxBytes: ModelCapability.DEFAULT_MAX_TEXT_BYTES,
+ })
+ }
+
+ for (const message of params.messages) {
+ if (message?.role !== "user" && message?.role !== "assistant") {
+ return invalidParams(`unsupported message role "${String(message?.role)}"`)
+ }
+ const parts: Array<
+ { type: "text"; text: string } | { type: "file"; data: string; mediaType: string }
+ > = []
+ for (const item of toArray(message.content)) {
+ if (item?.type === "text") {
+ if (typeof item.text !== "string") return invalidParams("text content must be a string")
+ const bytes = Buffer.byteLength(item.text, "utf8")
+ contentTypes.add("text")
+ requirements.push({ modality: "text", bytes })
+ parts.push({ type: "text", text: item.text })
+ if (message.role === "user" && textPrompt === undefined) textPrompt = item.text
+ continue
+ }
+ if (item?.type === "image" || item?.type === "audio") {
+ const modality = item.type
+ if (typeof item.data !== "string") return invalidParams(`${modality} content data must be a base64 string`)
+ if (typeof item.mimeType !== "string") return invalidParams(`${modality} content requires a mimeType`)
+ const mimeType = normalizeMime(item.mimeType, modality)
+ if (!mimeType) {
+ return invalidParams(`invalid ${modality} mimeType "${item.mimeType}"`, { mimeType: item.mimeType })
+ }
+ const bytes = decodedByteLength(item.data)
+ if (bytes === undefined) return invalidParams(`${modality} content data is not valid base64`)
+ if (bytes === 0) return invalidParams(`${modality} content data is empty`)
+ contentTypes.add(modality)
+ if (modality === "audio") audio.push({ mimeType, bytes })
+ requirements.push({ modality, mimeType, bytes })
+ parts.push({ type: "file", data: item.data, mediaType: mimeType })
+ continue
+ }
+ return invalidParams(`unsupported content type "${String((item as { type?: unknown })?.type)}"`)
+ }
+ if (parts.length === 0) return invalidParams("each message must carry at least one content block")
+ messages.push({ role: message.role, content: parts } as ModelMessage)
+ }
+
+ return {
+ messages,
+ requirements,
+ summary: {
+ contentTypes: [...contentTypes],
+ audio,
+ systemPrompt: preview(params.systemPrompt),
+ textPrompt: preview(textPrompt),
+ },
+ }
+}
+
+export function preview(value: string | undefined) {
+ if (!value) return undefined
+ const clean = value.replace(/\s+/g, " ").trim()
+ if (clean.length <= PREVIEW_LENGTH) return clean
+ return `${clean.slice(0, PREVIEW_LENGTH)}…`
+}
+
+export function policyFor(config: { mcp?: Record }, server: string): Policy {
+ // A nullable/absent config field arrives as undefined OR null depending on
+ // where it was parsed from, so discriminate on truthiness rather than on
+ // `=== undefined`, which would silently treat null as "configured".
+ const configured = config.mcp?.[server]?.sampling
+ if (configured === "deny" || configured === "allow" || configured === "ask") return configured
+ return "ask"
+}
+
+function mapStopReason(finishReason: string | undefined, stopSequences: ReadonlyArray | undefined) {
+ if (finishReason === "length") return "maxTokens"
+ if (finishReason === "stop") return stopSequences && stopSequences.length > 0 ? "stopSequence" : "endTurn"
+ return finishReason ?? "endTurn"
+}
+
+/**
+ * What is needed to keep a PEER's request timer alive while we work. Neither
+ * field is ours to invent: we are the CLIENT answering a server-initiated
+ * request, so the token belongs to the requester's message id and only the
+ * requester can mint it (`shared/protocol.js` sets
+ * `params._meta.progressToken = messageId`, and only when its caller passed
+ * `onprogress`). `serve` reads it back out of the request the SDK handed us and
+ * builds this; when the server did not ask for progress there is no token and
+ * this is `undefined`, which means we send nothing at all.
+ */
+export interface Liveness {
+ readonly progressToken: string | number
+ /** `extra.sendNotification` from the SDK request handler — this connection. */
+ readonly send: (notification: { method: string; params: Record }) => Promise
+ readonly intervalMs: number
+}
+
+/**
+ * Stream parts that are the SDK's own bookkeeping rather than model output.
+ *
+ * MEASURED, NOT ASSUMED, and the measurement overturned the obvious guess. Against
+ * a provider whose HTTP call never answers at all, `fullStream` still yields
+ * `start` immediately (and `abort` at the end). So "every part proves the provider
+ * is alive" is false: counting parts indiscriminately made a stone-dead provider
+ * report `1 chunk`, which destroys the single distinction this signal exists to
+ * draw — never started versus started and went quiet. Only parts OUTSIDE this set
+ * advance the activity record, so `chunks === 0` means exactly what it says.
+ *
+ * The terminal markers are excluded for the same reason and cost nothing: they
+ * arrive when the stream is already ending, so they could not have rescued a call
+ * from a stall verdict anyway.
+ */
+const LIFECYCLE_PARTS = new Set(["start", "start-step", "finish-step", "finish", "abort", "error"])
+
+/**
+ * What the model call has actually produced so far, written by the stream loop in
+ * `handle` and read by the two watchers below. Mutable on purpose: it is the one
+ * piece of shared state that makes "is it hung?" answerable rather than guessed.
+ *
+ * `characters` is a COUNT, never the text — see `heartbeat` for why the text
+ * itself does not leave this process on the progress channel.
+ */
+interface StreamActivity {
+ /** Epoch ms of the last chunk, or of the call starting if none has arrived. */
+ lastAt: number
+ /** Chunks of model output seen. 0 means the provider has produced nothing. */
+ chunks: number
+ /** Characters of model text seen. */
+ characters: number
+}
+
+/**
+ * LIVENESS FROM REAL EVIDENCE — and what is deliberately NOT sent.
+ *
+ * The model call streams (`streamText`), so unlike the previous fixed tick this
+ * notification reports something observed: how many chunks the provider has
+ * actually produced. That upgrade matters because the two failures a peer most
+ * needs to tell apart are "our process died" and "the provider went quiet", and a
+ * tick that increments on a local timer cannot distinguish them — it keeps
+ * arriving, unchanged, while the provider produces nothing forever. A peer can now
+ * see the difference, including the specific case of a call that never started at
+ * all, which gets its own wording.
+ *
+ * WHY THE MODEL'S TEXT IS NOT IN `message`, even though we now have it. Three
+ * reasons, and the middle one is the one that decided it.
+ * 1. CHATTINESS. Deltas arrive far faster than this interval; forwarding each
+ * would turn a keepalive into a second, unasked-for output stream. What goes
+ * out is coalesced to one notification per interval no matter the delta rate.
+ * 2. IT WOULD DELIVER OUTPUT THAT A FAILED REQUEST NEVER DELIVERS. The response
+ * contract is a single `CreateMessageResult`: if this request later stalls,
+ * times out or is cancelled, the server is told it failed and receives NO
+ * text. Streaming partial content on the progress channel would hand it a
+ * prefix of an answer the contract says it never got — a disclosure that
+ * exists only in the failure case, which is the worst place to invent one.
+ * 3. A SERVER THAT ASKED FOR PROGRESS DID NOT ASK FOR CONTENT. `onprogress` is
+ * how a peer says "tell me you are alive"; MCP has no partial-result channel,
+ * and reading `message` as one would be us deciding on the peer's behalf.
+ * What IS disclosed is the running length. That is metadata, not content, and the
+ * server learns the exact length seconds later from the result anyway — but it is
+ * a real if small disclosure and is named here rather than glossed over.
+ *
+ * `progress` stays a monotonic TICK, not the chunk count: the spec asks only that
+ * the value increase, and a chunk count does not increase during exactly the quiet
+ * stretch a peer most needs a notification for. `total` IS STILL OMITTED, because
+ * streaming does not tell us how many chunks are coming either — a fraction is
+ * still not computable, so none is implied.
+ *
+ * Runs forever and never fails: each send is ignored, because a peer that cannot
+ * receive a notification must not thereby kill the model call. Raced against the
+ * model call with `raceFirst` — the model settling first (success OR failure)
+ * interrupts this.
+ */
+function heartbeat(liveness: Liveness, activity: StreamActivity): Effect.Effect {
+ let tick = 0
+ return Effect.forever(
+ Effect.sleep(liveness.intervalMs).pipe(
+ Effect.flatMap(() =>
+ Effect.tryPromise({
+ try: () =>
+ liveness.send({
+ method: "notifications/progress",
+ params: {
+ progressToken: liveness.progressToken,
+ progress: ++tick,
+ message:
+ activity.chunks === 0
+ ? "sampling: model call in flight, no output yet"
+ : `sampling: model streaming, ${activity.chunks} chunks / ${activity.characters} characters so far`,
+ },
+ }),
+ catch: (error) => error,
+ }).pipe(Effect.ignore),
+ ),
+ ),
+ )
+}
+
+/**
+ * THE STALL DETECTOR — now the ONLY bound on the model call, which is why the total
+ * bounds could go.
+ *
+ * Fails as soon as `stallMs` has passed with no chunk arriving. The clock covers
+ * BOTH the wait for the first chunk and every gap between later chunks, because "no
+ * output at all" is the same symptom in both places; every arriving chunk resets it.
+ * Unlike a total bound this is a claim about the provider rather than about our
+ * patience: the stream stopped, and that is a fact we watched happen instead of a
+ * deadline we picked. Raced against the model call with `raceFirst`, so this failure
+ * interrupts the call fiber, which aborts the provider through the signal
+ * composed in `handle`.
+ *
+ * NOT gated on the peer having asked for progress. The heartbeat is (an
+ * unsolicited notification is an error on the peer's side); detecting our own
+ * stalled provider is not the peer's business and happens regardless. It is also
+ * the only reaper for the first server-initiated request of a connection, whose
+ * cancellation the SDK drops — see the top-of-file comment.
+ *
+ * Polling rather than a per-chunk timer, because the chunk loop lives inside a
+ * promise and this has to observe it from outside without restructuring it. The
+ * poll interval only bounds detection LATENCY, never correctness: a stall is
+ * reported at most one poll late, never early, since the comparison is against a
+ * timestamp the loop wrote.
+ */
+function stallWatch(
+ activity: StreamActivity,
+ stallMs: number,
+ onStalled: () => SamplingError,
+): Effect.Effect {
+ const poll = Math.max(25, Math.min(250, Math.floor(stallMs / 4)))
+ return Effect.forever(
+ Effect.sleep(poll).pipe(
+ Effect.flatMap(() => (Date.now() - activity.lastAt >= stallMs ? Effect.fail(onStalled()) : Effect.void)),
+ ),
+ )
+}
+
+export interface HandleInput {
+ readonly server: string
+ readonly params: CreateMessageParams
+ /**
+ * Session the approval prompt belongs to. Absent when no turn is in flight for
+ * this client; under the `ask` policy that fails closed rather than raising a
+ * prompt no UI is listening to.
+ */
+ readonly sessionID: SessionID | undefined
+ readonly signal?: AbortSignal
+ /**
+ * How long the model may produce nothing before the call is declared stalled.
+ * Defaults to the provider's `chunkTimeout` — see `chunkTimeoutFor`. This is the
+ * ONLY bound on the model call; there is deliberately no total one.
+ */
+ readonly chunkTimeoutMs?: number
+ /** Absent when the server did not ask for progress; then nothing is emitted. */
+ readonly liveness?: Liveness
+}
+
+/**
+ * Run one sampling request end to end. Fails with `SamplingError` only — the
+ * caller turns that into a JSON-RPC error response.
+ */
+export const handle = Effect.fn("MCP.sampling.handle")(function* (input: HandleInput) {
+ const started = Date.now()
+ const cfgSvc = yield* Config.Service
+ const provider = yield* Provider.Service
+ const permission = yield* Permission.Service
+ const cfg = yield* cfgSvc.get()
+
+ const policy = policyFor(cfg as never, input.server)
+ // TWO controls gate sampling and a `deny` from either one wins: the per-server
+ // `mcp..sampling` policy, and the standard `permission.mcp_sampling`
+ // ruleset. Evaluating the ruleset HERE rather than leaning on permission.ask
+ // is what makes that true — `allow` skips the ask entirely, so an explicit
+ // ruleset deny would otherwise never be consulted at all. Same precedence the
+ // permission service applies internally (permission/index.ts:243-247): a
+ // ruleset deny is not out-rankable by a more permissive setting elsewhere.
+ const ruleset = Permission.fromConfig(cfg.permission ?? {})
+ const ruleDenied = Permission.evaluate(PERMISSION, input.server, ruleset).action === "deny"
+ if (policy === "deny" || ruleDenied) {
+ return yield* Effect.fail(
+ new SamplingError(REJECTED_CODE, `sampling is denied for MCP server "${input.server}"`, {
+ server: input.server,
+ policy,
+ deniedBy: policy === "deny" ? "mcp.sampling" : "permission.mcp_sampling",
+ }),
+ )
+ }
+
+ const converted = convertMessages(input.params)
+ if (converted instanceof SamplingError) return yield* Effect.fail(converted)
+
+ const summary: RequestSummary = { server: input.server, ...converted.summary }
+
+ // Model selection: capability + credentials FIRST, hints only to rank.
+ const providers = yield* provider.list()
+ const configured = Object.values(providers).flatMap((info) => Object.values(info.models))
+ const fallbackRef = yield* provider.defaultModel().pipe(Effect.catchCause(() => Effect.succeed(undefined)))
+ const fallback = fallbackRef
+ ? yield* provider
+ .getModel(fallbackRef.providerID, fallbackRef.modelID)
+ .pipe(Effect.catchDefect(() => Effect.succeed(undefined)), Effect.catchCause(() => Effect.succeed(undefined)))
+ : undefined
+
+ const selection = ModelCapability.selectModel({
+ models: configured,
+ requirements: converted.requirements,
+ hints: input.params.modelPreferences?.hints,
+ fallback,
+ })
+
+ if (!selection.ok) {
+ return yield* Effect.fail(
+ new SamplingError(ErrorCode.InvalidParams, "no configured model can accept this sampling request", {
+ server: input.server,
+ required: selection.requirements.map((item) => ({
+ modality: item.modality,
+ mimeType: item.mimeType,
+ bytes: item.bytes,
+ })),
+ rejected: selection.rejections.map((item) => ({
+ model: item.model,
+ reason: ModelCapability.describeRejection(item.reason),
+ })),
+ }),
+ )
+ }
+
+ const model = selection.model
+ const modelRef = ModelCapability.modelRef(model)
+ // Resolved HERE and not at the top of `handle` because it is the SELECTED
+ // provider's setting: which provider runs a sampling request is decided by
+ // capability matching, so its silence bound is not knowable before that.
+ const chunkTimeoutMs = input.chunkTimeoutMs ?? chunkTimeoutFor(cfg as never, model.providerID)
+
+ if (policy === "ask") {
+ const sessionID = input.sessionID
+ if (!sessionID) {
+ // Fail closed: an `ask` with no session would publish a prompt no client
+ // is listening for, and waiting on it would hang the server's request.
+ return yield* Effect.fail(
+ new SamplingError(
+ REJECTED_CODE,
+ `sampling for MCP server "${input.server}" needs approval but no active session is available`,
+ { server: input.server, model: modelRef, policy },
+ ),
+ )
+ }
+ yield* permission
+ .ask(
+ {
+ sessionID,
+ permission: PERMISSION,
+ patterns: [input.server],
+ always: [input.server],
+ ruleset,
+ metadata: {
+ server: input.server,
+ model: modelRef,
+ requestedModel: input.params.modelPreferences?.hints?.map((hint) => hint.name).filter(Boolean) ?? [],
+ contentTypes: summary.contentTypes,
+ audio: summary.audio,
+ systemPrompt: summary.systemPrompt,
+ textPrompt: summary.textPrompt,
+ maxTokens: input.params.maxTokens,
+ },
+ },
+ input.signal,
+ )
+ .pipe(
+ Effect.catch((error) =>
+ Effect.fail(
+ new SamplingError(REJECTED_CODE, `the user declined sampling for MCP server "${input.server}"`, {
+ server: input.server,
+ model: modelRef,
+ reason: error._tag,
+ }),
+ ),
+ ),
+ // NO BOUND ON THE APPROVAL WAIT, deliberately — see the top-of-file comment.
+ // The ordinary interactive ask in `permission/index.ts` has none either, and
+ // this ask is the ordinary kind: no `forward`, and `mcp_sampling` is not in
+ // `FORCED_ASK`. `input.signal` is passed above, so a peer cancellation ends
+ // the wait promptly; the operator answering ends it; `cancelAll` ends it when
+ // the client closes.
+ )
+ }
+
+ const language = yield* provider
+ .getLanguage(model)
+ .pipe(
+ Effect.catchCause((cause) =>
+ Effect.fail(
+ new SamplingError(ErrorCode.InternalError, "failed to initialise the selected model", {
+ model: modelRef,
+ // Cause.pretty of a plain Error renders only its message, so no
+ // provider credential can ride along here.
+ detail: Cause.pretty(cause).split("\n")[0],
+ }),
+ ),
+ ),
+ )
+
+ // The signal actually handed to the provider. Assigned by `tryPromise` below
+ // and read by its `catch`, which has to tell "we aborted this" from "the
+ // provider genuinely failed" without assuming which source aborted.
+ let providerSignal: AbortSignal | undefined
+
+ // Shared with the two watchers raced against the call below. Seeded now, reset
+ // when the request is actually issued, and advanced by every chunk.
+ const activity: StreamActivity = { lastAt: Date.now(), chunks: 0, characters: 0 }
+
+ const call = Effect.tryPromise({
+ try: (fiberSignal: AbortSignal) => {
+ // COMPOSE both abort sources. `fiberSignal` is aborted whenever this fiber
+ // is interrupted, which covers the stall detector below and `cancelAll`; on
+ // its own, neither of those
+ // reaches the provider, because interrupting a fiber does not cancel a
+ // promise already in flight inside it. `input.signal` is the MCP SDK's
+ // per-request signal and covers a server-issued cancellation. Either one
+ // must stop the HTTP call, so the provider gets the union of the two, not
+ // just one of them.
+ providerSignal = input.signal ? AbortSignal.any([fiberSignal, input.signal]) : fiberSignal
+ const stream = streamText({
+ model: language,
+ system: input.params.systemPrompt,
+ messages: converted.messages,
+ maxOutputTokens: Math.min(input.params.maxTokens, ProviderTransform.maxOutputTokens(model)),
+ temperature: model.capabilities.temperature ? input.params.temperature : undefined,
+ stopSequences: input.params.stopSequences ? [...input.params.stopSequences] : undefined,
+ providerOptions: ProviderTransform.providerOptions(model, {}),
+ headers: { ...model.headers, "User-Agent": `mimocode/${InstallationVersion}` },
+ abortSignal: providerSignal,
+ maxRetries: 1,
+ // `streamText` reports provider failures as an `error` PART rather than by
+ // rejecting, and its default handler logs them. The loop below rethrows
+ // that part, which is what puts the failure back on the path `catch`
+ // already maps, so this handler exists only to stop the duplicate log.
+ onError: () => {},
+ })
+ // WHY THE STREAM IS ASSEMBLED HERE AND NOT RETURNED. The response contract is
+ // a single `CreateMessageResult` — `sampling/createMessage` has one reply and
+ // JSON-RPC has no streaming response — so streaming is an INTERNAL change:
+ // it buys an observable stall signal and real liveness, and changes nothing a
+ // server receives. The text is concatenated verbatim in arrival order.
+ return (async () => {
+ activity.lastAt = Date.now()
+ let text = ""
+ for await (const part of stream.fullStream) {
+ // Same shape as the in-tree consumers (session/goal.ts): an `error` part
+ // is the provider failing, so rethrow it and let `catch` classify it
+ // exactly as it classified a rejected `generateText`.
+ if (part.type === "error") throw part.error
+ if (part.type === "text-delta") {
+ text += part.text
+ activity.characters += part.text.length
+ }
+ // Only genuine model output counts as life. `start` arrives even from a
+ // provider that never answers, so lifecycle parts neither increment the
+ // count nor reset the stall clock — see LIFECYCLE_PARTS.
+ if (!LIFECYCLE_PARTS.has(part.type)) {
+ activity.chunks += 1
+ activity.lastAt = Date.now()
+ }
+ }
+ return { text, finishReason: await stream.finishReason }
+ })()
+ },
+ catch: (error) => {
+ const message = error instanceof Error ? error.message : String(error)
+ if (providerSignal?.aborted ?? input.signal?.aborted) {
+ return new SamplingError(ErrorCode.RequestTimeout, "sampling was cancelled", { server: input.server })
+ }
+ return new SamplingError(ErrorCode.InternalError, "the model provider failed to complete sampling", {
+ server: input.server,
+ model: modelRef,
+ detail: message,
+ })
+ },
+ })
+
+ // THE STALL DETECTOR, raced first and NOT gated on the peer asking for progress.
+ // `raceFirst` is "first to SETTLE", so this failing interrupts the call fiber and
+ // aborts the provider; `Effect.race` would be wrong because it waits for a losing
+ // side to fail and neither side here obliges.
+ //
+ // Skipped entirely at `<= 0`, which is what that value already means to
+ // `provider.ts` — see `chunkTimeoutFor`. Then the call has no bound of ours at
+ // all, exactly as a `chunkTimeout: 0` conversation has none on the main path.
+ const watched =
+ chunkTimeoutMs > 0
+ ? Effect.raceFirst(
+ call,
+ stallWatch(
+ activity,
+ chunkTimeoutMs,
+ () =>
+ new SamplingError(TIMEOUT_CODE, "sampling stalled: the model produced no output", {
+ server: input.server,
+ model: modelRef,
+ // Kept as its own phase now that `"model"` and `"total"` are gone:
+ // it says output STOPPED, which is a claim about the provider, and
+ // it is the only expiry the model call can now produce.
+ phase: "stall",
+ timeout: chunkTimeoutMs,
+ // The observability payoff, and the reason this is not just a
+ // shorter timeout: 0 says the provider never produced anything,
+ // non-zero says it started and then went quiet.
+ chunks: activity.chunks,
+ characters: activity.characters,
+ }),
+ ),
+ )
+ : call
+
+ // KEEPALIVE, and only if the server asked for it. `raceFirst` again, for the same
+ // reason: the model call winning with a failure — including a stall — still has
+ // to interrupt the heartbeat, which never settles and so can never win.
+ const kept = input.liveness ? Effect.raceFirst(watched, heartbeat(input.liveness, activity)) : watched
+
+ const result = yield* kept
+
+ // The model's text is returned verbatim: no summarising, no rewriting.
+ const text = result.text ?? ""
+ log.info("sampling completed", {
+ server: input.server,
+ model: modelRef,
+ via: selection.via,
+ contentTypes: summary.contentTypes,
+ audioBytes: summary.audio.reduce((total, item) => total + item.bytes, 0),
+ duration: Date.now() - started,
+ status: "ok",
+ })
+
+ return {
+ role: "assistant" as const,
+ content: { type: "text" as const, text },
+ model: modelRef,
+ stopReason: mapStopReason(result.finishReason, input.params.stopSequences),
+ } satisfies CreateMessageResult
+})
+
+/**
+ * Session of the turn a client is currently serving, used to address the sampling
+ * approval prompt at the right session. Written when a tool call starts and read
+ * by the sampling handler, which by definition runs while that call is still in
+ * flight. A WeakMap so a discarded client takes its entry with it.
+ */
+const activeSessions = new WeakMap