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() + +export function setActiveSession(client: object, sessionID: SessionID) { + activeSessions.set(client, sessionID) +} + +/** In-flight sampling fibers per client, interrupted when the client goes away. */ +const inFlight = new WeakMap>>() + +/** + * Interrupt every sampling request still running for a client. The interrupt + * aborts the in-flight provider call too, because `handle` hands the provider a + * signal derived from its own fiber — see the abort composition there. + */ +export function cancelAll(client: object) { + const fibers = inFlight.get(client) + if (!fibers) return Effect.void + const pending = [...fibers] + fibers.clear() + return Effect.forEach(pending, (fiber) => Fiber.interrupt(fiber).pipe(Effect.ignore), { + concurrency: "unbounded", + discard: true, + }).pipe(Effect.ignore) +} + +/** How many sampling requests are currently running for a client. Test-facing. */ +export function inFlightCount(client: object) { + return inFlight.get(client)?.size ?? 0 +} + +/** + * The part of the SDK's request-handler `extra` this module reads. Deliberately + * `unknown` for everything but the signal: `_meta` and `sendNotification` are + * typed on the SDK side against a notification union that is generic over the + * schema, and naming those types here would couple the module to SDK internals + * for no gain — the two are narrowed at the use site instead. + */ +export interface SamplingRequestExtra { + signal?: AbortSignal + /** The request's own `params._meta`, passed through verbatim by `_onrequest`. */ + _meta?: unknown + /** Sends a notification on THIS request's connection, tagged to its id. */ + sendNotification?: unknown +} + +/** + * The subset of the MCP `Client` surface this module drives. Typed loosely on + * purpose: the SDK's own `setRequestHandler` signature is generic over the Zod + * schema and infers a result type we satisfy structurally, so pinning it exactly + * here would only couple this module to SDK internals. + */ +export interface SamplingClient { + setRequestHandler( + schema: typeof CreateMessageRequestSchema, + handler: (request: { params?: unknown }, extra?: SamplingRequestExtra) => Promise, + ): void +} + +/** + * Read the progress token the REQUESTER minted, if it minted one. + * + * We are the client answering a server-initiated request, so we never choose this + * value. The SDK's requester side writes it only when its caller asked for + * progress (`shared/protocol.js`: `if (options?.onprogress) { ... _meta: { ..., + * progressToken: messageId } }`) and the responder side hands the handler that + * same object (`_meta: request.params?._meta`). NO TOKEN THEREFORE MEANS THE + * SERVER DID NOT ASK FOR PROGRESS, and we must send nothing at all — an + * unsolicited notification hits `_onprogress`'s "unknown token" branch and is + * reported to the peer as an error. + */ +function progressTokenOf(extra: SamplingRequestExtra | undefined) { + const meta = extra?._meta + if (typeof meta !== "object" || meta === null) return undefined + const token = (meta as { progressToken?: unknown }).progressToken + return typeof token === "string" || typeof token === "number" ? token : undefined +} + +export interface Bridge { + readonly fork: (effect: Effect.Effect) => Fiber.Fiber +} + +/** + * Register the server->client `sampling/createMessage` handler on a connected + * client. + * + * DEADLOCK AVOIDANCE. Two independent facts make a nested sampling request safe + * while we are parked on that same server's `tools/call`: + * + * 1. The SDK dispatches inbound requests from the transport's `onmessage` + * WITHOUT awaiting the handler (sdk/shared/protocol.js `_onrequest`), so our + * work never blocks the read loop that must later deliver the tool result. + * 2. Our work runs through `bridge.fork`, i.e. a FRESH ROOT FIBER that shares no + * fiber, lock or scope with the fiber awaiting `client.callTool`. + * + * Both directions therefore make progress independently. + * + * `chunkTimeoutMs` bounds how long the model may produce NOTHING and defaults to the + * selected provider's `chunkTimeout` (see `chunkTimeoutFor`); `livenessIntervalMs` + * sets the keepalive cadence. Production passes neither. `chunkTimeoutMs` is a + * parameter so the stall path can be driven in a test without waiting minutes. + * + * NO WALL-CLOCK BOUND IS APPLIED HERE AT ALL — not on the whole request, not on the + * model call, not on the approval wait. Each of the three that used to exist was a + * number with no precedent in this repo; the top-of-file comment records what the + * repo does instead in each case and what consequently stops being caught. + */ +export function serve( + server: string, + client: SamplingClient, + bridge: Bridge, + livenessIntervalMs: number = DEFAULT_LIVENESS_INTERVAL, + chunkTimeoutMs?: number, +) { + client.setRequestHandler(CreateMessageRequestSchema, async (request, extra) => { + const params = (request.params ?? {}) as CreateMessageParams + // KEEPALIVE WIRING. Both halves come from the SDK and neither is ours to + // fabricate: the token off the request's `_meta`, the sender off `extra`. If + // either is missing the server did not ask for progress and `liveness` stays + // undefined, which makes `handle` emit nothing. + const progressToken = progressTokenOf(extra) + const send = extra?.sendNotification + const liveness: Liveness | undefined = + progressToken !== undefined && typeof send === "function" + ? { progressToken, send: send as Liveness["send"], intervalMs: livenessIntervalMs } + : undefined + const effect = handle({ + server, + params, + sessionID: activeSessions.get(client), + signal: extra?.signal, + chunkTimeoutMs, + liveness, + }).pipe(Effect.exit) + + let fibers = inFlight.get(client) + if (!fibers) { + fibers = new Set() + inFlight.set(client, fibers) + } + const fiber = bridge.fork(effect) + fibers.add(fiber as Fiber.Fiber) + try { + const exit = await Effect.runPromise(Fiber.join(fiber)) + if (Exit.isSuccess(exit)) return exit.value as never + // `handle` puts SamplingError on the FAILURE channel, so squash returns the + // instance itself and `instanceof` survives. A cancelled fiber and a + // genuine defect both land here and become explicit errors. + const failure = Cause.squash(exit.cause) + if (failure instanceof SamplingError) throw failure.toMcpError() + if (Cause.hasInterrupts(exit.cause)) { + log.info("sampling cancelled", { server, status: "cancelled" }) + throw new McpError(TIMEOUT_CODE, "sampling was cancelled", { server }) + } + log.error("sampling failed", { server, status: "error" }) + throw new McpError(ErrorCode.InternalError, "sampling failed") + } finally { + fibers.delete(fiber as Fiber.Fiber) + } + }) +} + +export * as McpSampling from "./sampling" diff --git a/packages/opencode/src/memory/write-gate.ts b/packages/opencode/src/memory/write-gate.ts new file mode 100644 index 000000000..0c2c2e3eb --- /dev/null +++ b/packages/opencode/src/memory/write-gate.ts @@ -0,0 +1,33 @@ +/** + * Single read point for the memory write switch. + * + * Config field: `memory.disable_write` (negative). This accessor is the ONLY + * place that double negative is allowed to exist — it exposes a positive + * predicate so every gate reads as `if (!isMemoryWriteEnabled(cfg)) ...`. + * Business code must never touch `disable_write` directly: field name, polarity, + * and default all live in this one function body. + * + * The parameter is structural rather than `Config.Info` so the same accessor + * serves callers holding a generated-SDK config object (the plugin hook reads + * config over the plugin client, whose type lags the engine schema). + */ +export type MemoryWriteConfig = { + memory?: { + disable_write?: boolean + } +} + +/** + * Whether NEW memory may be written. Reading is never affected by this switch. + * + * Default ENABLED — an absent config, an absent `memory` section, an absent + * field, and an explicit `false` all mean writes proceed, so upgrading without + * touching config keeps today's behavior. + * + * `!== true` rather than `?? false`: only a literal `true` disables, so a + * malformed non-boolean value degrades to "writes enabled" instead of silently + * killing memory writes. + */ +export function isMemoryWriteEnabled(cfg: MemoryWriteConfig | undefined): boolean { + return cfg?.memory?.disable_write !== true +} diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 8534e2b1d..efc66505c 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -280,6 +280,22 @@ export const layer = Layer.effect( // hold isn't matched → we do NOT return here → it fails closed at the // non-interactive gate. No human wait, no hang. if (needsAsk && input.inherit && !forced) { + // An EXPLICIT `session grant-approval ` pre-authorizes this + // child. That grant is DB-backed (write-through in forwardRef.setGrant), + // so unlike the in-memory parentGrants snapshot it survives a restart and + // is visible to a child running in its own Instance/process. Checked here + // because `decideAskRouting` routes an ordinary background subagent to + // `inherit`, never to `forward` — the only other place grantAllowed is + // consulted — so without this the documented command silently does + // nothing for subagents and their asks fail closed below. + if (forwardRef.grantAllowed(input.inherit.parentSessionID, request.sessionID)) { + log.info("parent holds an explicit approval grant, auto-allowing", { + permission: request.permission, + patterns: request.patterns, + parentSessionID: input.inherit.parentSessionID, + }) + return + } const parentSnapshot = forwardRef.getParentGrants(input.inherit.parentSessionID) if (parentSnapshot) { // Mirror the parent's own two-phase evaluation (see the deny loop @@ -378,14 +394,27 @@ export const layer = Layer.effect( // Spec ③ P3: race against caller's abortSignal so a stranded ask // doesn't block forever when the surrounding scope is interrupted. // NOTE: Effect.callback (not Effect.promise) — when Deferred.await - // wins the race, Effect.race interrupts the callback and runs the + // wins the race, the race interrupts the callback and runs the // cleanup returned from the body, which removes the addEventListener. // Effect.promise has no such hook: listener leaks for the lifetime // of the AbortSignal + unhandled-rejection when the eventual abort // tries to reject the already-dead Promise. + // + // raceFirst, NOT race. `Effect.race` resolves with the first + // *success* and treats a failure as "not a winner", so it keeps + // waiting on the loser; `Effect.raceFirst` resolves with the first + // side to *complete*, success or failure. A human rejection FAILS + // this Deferred, so under `race` the ask parked forever whenever an + // abortSignal was passed — the abort side never settles on its own + // and there is nothing left to win. Measured on effect@4.0.0-beta.48: + // race(failed Deferred, never) never settles; raceFirst yields the + // RejectedError. Interruption still composes: an interrupt of this + // fiber exits with a cause for which Cause.hasInterrupts is true + // rather than being flattened into a plain failure, which is why + // this is not done by wrapping a side in Effect.exit. const deferredAwait = Deferred.await(deferred) const main = abortSignal - ? Effect.race( + ? Effect.raceFirst( deferredAwait, Effect.callback((resume) => { const onAbort = () => { @@ -407,8 +436,12 @@ export const layer = Layer.effect( // A forwarded ask that no approver resolves must still terminate (deny), // never hang. Race the bounded timeout; the grant path above already // resolved the Deferred, so it wins instantly when pre-authorized. + // raceFirst for the same reason as above: under `race` a forwarded ask + // that the approver DENIED failed the Deferred, which counted as no + // winner, so the caller waited out the whole FORWARD_DENY_TIMEOUT_MS + // before seeing the rejection it already had. let guarded = input.forward - ? Effect.race( + ? Effect.raceFirst( main, Effect.sleep(`${FORWARD_DENY_TIMEOUT_MS} millis`).pipe( Effect.andThen(() => Deferred.fail(deferred, new RejectedError())), @@ -422,8 +455,10 @@ export const layer = Layer.effect( // Bound it with a timeout; CorrectedError (not RejectedError) so the // processor does NOT set ctx.blocked — the model sees an error result with // actionable feedback and the session loop continues to the next step. - // NOTE: Effect.race with a permanently-blocked Deferred hangs under the - // current Effect v4 beta, so use Effect.timeoutOrElse instead. + // NOTE: keep Effect.timeoutOrElse here rather than racing a failing + // sleep. The reason is the same "a failure is not a winner" rule that + // forced raceFirst above: a timeout side that FAILS never wins an + // Effect.race, so the race would sit on the still-blocked Deferred. if (s.skipAll && forced) { const timeoutMs = skipAllForcedAskTimeoutMs() guarded = Effect.timeoutOrElse(guarded, { diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 9a4713633..4a599e211 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -128,12 +128,8 @@ export interface Interface { readonly list: () => Effect.Effect readonly init: () => Effect.Effect readonly reloadFileHooks: () => Effect.Effect - readonly triggerActorPreStop: ( - input: ActorPreStopInput, - ) => Effect.Effect - readonly triggerActorPostStop: ( - input: ActorPostStopInput, - ) => Effect.Effect + readonly triggerActorPreStop: (input: ActorPreStopInput) => Effect.Effect + readonly triggerActorPostStop: (input: ActorPostStopInput) => Effect.Effect } export class Service extends Context.Service()("@opencode/Plugin") {} @@ -175,19 +171,14 @@ function getLegacyPlugins(mod: Record) { if (seen.has(entry)) continue seen.add(entry) const plugin = getServerPlugin(entry) - if (!plugin) throw new TypeError("Plugin export is not a function") + if (!plugin) continue result.push(plugin) } return result } -async function applyPlugin( - load: PluginLoader.Loaded, - input: PluginInput, - hooks: Hooks[], - hooksWithMeta: HookEntry[], -) { +async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: Hooks[], hooksWithMeta: HookEntry[]) { const plugin = readV1Plugin(load.mod, load.spec, "server", "detect") if (plugin) { await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec), load.pkg) @@ -204,9 +195,7 @@ async function applyPlugin( for (const server of getLegacyPlugins(load.mod)) { const fnName = (server as { name?: string }).name - const pluginName = fnName && fnName !== "default" && fnName !== "" - ? fnName - : (load.pkg?.pkg ?? load.spec) + const pluginName = fnName && fnName !== "default" && fnName !== "" ? fnName : (load.pkg?.pkg ?? load.spec) const hookObj = await server(input, load.options) hooks.push(hookObj) hooksWithMeta.push({ @@ -314,7 +303,7 @@ export const layer = Layer.effect( ([exportName, v]) => typeof v === "function" && exportName.endsWith("Plugin"), )?.[1] as PluginInstance | undefined if (!overlay) continue - log.info("loading extension", { name }) + // log.info("loading extension", { name }) const init = yield* Effect.tryPromise({ try: () => overlay(input), catch: (err) => log.error("failed to load extension", { name, error: err }), @@ -460,22 +449,28 @@ export const layer = Layer.effect( const tmpFile = `${match}.${Date.now()}.mjs` await Bun.write(tmpFile, blob) try { - return await import(tmpFile) as Record + return (await import(tmpFile)) as Record } finally { fs.promises.unlink(tmpFile).catch(() => {}) } }, catch: (err) => err, - }).pipe(Effect.catch((err) => { - log.error("failed to load file hook", { path: match, error: errorMessage(err) }) - return Effect.succeed(undefined) - })) + }).pipe( + Effect.catch((err) => { + log.error("failed to load file hook", { path: match, error: errorMessage(err) }) + return Effect.succeed(undefined) + }), + ) if (!mod) continue const hookObj: Hooks = (mod.default ?? mod) as Hooks if (hookObj && typeof hookObj === "object") { const name = path.basename(match, path.extname(match)) hooks.push(hookObj) - meta.push({ hook: hookObj, pluginName: `file:${name}`, hookIDFor: (event: string) => `file:${name}#${event}` }) + meta.push({ + hook: hookObj, + pluginName: `file:${name}`, + hookIDFor: (event: string) => `file:${name}#${event}`, + }) log.info("loaded file hook", { path: match, name }) } } @@ -560,8 +555,7 @@ export const layer = Layer.effect( if (!reg) continue const fn = typeof reg === "function" ? reg : reg.run - const matcher: ActorMatcher | undefined = - typeof reg === "function" ? undefined : reg.matcher + const matcher: ActorMatcher | undefined = typeof reg === "function" ? undefined : reg.matcher if (!matchesActor(matcher, input)) { yield* bus.publish(HookEvent.Executed, { @@ -594,7 +588,11 @@ export const layer = Layer.effect( Effect.tapError((err) => Effect.gen(function* () { hookOutcome = "error" - log.error(`${eventName} hook failed`, { pluginName: entry.pluginName, hookID: entry.hookIDFor(eventName), error: err }) + log.error(`${eventName} hook failed`, { + pluginName: entry.pluginName, + hookID: entry.hookIDFor(eventName), + error: err, + }) yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID as SessionID, error: new NamedError.Unknown({ @@ -640,15 +638,11 @@ export const layer = Layer.effect( return aggregated }) - const triggerActorPreStop = Effect.fn("Plugin.triggerActorPreStop")(function* ( - input: ActorPreStopInput, - ) { + const triggerActorPreStop = Effect.fn("Plugin.triggerActorPreStop")(function* (input: ActorPreStopInput) { return yield* aggregateDecision(input, "actor.preStop") }) - const triggerActorPostStop = Effect.fn("Plugin.triggerActorPostStop")(function* ( - input: ActorPostStopInput, - ) { + const triggerActorPostStop = Effect.fn("Plugin.triggerActorPostStop")(function* (input: ActorPostStopInput) { return yield* aggregateDecision(input, "actor.postStop") }) diff --git a/packages/opencode/src/plugin/subagent-progress-checker.ts b/packages/opencode/src/plugin/subagent-progress-checker.ts index 6a5a11c67..302f5c9c4 100644 --- a/packages/opencode/src/plugin/subagent-progress-checker.ts +++ b/packages/opencode/src/plugin/subagent-progress-checker.ts @@ -3,6 +3,7 @@ import fs from "fs/promises" import path from "path" import { Log } from "../util" import { progressPath } from "../session/checkpoint-paths" +import { isMemoryWriteEnabled, type MemoryWriteConfig } from "../memory/write-gate" import type { SessionID } from "../session/schema" const log = Log.create({ service: "plugin.subagent-progress-checker" }) @@ -78,7 +79,27 @@ async function injectFrontmatter(filePath: string, body: string): Promise await Bun.write(filePath, newBody) } -export async function SubagentProgressCheckerPlugin(_pluginInput: PluginInput): Promise { +/** + * Whether new memory may be written. Delegates the field read to the shared + * accessor (memory/write-gate.ts) so this hook can't drift from the write gate. + * + * Config comes over the plugin client, not Config.Service: this hook body is a + * plain async function with no Effect context, and importing AppRuntime here + * would close an import cycle (app-runtime → Plugin.defaultLayer → + * plugin/index → this file). The client carries the instance directory, so it + * resolves the same config the write gate sees. + * + * Fails OPEN: a config read that errors must never silently disable the journal + * check. + */ +async function memoryWriteEnabled(client: PluginInput["client"]): Promise { + const res = await client.config.get().catch(() => undefined) + // Cast is structural: the generated SDK config type lags the engine schema + // until the next SDK regen, so the field isn't on it yet. + return isMemoryWriteEnabled(res?.data as MemoryWriteConfig | undefined) +} + +export async function SubagentProgressCheckerPlugin(pluginInput: PluginInput): Promise { return { "actor.postStop": { // Use excludeOnly so the matcher fires for ALL actor types EXCEPT those @@ -110,6 +131,13 @@ export async function SubagentProgressCheckerPlugin(_pluginInput: PluginInput): // `=== false` (not falsy): an absent canWrite must NOT suppress (fail-open). if ((input as { canWrite?: boolean }).canWrite === false) return + // Memory writing off — the write gate (memory-path-guard) hard-rejects + // progress.md, so asking the subagent to write it would spin the postStop + // ReAct loop forever: nudge → write rejected → nudge again, burning a model + // turn per iteration. Bail before the first nudge. Checked after the two + // sync fast-outs above so non-task-bound subagents don't pay a config read. + if (!(await memoryWriteEnabled(pluginInput.client))) return + const sessionID = input.sessionID as SessionID const filePath = progressPath(sessionID, taskId) diff --git a/packages/opencode/src/provider/capability-registry.ts b/packages/opencode/src/provider/capability-registry.ts new file mode 100644 index 000000000..448ad8c2c --- /dev/null +++ b/packages/opencode/src/provider/capability-registry.ts @@ -0,0 +1,336 @@ +import type { Provider } from "@/provider" + +/** + * Model Capability Registry. + * + * MCP itself publishes no model list and no modality discovery, so a + * `sampling/createMessage` request cannot be routed by guessing. This registry + * is the single place that answers "can THIS model, through THIS adapter, + * actually accept THIS content?". + * + * Two independent gates are ANDed: + * + * 1. The MODEL gate — `model.capabilities.input.{text,image,audio}`, sourced + * from models.dev metadata or the user's own `/modalities` config. + * 2. The ADAPTER gate — whether the ai-sdk package behind the model can + * actually serialize that media. A model may accept audio while the adapter + * wired up for it cannot carry audio bytes; sending anyway would silently + * degrade the request. + * + * The adapter gate is deliberately TRI-STATE. "unsupported" is a claim we can + * substantiate; "unknown" means we have no evidence either way and refuse to + * invent one. Both are ineligible (fail closed) but they are reported + * distinctly so an operator can tell "this cannot work" from "we do not know". + */ + +export type Modality = "text" | "image" | "audio" + +export type Support = "supported" | "unsupported" | "unknown" + +/** What a single adapter accepts for a single modality. */ +export interface ModalityDeclaration { + readonly support: Support + /** Accepted MIME types. `"any"` means every MIME within the modality prefix. */ + readonly mimeTypes: ReadonlyArray | "any" + /** Per-item cap on DECODED bytes. */ + readonly maxBytes: number +} + +export interface AdapterDeclaration { + readonly text: ModalityDeclaration + readonly image: ModalityDeclaration + readonly audio: ModalityDeclaration + /** Why this declaration reads the way it does. Surfaced in errors and docs. */ + readonly evidence: string +} + +/** + * Client-side safety cap on inline media, NOT a claim about any provider's real + * limit (no provider we wire up documents one we can read from metadata). It + * exists so a hostile or buggy MCP server cannot push an unbounded payload + * through the sampling path. 20 MiB comfortably clears the target use case: 30s + * of 16 kHz mono 16-bit PCM WAV is ~0.92 MiB. + */ +export const DEFAULT_MAX_MEDIA_BYTES = 20 * 1024 * 1024 + +/** Text is capped far lower — a prompt, not a payload. */ +export const DEFAULT_MAX_TEXT_BYTES = 1 * 1024 * 1024 + +const SAFE_IMAGE_MIMES = ["image/jpeg", "image/png", "image/gif", "image/webp"] +// Mirrors OPENAI_AUDIO_MIMES in src/session/tool-attachment.ts — the set the +// OpenAI-compatible chat adapter can serialize as input_audio. +const OPENAI_AUDIO_MIMES = ["audio/wav", "audio/mp3", "audio/mpeg"] + +const TEXT_SUPPORTED: ModalityDeclaration = { + support: "supported", + mimeTypes: "any", + maxBytes: DEFAULT_MAX_TEXT_BYTES, +} + +const IMAGE_SUPPORTED: ModalityDeclaration = { + support: "supported", + mimeTypes: SAFE_IMAGE_MIMES, + maxBytes: DEFAULT_MAX_MEDIA_BYTES, +} + +function absent(mimeTypes: ReadonlyArray | "any" = []): ModalityDeclaration { + return { support: "unsupported", mimeTypes, maxBytes: 0 } +} + +function unknown(): ModalityDeclaration { + return { support: "unknown", mimeTypes: [], maxBytes: 0 } +} + +/** + * Per-adapter declarations, keyed by the ai-sdk npm package on `model.api.npm`. + * + * Every audio verdict below was verified by driving the INSTALLED adapter with an + * `audio/*` file part and observing the serialized request body (or the thrown + * `functionality not supported` error). Those observations are locked in by + * test/provider/capability-registry-wire.test.ts, which fails if an adapter + * upgrade changes the behaviour this table asserts. The verdicts also agree with + * the routing logic this repo already ships in `src/session/tool-attachment.ts`. + */ +const ADAPTERS: Record = { + "@ai-sdk/openai-compatible": { + text: TEXT_SUPPORTED, + image: IMAGE_SUPPORTED, + audio: { support: "supported", mimeTypes: OPENAI_AUDIO_MIMES, maxBytes: DEFAULT_MAX_MEDIA_BYTES }, + // Observed: wav/mp3/mpeg serialize to `input_audio`; flac and ogg throw + // "'audio media type ...' functionality not supported". + evidence: "@ai-sdk/openai-compatible@3 serializes wav/mp3/mpeg as input_audio and rejects other audio", + }, + "@ai-sdk/google": { + text: TEXT_SUPPORTED, + image: IMAGE_SUPPORTED, + audio: { support: "supported", mimeTypes: "any", maxBytes: DEFAULT_MAX_MEDIA_BYTES }, + // Observed: any audio/* passes through as `inlineData` with its MIME intact. + evidence: "@ai-sdk/google passes any audio/* through as inlineData", + }, + "@ai-sdk/google-vertex": { + text: TEXT_SUPPORTED, + image: IMAGE_SUPPORTED, + audio: { support: "supported", mimeTypes: "any", maxBytes: DEFAULT_MAX_MEDIA_BYTES }, + evidence: "@ai-sdk/google-vertex shares the @ai-sdk/google content conversion", + }, + "@ai-sdk/anthropic": { + text: TEXT_SUPPORTED, + image: IMAGE_SUPPORTED, + audio: absent(), + // Observed: an audio/wav part throws "'media type: audio/wav' functionality + // not supported" while image/png serializes fine. Known-absent, not unproven. + evidence: "@ai-sdk/anthropic throws 'media type: audio/wav' functionality not supported", + }, + "@ai-sdk/google-vertex/anthropic": { + text: TEXT_SUPPORTED, + image: IMAGE_SUPPORTED, + audio: absent(), + evidence: "@ai-sdk/google-vertex/anthropic shares the @ai-sdk/anthropic content conversion", + }, + "@ai-sdk/amazon-bedrock": { + text: TEXT_SUPPORTED, + image: IMAGE_SUPPORTED, + audio: absent(), + evidence: "tool-attachment.ts:49-53,69-71 exclude @ai-sdk/amazon-bedrock from every audio route", // no direct wire probe; routing logic is the evidence + }, +} + +/** + * Adapters with no entry above. Text and image are still declared supported + * because every ai-sdk language model carries text, and image parts are a + * baseline `LanguageModelV3` file part that adapters reject loudly rather than + * silently mangle. Audio is `unknown`: we have no evidence, so we say so. + */ +const UNDECLARED: AdapterDeclaration = { + text: TEXT_SUPPORTED, + image: IMAGE_SUPPORTED, + audio: unknown(), + evidence: "no capability declaration for this adapter; audio support is unproven, not disproven", +} + +export function adapterDeclaration(npm: string | undefined): AdapterDeclaration { + if (!npm) return UNDECLARED + return ADAPTERS[npm] ?? UNDECLARED +} + +/** Adapter packages carrying an explicit declaration. Exported for docs/tests. */ +export function declaredAdapters(): ReadonlyArray { + return Object.keys(ADAPTERS) +} + +function modelGate(model: Provider.Model, modality: Modality): boolean { + if (modality === "text") return model.capabilities.input.text + if (modality === "image") return model.capabilities.input.image + return model.capabilities.input.audio +} + +/** + * The effective declaration for a model: the adapter declaration narrowed by + * the model's own declared input modalities. A model that does not declare a + * modality is `unsupported` for it regardless of what its adapter could carry. + */ +export function modelDeclaration(model: Provider.Model, modality: Modality): ModalityDeclaration { + const adapter = adapterDeclaration(model.api.npm)[modality] + if (!modelGate(model, modality)) { + return { support: "unsupported", mimeTypes: [], maxBytes: 0 } + } + return adapter +} + +/** One piece of content a sampling request wants to send. */ +export interface ContentRequirement { + readonly modality: Modality + readonly mimeType?: string + /** Decoded size in bytes. */ + readonly bytes: number +} + +export type RejectionReason = + | { readonly kind: "modality-unsupported"; readonly modality: Modality } + | { readonly kind: "modality-unknown"; readonly modality: Modality } + | { readonly kind: "mime-unsupported"; readonly modality: Modality; readonly mimeType: string } + | { + readonly kind: "too-large" + readonly modality: Modality + readonly bytes: number + readonly maxBytes: number + } + +export interface Rejection { + readonly model: string + readonly reason: RejectionReason +} + +export function describeRejection(reason: RejectionReason): string { + if (reason.kind === "modality-unsupported") return `does not accept ${reason.modality} input` + if (reason.kind === "modality-unknown") return `has no declared ${reason.modality} support` + if (reason.kind === "mime-unsupported") return `does not accept ${reason.mimeType}` + return `content is ${reason.bytes} bytes, over the ${reason.maxBytes} byte limit for ${reason.modality}` +} + +export function modelRef(model: Provider.Model): string { + return `${model.providerID}/${model.id}` +} + +/** + * Check one model against every content requirement. Returns the first reason + * the model cannot serve the request, or `undefined` when it can. + */ +export function rejectionFor( + model: Provider.Model, + requirements: ReadonlyArray, +): RejectionReason | undefined { + for (const requirement of requirements) { + const declaration = modelDeclaration(model, requirement.modality) + if (declaration.support === "unknown") { + return { kind: "modality-unknown", modality: requirement.modality } + } + if (declaration.support === "unsupported") { + return { kind: "modality-unsupported", modality: requirement.modality } + } + if (requirement.mimeType && declaration.mimeTypes !== "any") { + const mime = requirement.mimeType.toLowerCase() + if (!declaration.mimeTypes.some((item) => item.toLowerCase() === mime)) { + return { kind: "mime-unsupported", modality: requirement.modality, mimeType: requirement.mimeType } + } + } + if (requirement.bytes > declaration.maxBytes) { + return { + kind: "too-large", + modality: requirement.modality, + bytes: requirement.bytes, + maxBytes: declaration.maxBytes, + } + } + } + return undefined +} + +export interface ModelHint { + readonly name?: string +} + +export interface SelectionInput { + /** Every model the user has actually configured credentials for. */ + readonly models: ReadonlyArray + readonly requirements: ReadonlyArray + /** Advisory, in server-preference order. Ranks eligible models; never widens. */ + readonly hints?: ReadonlyArray + /** Existing model-selection strategy's answer, used when no hint matches. */ + readonly fallback?: Provider.Model +} + +export type SelectionResult = + | { + readonly ok: true + readonly model: Provider.Model + /** How the winner was chosen. */ + readonly via: "hint" | "fallback" | "first-eligible" + /** The hint that matched, when `via` is "hint". */ + readonly hint?: string + } + | { + readonly ok: false + /** Every configured model and why it was rejected. */ + readonly rejections: ReadonlyArray + readonly requirements: ReadonlyArray + } + +function hintMatches(model: Provider.Model, hint: string): boolean { + const needle = hint.toLowerCase() + const id = model.id.toLowerCase() + const ref = modelRef(model).toLowerCase() + const name = model.name.toLowerCase() + if (id === needle || ref === needle || name === needle) return true + // The spec treats a hint name as a substring that MAY match loosely. + return id.includes(needle) || ref.includes(needle) || name.includes(needle) +} + +function exactHintMatch(model: Provider.Model, hint: string): boolean { + const needle = hint.toLowerCase() + return model.id.toLowerCase() === needle || modelRef(model).toLowerCase() === needle +} + +function stableOrder(models: ReadonlyArray): Provider.Model[] { + return [...models].sort((a, b) => modelRef(a).localeCompare(modelRef(b))) +} + +/** + * FILTER THEN RANK. Eligibility is decided purely by capability + configured + * credentials; only the surviving set is then ordered by the server's hints. + * A hint can never make an ineligible model eligible. + */ +export function selectModel(input: SelectionInput): SelectionResult { + const eligible: Provider.Model[] = [] + const rejections: Rejection[] = [] + + for (const model of stableOrder(input.models)) { + const reason = rejectionFor(model, input.requirements) + if (reason) rejections.push({ model: modelRef(model), reason }) + else eligible.push(model) + } + + if (eligible.length === 0) { + return { ok: false, rejections, requirements: input.requirements } + } + + for (const hint of input.hints ?? []) { + const name = hint.name + if (!name) continue + const exact = eligible.find((model) => exactHintMatch(model, name)) + if (exact) return { ok: true, model: exact, via: "hint", hint: name } + const loose = eligible.find((model) => hintMatches(model, name)) + if (loose) return { ok: true, model: loose, via: "hint", hint: name } + } + + // No hint landed on an eligible model: defer to the existing selection + // strategy when its answer is itself eligible, else take the first eligible + // model in a deterministic order. + const fallback = input.fallback + if (fallback && eligible.some((model) => modelRef(model) === modelRef(fallback))) { + return { ok: true, model: fallback, via: "fallback" } + } + return { ok: true, model: eligible[0], via: "first-eligible" } +} + +export * as ModelCapability from "./capability-registry" diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index 49dec3f51..7318d8022 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -134,7 +134,7 @@ export type ParsedStreamError = | { type: "api_error" message: string - isRetryable: false + isRetryable: boolean responseBody: string } @@ -146,6 +146,13 @@ export function parseStreamError(input: unknown): ParsedStreamError | undefined if (body.type !== "error") return switch (body?.error?.code) { + case "server_error": + return { + type: "api_error", + message: typeof body?.error?.message === "string" ? body.error.message : "OpenAI server error", + isRetryable: true, + responseBody, + } case "context_length_exceeded": return { type: "context_overflow", diff --git a/packages/opencode/src/provider/index.ts b/packages/opencode/src/provider/index.ts index 9e8891144..2864e33e6 100644 --- a/packages/opencode/src/provider/index.ts +++ b/packages/opencode/src/provider/index.ts @@ -3,3 +3,4 @@ export * as ProviderAuth from "./auth" export * as ProviderError from "./error" export * as ModelsDev from "./models" export * as ProviderTransform from "./transform" +export * as ModelCapability from "./capability-registry" diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index b78cff0f5..0ec0d8a55 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -94,25 +94,140 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) { }, }) - return new Response(body, { + return wrapResponse(res, body) +} + +function wrapResponse(res: Response, body: ReadableStream) { + const wrapped = new Response(body, { headers: new Headers(res.headers), status: res.status, statusText: res.statusText, }) + Object.defineProperties(wrapped, { + redirected: { get: () => res.redirected }, + type: { get: () => res.type }, + url: { get: () => res.url }, + }) + return wrapped } -function timeoutController(ms: number) { +function timeoutController(ms: number, message = `Response header timed out after ${ms}ms`) { const ctl = new AbortController() - const id = setTimeout( - () => ctl.abort(Object.assign(new Error(`Response header timed out after ${ms}ms`), { code: "ETIMEDOUT" })), - ms, - ) + const id = setTimeout(() => ctl.abort(Object.assign(new Error(message), { code: "ETIMEDOUT" })), ms) return { signal: ctl.signal, clear: () => clearTimeout(id), } } +type AbortSource = "request" | "timeout" + +export function trackAbortSource(requestSignal: AbortSignal | null | undefined, timeoutSignals: AbortSignal[]) { + const signals = [requestSignal, ...timeoutSignals].filter((signal) => signal !== null && signal !== undefined) + let source: AbortSource | undefined = requestSignal?.aborted + ? "request" + : timeoutSignals.some((signal) => signal.aborted) + ? "timeout" + : undefined + let winner = requestSignal?.aborted ? requestSignal : timeoutSignals.find((signal) => signal.aborted) + const listeners = signals.map((signal, index) => { + const listener = () => { + if (source) return + source = index === 0 && requestSignal ? "request" : "timeout" + winner = signal + } + signal.addEventListener("abort", listener, { once: true }) + if (signal.aborted) listener() + return { signal, listener } + }) + return { + signal: signals.length === 0 ? undefined : signals.length === 1 ? signals[0] : AbortSignal.any(signals), + source: () => source, + winner: () => winner, + dispose: () => listeners.forEach((item) => item.signal.removeEventListener("abort", item.listener)), + } +} + +export function normalizeTimeoutError(error: unknown, source: AbortSource | undefined, signal?: AbortSignal) { + if (source !== "timeout") return error + const reason = signal?.reason + return Object.assign(new Error(reason instanceof Error ? reason.message : "Request timed out"), { + code: "ETIMEDOUT", + cause: error, + }) +} + +export function requestSignal(input: RequestInfo | URL, init?: RequestInit) { + return init?.signal ?? (input instanceof Request ? input.signal : undefined) +} + +export function wrapRequestTimeout( + res: Response, + requestSignal: AbortSignal | null | undefined, + timeoutSignal: AbortSignal, + clear: () => void, +) { + const tracked = trackAbortSource(requestSignal, [timeoutSignal]) + let finalized = false + const finalize = () => { + if (finalized) return + finalized = true + clear() + tracked.dispose() + } + if (!res.body) { + finalize() + return res + } + + const reader = res.body.getReader() + return wrapResponse( + res, + new ReadableStream({ + async pull(ctrl) { + const part = await new Promise>>((resolve, reject) => { + const onAbort = () => { + const error = normalizeTimeoutError( + tracked.signal?.reason ?? new DOMException("The operation was aborted", "AbortError"), + tracked.source(), + tracked.winner(), + ) + cleanup() + void reader.cancel(error).catch(() => {}) + reject(error) + } + const cleanup = () => tracked.signal?.removeEventListener("abort", onAbort) + if (tracked.signal?.aborted) return onAbort() + tracked.signal?.addEventListener("abort", onAbort, { once: true }) + reader.read().then( + (part) => { + cleanup() + resolve(part) + }, + (error) => { + cleanup() + reject(normalizeTimeoutError(error, tracked.source(), tracked.winner())) + }, + ) + }).catch((error) => { + finalize() + throw error + }) + if (part.done) { + finalize() + ctrl.close() + return + } + ctrl.enqueue(part.value) + }, + async cancel(reason) { + finalize() + await reader.cancel(reason) + }, + }), + ) +} + type BundledSDK = { languageModel(modelId: string): LanguageModelV3 } @@ -1386,7 +1501,7 @@ const layer: Layer.Layer< const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie) provider.models = yield* Effect.promise(async () => { - const next = await models(provider as any, { auth: pluginAuth }) + const next = await models(provider, { auth: pluginAuth }) return Object.fromEntries( Object.entries(next).map(([id, model]) => [ id, @@ -1532,28 +1647,44 @@ const layer: Layer.Layer< options["fetch"] = async (input: any, init?: BunFetchRequestInit) => { const fetchFn = customFetch ?? fetch const opts = init ?? {} + const callerSignal = requestSignal(input, opts) const chunkAbortCtl = typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined const headerTimeoutMs = headerTimeout === false ? undefined : headerTimeout const headerTimeoutCtl = typeof headerTimeoutMs === "number" ? timeoutController(headerTimeoutMs) : undefined - const signals: AbortSignal[] = [] - - if (opts.signal) signals.push(opts.signal) - if (chunkAbortCtl) signals.push(chunkAbortCtl.signal) - if (headerTimeoutCtl) signals.push(headerTimeoutCtl.signal) - if (options["timeout"] !== undefined && options["timeout"] !== null && options["timeout"] !== false) - signals.push(AbortSignal.timeout(options["timeout"])) - - const combined = signals.length === 0 ? null : signals.length === 1 ? signals[0] : AbortSignal.any(signals) - if (combined) opts.signal = combined - - const res = await fetchFn(input, { - ...opts, - // @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682 - timeout: false, - }).finally(() => headerTimeoutCtl?.clear()) + const requestTimeoutCtl = + typeof options["timeout"] === "number" && options["timeout"] > 0 + ? timeoutController(options["timeout"], `Request timed out after ${options["timeout"]}ms`) + : undefined + const tracked = trackAbortSource( + callerSignal, + [headerTimeoutCtl?.signal, requestTimeoutCtl?.signal].filter((signal) => signal !== undefined), + ) + const signals = [tracked.signal, chunkAbortCtl?.signal].filter((signal) => signal !== undefined) + if (signals.length > 0) opts.signal = signals.length === 1 ? signals[0] : AbortSignal.any(signals) + + const res = await Promise.resolve() + .then(() => + fetchFn(input, { + ...opts, + // @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682 + timeout: false, + }), + ) + .catch((error: unknown) => { + requestTimeoutCtl?.clear() + tracked.dispose() + throw normalizeTimeoutError(error, tracked.source(), tracked.winner()) + }) + .finally(() => { + headerTimeoutCtl?.clear() + }) - if (!chunkAbortCtl) return res - return wrapSSE(res, chunkTimeout, chunkAbortCtl) + tracked.dispose() + const bounded = requestTimeoutCtl + ? wrapRequestTimeout(res, callerSignal, requestTimeoutCtl.signal, requestTimeoutCtl.clear) + : res + if (!chunkAbortCtl) return bounded + return wrapSSE(bounded, chunkTimeout, chunkAbortCtl) } const bundledLoader = BUNDLED_PROVIDERS[model.api.npm] diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 19b0ec80e..26b39bcb0 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -47,6 +47,28 @@ function sdkKey(npm: string): string | undefined { return undefined } +// Providers that hard-reject an empty text/reasoning BLOCK inside an otherwise +// non-empty message ("text content blocks must be non-empty"). The AI SDK's own +// filter does not save us here: its user branch drops empty text parts, but its +// assistant branch KEEPS an empty text part that carries providerOptions, and it +// never inspects `reasoning` parts at all — so an empty reasoning block reaches +// the provider untouched for every npm package. +// +// The original list was `@ai-sdk/anthropic` + `@ai-sdk/amazon-bedrock` only, +// which missed the two other ways to reach the same Anthropic API: +// `@ai-sdk/google-vertex/anthropic` and Claude via `@openrouter/ai-sdk-provider`. +// Stripping an empty block is information-preserving for any provider, so the +// list errs on the side of including a provider rather than excluding one. +function stripsEmptyParts(model: Provider.Model): boolean { + return [ + "@ai-sdk/anthropic", + "@ai-sdk/amazon-bedrock", + "@ai-sdk/google-vertex/anthropic", + "@openrouter/ai-sdk-provider", + "@ai-sdk/openai-compatible", + ].includes(model.api.npm) +} + function normalizeMessages( msgs: ModelMessage[], model: Provider.Model, @@ -54,7 +76,7 @@ function normalizeMessages( ): ModelMessage[] { // Anthropic rejects messages with empty content - filter out empty string messages // and remove empty text/reasoning parts from array content - if (model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/amazon-bedrock") { + if (stripsEmptyParts(model)) { msgs = msgs .map((msg) => { if (typeof msg.content === "string") { @@ -270,6 +292,92 @@ function supportsCacheMarkers(model: Provider.Model): boolean { // not an assistant prefill. const CONTINUATION_PROMPT = "Continue." +// Backfill text for a message whose content is structurally present but carries +// nothing a provider will accept. Same string as the continuation prompt: both +// mean "there is no new instruction here, keep going". +const EMPTY_CONTENT_PLACEHOLDER = CONTINUATION_PROMPT + +// Mirrors the AI SDK's OWN user-content filter. `ai@6` builds the wire payload in +// `convertToLanguageModelMessage`, whose user branch is: +// +// content: message.content.map((part) => convertPartToLanguageModelPart(part, ...)) +// .filter((part) => part.type !== "text" || part.text !== "") +// +// It runs AFTER every transform here and does NOT backfill, so a user message +// whose only text part is "" reaches the provider as `content: []` — exactly the +// shape observed in the live Bedrock 400 payload. Note the asymmetry: the SDK's +// assistant branch keeps an empty text part when it carries providerOptions +// (`|| part.providerOptions != null`); the user branch has no such escape, so +// even an empty text part holding a cache_control marker is stripped. +// +// Consequence: emptiness of a user message CANNOT be judged by `content.length` +// — at this layer the offending message is a length-1 array that looks fine. It +// must be judged by what SURVIVES this filter. +function sdkVisibleUserParts(content: readonly any[]): readonly any[] { + return content.filter((part) => !part || part.type !== "text" || part.text !== "") +} + +// The SDK's ASSISTANT branch uses a slightly looser predicate — an empty text +// part survives when it carries providerOptions: +// .filter((part) => part.type !== "text" || part.text !== "" || part.providerOptions != null) +// so assistant emptiness has to be judged against that rule, not the user one. +function sdkVisibleAssistantParts(content: readonly any[]): readonly any[] { + return content.filter( + (part) => !part || part.type !== "text" || part.text !== "" || part.providerOptions != null, + ) +} + +// True when a message will reach the provider with no usable content. +function hasNoSendableContent(msg: ModelMessage): boolean { + const content = msg.content as unknown + if (typeof content === "string") return content === "" + if (!Array.isArray(content)) return true + // Judge each role by the SDK's own post-filter view (see the notes above). + if (msg.role === "user") return sdkVisibleUserParts(content).length === 0 + if (msg.role === "assistant") return sdkVisibleAssistantParts(content).length === 0 + return content.length === 0 +} + +// THE global pre-send content invariant: no message may reach the provider with +// empty content. This layer never existed before — `normalizeContentArray` only +// guards content SHAPE, `normalizeMessages` only strips empty parts and only for +// `@ai-sdk/anthropic`/`@ai-sdk/amazon-bedrock` (so a Bedrock-backed gateway on any +// other npm got no protection at all), and `ensureTrailingUserMessage` inspects +// only the trailing assistant. An empty user message fell through all three seams +// and produced `messages.: user messages must have non-empty content`. +// +// Policy is per-role and deliberately asymmetric: +// - user → BACKFILL a minimal non-empty text turn. Dropping it would make +// the request end with an assistant message, which Bedrock rejects +// as a prefill — trading this 400 for the prefill 400. +// - assistant → DROP. It is residue with nothing to preserve, and the trailing +// user guard that runs next re-establishes the prefill invariant. +// - tool → LEAVE UNTOUCHED. A tool message's content must be `tool-result` +// blocks keyed to a preceding `tool-call`; we cannot synthesize a +// valid one, and injecting text would break tool_use/tool_result +// pairing (a different 400). The SDK's empty-text filter does not +// apply to the tool branch, and no empty tool message exists in +// any observed transcript, so there is nothing to repair here. +// +// Provider-agnostic on purpose: the AI SDK applies its stripping filter for every +// provider, so gating this on an npm package name is what created the hole. +export function ensureNonEmptyContent(msgs: ModelMessage[]): ModelMessage[] { + const result: ModelMessage[] = [] + for (const msg of msgs) { + if (!hasNoSendableContent(msg)) { + result.push(msg) + continue + } + if (msg.role === "assistant") continue + if (msg.role === "tool") { + result.push(msg) + continue + } + result.push({ ...msg, content: [{ type: "text", text: EMPTY_CONTENT_PLACEHOLDER }] } as ModelMessage) + } + return result +} + // True when an assistant ModelMessage carries no renderable content (no text and // no tool-call) — pure residue we can drop without losing anything. function isEmptyAssistant(msg: ModelMessage): boolean { @@ -292,17 +400,46 @@ function isEmptyAssistant(msg: ModelMessage): boolean { // user turn is appended so the list ends with a user message. Runs at the // pre-send choke point in `message()`, so it also self-heals history that // already ends in an assistant turn. +// +// ORDERING CONTRACT: `ensureNonEmptyContent` MUST run before this function. +// This guard only establishes "the list ends with user/tool"; it says nothing +// about whether that trailing message has usable content. Running it first and +// resolving emptiness second would let this function return a list ending in an +// empty user message (which is what shipped, and what produced the live 400), +// and resolving emptiness afterwards could drop that message again and re-open +// the prefill 400. Emptiness first, prefill second — the two cannot fight. export function ensureTrailingUserMessage(msgs: ModelMessage[]): ModelMessage[] { // Drop only trailing EMPTY assistant residue (nothing to preserve). let end = msgs.length while (end > 0 && isEmptyAssistant(msgs[end - 1])) end-- const trimmed = end === msgs.length ? msgs : msgs.slice(0, end) const last = trimmed[trimmed.length - 1] - // Already ends with user or tool (or empty) — safe to send as-is. + // Already ends with a user or tool message, so this is not a prefill. Their + // content is guaranteed non-empty by `ensureNonEmptyContent` (see the ordering + // contract above) — an empty trailing message is NOT safe to send as-is. if (!last || last.role !== "assistant") return trimmed // A content-bearing assistant is legitimately last: keep it and append a // minimal user turn so the request ends with a user message. - return [...trimmed, { role: "user", content: CONTINUATION_PROMPT }] + // + // The content MUST be an array of parts, never a bare string. `message()` is + // typed for `ModelMessage[]` (where `content: string` is legal) but it does not + // run on `ModelMessage[]` — it runs inside the `wrapLanguageModel` middleware on + // `args.params.prompt`, a `LanguageModelV3Prompt`, whose user content is + // `Array`. That mismatch is silenced by the + // `@ts-expect-error` at session/llm.ts:670 (and session/prompt.ts:596). + // + // A bare string there is not merely untidy, it is THE producer of the 400: + // @ai-sdk/anthropic's user branch does `for (let j = 0; j < content.length; j++)` + // and `switch (part.type)` with cases for only `text`/`file` and NO default + // (dist/index.mjs:2320-2408 on 3.0.82), so a string is iterated as individual + // characters whose `.type` is `undefined`, nothing is pushed, and the message + // goes out as `{"role":"user","content":[]}` — the exact trailing message in the + // observed failing request. + // + // `ensureNonEmptyContent` cannot save this: per the ordering contract it runs + // BEFORE this function, and "Continue." is not empty by any predicate, so the + // append is never re-inspected. + return [...trimmed, { role: "user", content: [{ type: "text", text: CONTINUATION_PROMPT }] } as ModelMessage] } // Hard prune of the trailing assistant run, discarding its content. Unlike @@ -454,17 +591,36 @@ function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage return msgs } -// Minimal crash guard: ensure msg.content is never a non-string non-array value -// (object, undefined, null) that would blow up downstream `.map()` calls. +// Minimal crash guard: for the roles it can repair, ensure msg.content is never a +// non-string non-array value (object, undefined, null) that would blow up +// downstream `.map()` calls. NOT a blanket guarantee — `tool` is deliberately +// exempt (see below), so downstream code must still not assume array content. // Strings are valid ModelMessage content (the AI SDK accepts content: string | -// Array) and are left untouched. Only genuinely-invalid types are normalized -// to a safe empty array so every downstream path can safely call `.map()`. +// Array) and are left untouched. Only genuinely-invalid types are normalized. +// +// Invalid content is BACKFILLED, not blanked, for roles the provider requires to +// be non-empty. Emitting `content: []` here would trade a crash for a 400 +// ("user messages must have non-empty content"), and blanking a user turn also +// re-opens the trailing-assistant prefill 400 once the empty message is dropped +// downstream. An assistant gets `[]` because it carries no obligation: the +// non-empty invariant drops empty assistant residue and the trailing-user guard +// then re-establishes the prefill invariant. +// +// A `tool` message is left EXACTLY as-is, matching ensureNonEmptyContent's +// per-role policy: injecting a text part into a tool message breaks tool_use / +// tool_result pairing, which trades one 400 for another, and emitting `content: +// []` is itself illegal for a tool result. Only `user` gets the backfill. +// "Exactly as-is" is the load-bearing part: `[]` is NOT an acceptable substitute, +// and leaving the value untouched is what makes this guard and +// `ensureNonEmptyContent` reach the same outcome on the same input +// (`hasNoSendableContent` returns true for non-array content, and the tool branch +// there re-pushes the message unchanged). function normalizeContentArray(msgs: ModelMessage[]): ModelMessage[] { return msgs.map((msg) => { if (typeof msg.content === "string" || Array.isArray(msg.content)) return msg - // object / undefined / null — not a valid ModelMessage content shape; - // wrap in an empty array so .map() downstream never throws. - return { ...msg, content: [] } as ModelMessage + if (msg.role === "assistant") return { ...msg, content: [] } as ModelMessage + if (msg.role === "user") return { ...msg, content: [{ type: "text", text: EMPTY_CONTENT_PLACEHOLDER }] } as ModelMessage + return msg }) } @@ -810,6 +966,11 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re msgs = limitImages(msgs, model) msgs = normalizeMessages(msgs, model, options) msgs = forceAnthropicReasoningContent(msgs, model) + // Ordering is load-bearing (see ensureTrailingUserMessage's ordering contract): + // resolve EMPTY content first, then the trailing-assistant/prefill invariant. + // Emptiness is provider-agnostic because the AI SDK strips empty user text + // parts for every provider, downstream of everything here. + msgs = ensureNonEmptyContent(msgs) // SAFE prefill guard: never let the request end with an assistant (prefill) // message a provider (e.g. Bedrock) would reject, without deleting a completed // reply. Drops only empty residue; appends a continuation user turn otherwise. @@ -848,14 +1009,89 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re return msgs } -// Place a cache breakpoint on the tool definitions. The cache hierarchy is -// `tools` → `system` → `messages`, so marking the LAST tool caches the entire -// tool-schema block (often several KB) as a stable prefix that sits in front of -// the system + message caches. Tools are passed to the SDK separately from -// `message()` and never go through its providerID→SDK-key remap, so we resolve -// the SDK-keyed marker via `cacheMarkerFor`. Tool registration order is stable -// (insertion order of the tools record), so "last tool" is deterministic. +// OpenAI's Responses API treats a function tool that OMITS `strict` as strict, +// and `@ai-sdk/openai` only emits the field when the tool sets it +// (`...tool.strict != null ? { strict: tool.strict } : {}`) — so by default we +// were opting into constrained decoding by accident. +// +// Our tool schemas are deliberately NOT strict-compatible: optional parameters +// stay out of `required`, not every object carries `additionalProperties: false`, +// and discriminated unions keep an `anyOf`. Rather than reject the request, the +// Codex backend auto-patches such a schema (observed on the wire: all 17 tools +// came back tagged `strict: true`, `bash.required` grew from 2 entries to 5, and +// `task.parameters.properties.operation` gained `additionalProperties: false`) +// and then fails to compile the resulting decoding grammar. Because the failure +// happens at GENERATION time, the 200 is already committed and the error can +// only arrive mid-stream as `event: error` (`server_error`) + `response.failed` — +// i.e. "the answer stops half-written". Sending `strict: true` explicitly with +// the same schema gets a clean 502 instead, which is why this read as random +// upstream flakiness rather than a deterministic schema problem. +// +// So state the intent explicitly. +// +// This list is keyed by npm package, but the Responses-vs-Chat decision is made +// per PROVIDER in `provider.ts` `getModel`. Those two can drift, so here is the +// full set of `sdk.responses()` call sites and why each is or is not listed: +// +// provider.ts:323 openai @ai-sdk/openai → LISTED +// provider.ts:359 azure @ai-sdk/azure → LISTED, builds +// `OpenAIResponsesLanguageModel` from `@ai-sdk/openai/internal` +// provider.ts:379 azure-cognitive-services @ai-sdk/azure → covered by the above +// (catalog pins the provider's npm to `@ai-sdk/azure`) +// provider.ts:340 github-copilot → the npm id resolves to the VENDORED +// `./sdk/copilot`, whose prepare-tools already always emits +// `strict`, so it is immune and must stay out of this list +// provider.ts:331 xai @ai-sdk/xai → NOT listed. It +// forwards `tool.strict` with the same omit-when-null guard, but +// its prepare-tools runs every tool schema through +// `removeAdditionalPropertiesFalse` (xai/dist:319). Strict mode +// REQUIRES `additionalProperties: false`, so a strict-by-default +// xAI would reject every tool call the SDK makes. It therefore +// cannot be strict by default, and forcing the field here would +// assert a constraint xAI has not been shown to honour. +// +// Everything else is left alone on purpose: `@ai-sdk/anthropic` warns ("strict mode +// is not supported by this provider") for any non-null `strict`, so a blanket +// default would spam warnings on every Anthropic request. +// +// An explicit per-tool `strict` is preserved, so a tool that has been made +// strict-compatible can still opt in. +// +// Azure's `useCompletionUrls` branch sends the same tools to Chat Completions +// instead, where the field lands as `function.strict: false` — a documented +// boolean whose default is already false, so that path is unaffected. +const EXPLICIT_NON_STRICT_TOOL_SDKS = ["@ai-sdk/openai", "@ai-sdk/azure"] + +// The single choke point for the outbound tool set (session/llm.ts passes the +// result straight to `streamText`). Two responsibilities: +// +// 1. Pin `strict: false` for EXPLICIT_NON_STRICT_TOOL_SDKS — see above for why +// omitting the field breaks the Codex backend mid-stream. +// 2. Place a cache breakpoint on the tool definitions. The cache hierarchy is +// `tools` → `system` → `messages`, so marking the LAST tool caches the entire +// tool-schema block (often several KB) as a stable prefix that sits in front +// of the system + message caches. Tools are passed to the SDK separately from +// `message()` and never go through its providerID→SDK-key remap, so we +// resolve the SDK-keyed marker via `cacheMarkerFor`. Tool registration order +// is stable (insertion order of the tools record), so "last tool" is +// deterministic. +// +// Both mutate in place. That is safe because the record and every tool object in +// it are rebuilt per request: `resolveTools` allocates a fresh record and calls +// `tool()` per entry, and MCP entries come from `convertMcpTool`, which returns +// a new `dynamicTool()` on every `MCP.tools()` call. Nothing here outlives the +// request, so a model switch between steps cannot carry `strict` over to a +// provider that would reject or warn on it. export function tools>(tools: T, model: Provider.Model): T { + if (EXPLICIT_NON_STRICT_TOOL_SDKS.includes(model.api.npm)) { + // Guarded because this walks every entry; the single `last` lookup below can + // assume a well-formed record, but a loop over N values is cheaper to make + // safe than to debug as a crash in the request path. + for (const tool of Object.values(tools)) { + if (tool && tool.strict == null) tool.strict = false + } + } + if (!supportsCacheMarkers(model)) return tools const marker = cacheMarkerFor(model) if (!marker) return tools @@ -867,6 +1103,48 @@ export function tools>(tools: T, model: Provider.M return tools } +// The `response_format` / `text.format` sibling of the tool `strict` problem +// above. `generateObject`/`streamObject` ship our zod schema as a `json_schema` +// response format, and there the OpenAI SDKs default `strictJsonSchema` to TRUE +// — `@ai-sdk/openai` on both the chat and responses paths, and +// `@ai-sdk/openai-compatible` — so `strict: true` goes out EXPLICITLY rather +// than being omitted. +// +// Our judge schema is not strict-compatible: `SessionGoal.Verdict` marks +// `impossible` optional, so `required` ships 2 of its 3 properties and OpenAI +// rejects the request (strict mode requires every key in `properties` to appear +// in `required`). Verified on the wire: `text.format` goes out as `strict: true` +// with `required: ["ok", "reason"]`. Because `goal.ts` judges with the SESSION's +// model, this breaks the stop-condition judge on every OpenAI-backed model. +// +// Unlike the tool case this fails cleanly at validation instead of mid-stream, so +// it is a separate, visible bug — but the root cause is the same: a strict +// default meeting a deliberately non-strict schema. +// +// Making the schema strict-compatible is the wrong trade here. `impossible` is +// optional BY DESIGN — JUDGE_SYSTEM tells the judge to return `{"ok": false}` +// WITHOUT `impossible` when in doubt — so forcing it into `required` would change +// what the judge is asked to produce. State `strict: false` instead, exactly as +// for tools. +// +// Scoped to the SDKs that read `strictJsonSchema` AND default it to true. +// `@ai-sdk/openai-compatible` is included: it looks up provider options under the +// name it was constructed with, which provider.ts sets to `model.providerID` — +// the same key `providerOptions()` falls back to when `sdkKey()` has no mapping. +// +// Schemas that ARE strict-compatible (e.g. the agent-config schema in +// agent/agent.ts) are deliberately left alone so they keep constrained decoding. +const DEFAULT_STRICT_SCHEMA_SDKS = ["@ai-sdk/openai", "@ai-sdk/azure", "@ai-sdk/openai-compatible"] + +// Feed through `providerOptions()` before handing to generateObject/streamObject. +// Returns undefined — not `{}` — for SDKs that do not default strict on, so +// callers can skip attaching a provider-options bag entirely rather than sending +// an empty one to every other provider. +export function structuredOutputOptions(model: Provider.Model) { + if (!DEFAULT_STRICT_SCHEMA_SDKS.includes(model.api.npm)) return undefined + return { strictJsonSchema: false } +} + export function temperature(model: Provider.Model) { const id = model.id.toLowerCase() if (id.includes("qwen")) return 0.55 diff --git a/packages/opencode/src/server/routes/instance/access.ts b/packages/opencode/src/server/routes/instance/access.ts new file mode 100644 index 000000000..3c65af08d --- /dev/null +++ b/packages/opencode/src/server/routes/instance/access.ts @@ -0,0 +1,17 @@ +/** + * Shared contract for the instance middleware's directory whitelist rejection. + * + * The rejection itself is correct policy (a client may not point the server at a + * directory outside its cwd), but a client has to be able to RECOGNISE it: the + * generated SDK throws the parsed response body, with no status code attached, so + * a 403 is otherwise indistinguishable from a transport failure and gets treated + * as fatal. `code` is the stable discriminator — never match on `error` prose. + * + * Leaf module on purpose: the TUI imports the guard, so this file must not pull + * the server's instance/bootstrap graph into the TUI bundle. + */ +export const DIRECTORY_DENIED_CODE = "directory_not_allowed" + +export function isDirectoryDeniedError(e: unknown): e is { code: string; error: string; directory?: string } { + return typeof e === "object" && e !== null && "code" in e && e.code === DIRECTORY_DENIED_CODE +} diff --git a/packages/opencode/src/server/routes/instance/middleware.ts b/packages/opencode/src/server/routes/instance/middleware.ts index d5f6683a9..c635de844 100644 --- a/packages/opencode/src/server/routes/instance/middleware.ts +++ b/packages/opencode/src/server/routes/instance/middleware.ts @@ -9,6 +9,7 @@ import { Flag } from "@/flag/flag" import { Filesystem } from "@/util" import { Global } from "@/global" import path from "node:path" +import { DIRECTORY_DENIED_CODE } from "./access" export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler { return async (c, next) => { @@ -34,7 +35,17 @@ export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler ? Filesystem.resolve(path.join(Global.Path.data, "orchestrator")) : undefined if (!Filesystem.contains(cwd, directory) && directory !== orchestrator) { - return c.json({ error: "Access denied: directory must be within the server's working directory" }, 403) + // Keep the 403 and the prose message; add a stable `code` so a client can + // tell this policy rejection apart from a transport failure and surface it + // instead of dying (see ./access.ts). + return c.json( + { + code: DIRECTORY_DENIED_CODE, + error: "Access denied: directory must be within the server's working directory", + directory, + }, + 403, + ) } } diff --git a/packages/opencode/src/server/routes/instance/session.ts b/packages/opencode/src/server/routes/instance/session.ts index 883a513a1..c072765d0 100644 --- a/packages/opencode/src/server/routes/instance/session.ts +++ b/packages/opencode/src/server/routes/instance/session.ts @@ -732,7 +732,7 @@ export const SessionRoutes = lazy(() => const actor = spawnRef.current if (!actor) return yield* Effect.fail( - new Error("Actor service unavailable — Actor.defaultLayer must be running to ask a side question"), + new Error("Actor service unavailable — Actor.appLayer must be running to ask a side question"), ) const selectedModel = body.providerID && body.modelID ? { providerID: body.providerID, modelID: body.modelID } : undefined diff --git a/packages/opencode/src/session/auto-dream.ts b/packages/opencode/src/session/auto-dream.ts index 6908821bf..7f8177302 100644 --- a/packages/opencode/src/session/auto-dream.ts +++ b/packages/opencode/src/session/auto-dream.ts @@ -1,4 +1,5 @@ import { Effect } from "effect" +import { isMemoryWriteEnabled } from "@/memory/write-gate" import { Database, eq, desc, asc, isNull } from "@/storage" import { SessionTable } from "./session.sql" import { Log } from "@/util" @@ -107,6 +108,9 @@ function shouldAutoRun(input: { } export function shouldAutoDream(cfg: Config.Info) { + // Memory writing off → the consolidation pass that rewrites project memory + // must not run either. + if (!isMemoryWriteEnabled(cfg)) return Effect.succeed(false) const enabled = cfg.dream?.auto === true if (!enabled) return Effect.succeed(false) const now = Date.now() @@ -117,6 +121,9 @@ export function shouldAutoDream(cfg: Config.Info) { } export function shouldAutoDistill(cfg: Config.Info) { + // Distill reads memory to mine patterns and then auto-produces artifacts in the + // background. With writing off, nothing should be produced automatically. + if (!isMemoryWriteEnabled(cfg)) return Effect.succeed(false) const enabled = cfg.distill?.auto === true if (!enabled) return Effect.succeed(false) const now = Date.now() diff --git a/packages/opencode/src/session/checkpoint-align.ts b/packages/opencode/src/session/checkpoint-align.ts index 08ad5f42d..738cd744e 100644 --- a/packages/opencode/src/session/checkpoint-align.ts +++ b/packages/opencode/src/session/checkpoint-align.ts @@ -10,8 +10,9 @@ type AlignMsg = { * * Used to align a delta slice's start so the LLM does not see an orphan * tool_result. If no qualifying message exists in `[0, idx]`, returns 0 - * (caller may still receive an LLM rejection, in which case writerFailures - * increments via the existing path — degenerate sessions only). + * (caller may still receive an LLM rejection; that surfaces as a writer + * failure, which leaves the previous checkpoint and its watermark in place, so + * the next threshold crossing re-covers the same delta). * * If `idx` is past the end of `msgs`, returns `idx` unchanged: the empty * delta is a legitimate (post-watermark) state. diff --git a/packages/opencode/src/session/checkpoint.ts b/packages/opencode/src/session/checkpoint.ts index 8b39ef138..b5d34bac4 100644 --- a/packages/opencode/src/session/checkpoint.ts +++ b/packages/opencode/src/session/checkpoint.ts @@ -4,10 +4,11 @@ import { Global } from "@/global" import { Bus } from "@/bus" import { Config } from "@/config" import { Memory } from "@/memory" +import { isMemoryWriteEnabled } from "@/memory/write-gate" import { MemoryFtsTable } from "@/memory/fts.sql" import { TaskRegistry } from "@/task/registry" import { ActorRegistry } from "@/actor/registry" -import type { AgentOutcome, ForkContext } from "@/actor/spawn" +import type { AgentOutcome, FailureInfo, ForkContext } from "@/actor/spawn" import { spawnRef } from "@/actor/spawn-ref" import { prefixCaptureRef } from "./prefix-capture-ref" import { Database, and, eq, or } from "@/storage" @@ -417,9 +418,9 @@ export type TryStartCheckpointWriterInput = { * newest wins because its range is a strict superset of the * older pending range, so the older one would just duplicate * work. (F40) - * - "skipped": the request was rejected outright — empty session, system- - * spawned subagent, or Actor service unavailable. No writer - * will fire for this request now or later. + * - "skipped": the request was rejected outright — memory writing disabled, + * empty session, system-spawned subagent, or Actor service + * unavailable. No writer will fire for this request now or later. */ export type TryStartCheckpointWriterResult = "started" | "queued" | "skipped" @@ -430,6 +431,20 @@ export interface Interface { readonly waitForWriter: (sessionID: SessionID) => Effect.Effect + /** + * The same bounded wait as `waitForWriter`, additionally surfacing the + * failure classification the writer's outcome already carries. + * + * `waitForWriter` is #1938's contract — three flat values, one of which + * ("timeout") means "still in flight". That contract is deliberately left + * alone; this is the shape a caller needs when the CLASS of a failure + * changes what it does next (prune's recovery gate). Both are one + * implementation: `waitForWriter` projects `.outcome` off this, so the two + * can never disagree about what a settled writer did. + */ + readonly waitForWriterSettlement: (sessionID: SessionID) => Effect.Effect + + /** * Await all in-flight writers across sessions up to `timeoutMs`. Used by * the CLI shutdown path so headless `mimo run` invocations don't exit @@ -513,7 +528,27 @@ export class Service extends Context.Service()("@opencode/Se // Writer state per session // --------------------------------------------------------------------------- -export type WriterOutcome = "success" | "failure" +// "timeout" means the caller's bounded wait expired while the writer was STILL +// IN FLIGHT — it is deliberately distinct from "failure" (the writer settled +// unsuccessfully). See waitForWriter for why conflating the two silently +// disables checkpointing for a session whose writers are merely slow. +export type WriterOutcome = "success" | "failure" | "timeout" + +/** + * A settled (or bound-expired) writer, plus the classification its + * AgentOutcome already carried. + * + * `failure` is present only when `outcome === "failure"` AND the writer's + * error was classifiable at the construction site. It is absent for a + * cancelled writer and for a failure whose error never reached + * classifyAssistantError — so "absent" means "unknown class", never + * "retryable". + */ +export type WriterSettlement = { + outcome: WriterOutcome | "no-writer" + failure?: FailureInfo +} + interface WriterState { // Holds the AgentOutcome Deferred returned by Actor.spawn so callers can @@ -554,6 +589,27 @@ export const layer: Layer.Layer< ) => Effect.Effect = Effect.fn("SessionCheckpoint.tryStartCheckpointWriter")(function* ( input: TryStartCheckpointWriterInput, ) { + // Memory writing disabled — stop producing NEW memory. This is the single + // gate for the whole write side of checkpointing: template bootstrap + // (ensureCheckpointTemplate / ensureMemoryTemplate / ensureNotesTemplate), + // the writer subagent spawn, and the validator retry rename all live past + // this point, so returning here holds every one of them down at once. We + // deliberately never spawn rather than spawn-and-drop-the-write: the writer + // would burn a full model turn producing bytes nobody stores. + // + // READS are untouched — renderRebuildContext still injects an existing + // checkpoint.md / MEMORY.md / notes.md, and the `memory` search tool keeps + // working. The reads inside this function (prior checkpoint, progressDiff) + // exist only to feed the writer prompt, so short-circuiting loses no + // read capability. + // + // Default is ENABLED: absent config → write. The field name and polarity + // live in exactly one place (memory/write-gate.ts). + if (!isMemoryWriteEnabled(yield* config.get())) { + log.info("memory writing disabled, skipping checkpoint", { sessionID: input.sessionID }) + return "skipped" as const + } + // F40: writer1 still running. Evict any prior pending and queue this // request — newest wins because its range is a strict superset of the // older pending range, so older pending checkpoints would only @@ -923,10 +979,39 @@ export const layer: Layer.Layer< ), ) } else { - log.warn("checkpoint writer did not succeed — leaving watermark unchanged so the delta is re-covered", { - sessionID: input.sessionID, - status: outcome.status, - }) + // Classify instead of count. This is the replacement for the failure + // accounting deleted earlier in this branch: the same "is this writer + // broken?" question, answered by reading the outcome the writer already + // carries instead of accumulating a tally across thresholds. + // + // A TRANSIENT failure needs nothing here — the writer's LLM calls run + // through SessionRetry's ladder (session/retry.ts), so it is already + // post-retry, and the next threshold crossing re-covers the delta with + // fresher context. A DETERMINISTIC one (overflow / auth / bad request) + // will recur identically at every future threshold, so it is reported + // as the distinct thing it is rather than as one more copy of an + // undifferentiated line. + // + // Deliberately STATELESS: no per-session memory of previous failures. + // Such memory is a trend detector, and the trend is the deferred + // user-facing-warning change's own state — accumulating it here under a + // new name is precisely what this branch removed. + // `failure` is optional and its source is nullable — truthiness, never + // `=== undefined` (AGENTS.md, "Reading a nullable column"). + const failure = outcome.status === "failure" ? outcome.failure : undefined + const classified = failure ? { kind: failure.kind, cause: failure.name } : {} + if (failure && !failure.retryable) { + log.warn( + "checkpoint writer failed deterministically — this will recur at every threshold until the cause is fixed; leaving watermark unchanged so the delta is re-covered", + { sessionID: input.sessionID, status: outcome.status, ...classified }, + ) + } else { + log.warn("checkpoint writer did not succeed — leaving watermark unchanged so the delta is re-covered", { + sessionID: input.sessionID, + status: outcome.status, + ...classified, + }) + } } // F40: capture pending before deleting the slot so a queued writer @@ -974,21 +1059,60 @@ export const layer: Layer.Layer< return "started" as const }) - const waitForWriter = Effect.fn("SessionCheckpoint.waitForWriter")(function* (sessionID: SessionID) { + const waitForWriterSettlement = Effect.fn("SessionCheckpoint.waitForWriterSettlement")(function* ( + sessionID: SessionID, + ) { const state = writers.get(sessionID) - if (!state) return "no-writer" as const + if (!state) return { outcome: "no-writer" as const } - // v2 writers manage 3 file types and frequently take 60-180s; pad to - // 5min so a long-but-honest writer is not mistaken for a failure by - // the prune retry watcher. AgentOutcome → WriterOutcome translation: - // success → "success", failure / cancelled → "failure". + // v2 writers manage 3 file types and frequently take 60-180s, so the + // wait is bounded at 5min rather than left unbounded. AgentOutcome → + // WriterOutcome translation: success → "success", failure / cancelled → + // "failure", bound expired with the writer still unsettled → "timeout". + // + // The bound expiring is NOT a writer failure — the padding is not what + // keeps the two apart, the distinct return value is. This timeout does + // not cancel the writer, and the settle watcher that owns the watermark + // advance (see tryStartCheckpointWriter) awaits the SAME Deferred with no + // bound — so a slow-but-successful writer still advances + // last_checkpoint_message_id after we stop waiting. Reporting "failure" + // here made a slow-but-working writer indistinguishable from a broken + // one, which is why the two outcomes stay distinct. Callers must be able + // to tell "still in flight" from "settled unsuccessfully": prune arms its + // recovery gate on a settled TRANSIENT failure only, and "timeout" must + // never reach that gate — the writer may still be about to succeed. const outcome = yield* Deferred.await(state.writing).pipe( Effect.timeout(300_000), - Effect.catch(() => Effect.succeed({ status: "failure", error: "timeout" })), + Effect.catch(() => Effect.succeed("timeout" as const)), ) - return outcome.status === "success" ? ("success" as const) : ("failure" as const) + if (outcome === "timeout") { + // Hitting the bound must stay observable: the caller reports neither a + // success nor a failure, so without this line a writer stuck past 5min + // produces no log at all until it finally settles. + log.info("checkpoint writer wait bound expired — writer still in flight", { + sessionID, + boundMs: 300_000, + }) + return { outcome: "timeout" as const } + } + if (outcome.status === "success") return { outcome: "success" as const } + // `failure` is optional and its source is nullable — truthiness, never + // `=== undefined` (AGENTS.md, "Reading a nullable column"). A cancelled + // outcome has no failure arm at all, so it lands here unclassified. + const failure = outcome.status === "failure" ? outcome.failure : undefined + return failure ? { outcome: "failure" as const, failure } : { outcome: "failure" as const } + }) + + // #1938's contract, unchanged: three flat values, "timeout" distinct from + // "failure". Projected off waitForWriterSettlement rather than duplicated, + // so the classification-aware caller and this one can never disagree. + const waitForWriter: (sessionID: SessionID) => Effect.Effect = Effect.fn( + "SessionCheckpoint.waitForWriter", + )(function* (sessionID: SessionID) { + return (yield* waitForWriterSettlement(sessionID)).outcome }) + const drainWriters = Effect.fn("SessionCheckpoint.drainWriters")(function* (input?: { timeoutMs?: number }) { const timeoutMs = input?.timeoutMs ?? 120_000 const pending = [...writers.values()] @@ -1419,7 +1543,36 @@ export const layer: Layer.Layer< .get(), ), ) - return row?.last_checkpoint_message_id as MessageID | undefined + // Two independent absences meet in this one expression, and only one of + // them is `undefined`: + // + // row is undefined -> no such session row. Drizzle normalises the + // driver's null to undefined here (measured: + // bun:sqlite's .get() returns null, Drizzle + // returns undefined), so this half is honest. + // column is null -> the session exists but no writer has set the + // watermark yet. SQL NULL, faithfully mapped to + // JS null, because the column is nullable + // (session.sql.ts: text().$type() + // with no .notNull()). + // + // So `row?.last_checkpoint_message_id` is `MessageID | null | undefined`. + // Callers only ever ask "is there a boundary to rebuild from", for which + // the last two are the same answer, so flattening to `undefined` is right + // — it also matches Drizzle's own row-level convention. + // + // The flattening is written as an ANNOTATION rather than an `as` cast on + // purpose. A cast would let the union be narrowed by assertion, which is + // how this went wrong before: the previous version claimed + // `as MessageID | undefined` while returning `null`, and a caller that + // then wrote `boundary !== undefined` got a condition that is always true + // — a guard that typechecks, reads correctly, and does nothing. Every + // other caller happened to test truthiness (`!boundary` at prompt.ts:413 + // and `watermarkBefore ?` at :1131) and so never noticed. With an + // annotation, dropping the `?? undefined` is a + // compile error instead of a silent lie. + const boundary: MessageID | undefined = row?.last_checkpoint_message_id ?? undefined + return boundary }) const isWriterRunning = Effect.fn("SessionCheckpoint.isWriterRunning")(function* (sessionID: SessionID) { @@ -1549,6 +1702,7 @@ export const layer: Layer.Layer< return Service.of({ tryStartCheckpointWriter, waitForWriter, + waitForWriterSettlement, drainWriters, hasCheckpoint, hasMemoryOrTasks, @@ -1571,8 +1725,9 @@ export const layer: Layer.Layer< // the Actor implementation through the late-bound `spawnRef` (see // `actor/spawn-ref.ts`). This deliberately breaks the otherwise-unresolvable // layer cycle Actor → SessionPrompt → SessionCheckpoint → Actor. The AppLayer -// constructs `Actor.defaultLayer` separately; its initialiser populates -// `spawnRef`, which `tryStartCheckpointWriter` reads at call time. +// constructs `Actor.appLayer` separately; that variant wraps the same +// `Actor.layer`, whose initialiser populates `spawnRef` (see +// `actor/spawn.ts`), and `tryStartCheckpointWriter` reads the ref at call time. export const defaultLayer = Layer.suspend(() => layer.pipe( Layer.provide(Session.defaultLayer), diff --git a/packages/opencode/src/session/goal.ts b/packages/opencode/src/session/goal.ts index 19a986cf8..32f9cf16f 100644 --- a/packages/opencode/src/session/goal.ts +++ b/packages/opencode/src/session/goal.ts @@ -158,7 +158,16 @@ export const layer = Layer.effect( // Convert the conversation to native model messages so the judge sees the // real tool calls/results/images — same context the working agent had. - const conversation = yield* MessageV2.toModelMessagesEffect(input.msgs, resolved) + // + // `ensureNonEmptyContent` is applied by hand here because this is the ONE + // persisted-parts→provider site that does not run `ProviderTransform.message`: + // `model: language` below is the RAW model, with no `wrapLanguageModel` and no + // middleware anywhere in this file, so the pre-send invariant that every other + // build site inherits from the middleware would otherwise be absent. An empty + // user message here reaches the judge's provider unrepaired. + const conversation = ProviderTransform.ensureNonEmptyContent( + yield* MessageV2.toModelMessagesEffect(input.msgs, resolved), + ) // Diagnostic: dump the FULL message array sent to the judge. Long strings // (e.g. base64 image data) are clipped with a length marker so the log @@ -179,6 +188,12 @@ export const layer = Layer.effect( messages: JSON.stringify(fullMessages, clip), }) + // `Verdict.impossible` is optional by design, which strict mode rejects. + // See ProviderTransform.structuredOutputOptions for the full reasoning. + // undefined for SDKs that don't default json_schema strict on, so those + // models keep sending no provider options at all. + const structuredOutput = ProviderTransform.structuredOutputOptions(resolved) + const params = { experimental_telemetry: { isEnabled: cfg.experimental?.openTelemetry, @@ -196,6 +211,7 @@ export const layer = Layer.effect( ], model: language, schema: Verdict, + providerOptions: structuredOutput && ProviderTransform.providerOptions(resolved, structuredOutput), } satisfies Parameters[0] if (isOpenaiOauth) { @@ -205,6 +221,7 @@ export const layer = Layer.effect( providerOptions: ProviderTransform.providerOptions(resolved, { instructions: JUDGE_SYSTEM, store: false, + ...structuredOutput, }), onError: () => {}, }) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 5c3b32ac5..758eefca5 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -33,9 +33,55 @@ import { ActorRegistry } from "@/actor/registry" import { Memory } from "@/memory" import { isRetryableTransientError } from "./retry" import { MCP_TOOL_SEARCH_ID } from "@/tool/mcp-tool-search" +import { deriveLiveness } from "@/actor/schema" +import { SYSTEM_SPAWNED_AGENT_TYPES } from "@/agent/config" const log = Log.create({ service: "llm" }) export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX + +/** + * Lead-in for the orchestrator's fleet roster, and the reason the roster carries + * NO XML envelope. + * + * It used to be pushed as `\n…\n`, and users + * saw that literal tag — rows and all — in the TUI. The TUI is not at fault: the + * roster goes into the SYSTEM array and the TUI never renders system content. + * The model was quoting it. It had every reason to: `orchestrator.txt` named the + * tag five times and told it to "Look at ``", so the tag was + * vocabulary the prompt had taught it, and the literal string was sitting in its + * context to copy. + * + * Asking it not to echo the tag would be another prompt instruction, and this PR + * measured what those are worth — the maintainer/author paragraph lost 3/3 live + * turns. So remove the artifact instead of requesting restraint: with no + * `` string anywhere in the assembled request, echoing it is not + * a behaviour the model can exhibit. The prompt now refers to the roster + * functionally ("your fleet roster") and keeps the field layout, which is the + * part that was actually load-bearing for routing. + * + * Dropping the delimiter costs nothing structurally: this was the ONLY tagged + * block in the system array (the agent prompt and the memory instructions are + * both plain prose), and `dispatchLedgerNotice` already ships the same roster to + * the model in a tool result with a prose header and no envelope. + * + * The "internal working context" sentence is a genuinely weaker lever than the + * removal — it can only ask. It is here because it costs one line and it sits + * ADJACENT to the data it governs rather than in a paragraph assembled far away. + * It does not stop the model paraphrasing a child's title, and it is not claimed + * to; what is mechanically closed is the literal tag. + */ +export const ROSTER_HEADER = + "Your fleet — your routable child sessions right now. This list is internal working context, " + + "not output: never repeat it, or these session ids and titles, back to the user — report the " + + "routing DECISION instead (\"routing this to the docs child\"). Format is id | title | agent | status:" + +// How many FINISHED-but-resumable child sessions the fleet roster carries, +// most-recently-active first. The roster is re-injected on EVERY request, +// so the idle tail (which grows monotonically as children complete) must be +// bounded; running children are self-limiting and are never dropped. A count cap +// rather than a time window, because N children can finish inside one minute and +// a window would not actually bound the block. +export const ROSTER_IDLE_LIMIT = 5 type Result = Awaited> /** @@ -288,6 +334,51 @@ const live: Layer.Layer< system.push(buildMemoryInstructions(SessionID.make(input.sessionID), projectID, yield* memory.root())) } + // Orchestrator fleet roster: inject a compact one-line-per-session + // list of the orchestrator's ROUTABLE child sessions. Only for the orchestrator + // agent — other agents don't manage children. Format is intentionally compact + // (~30 tokens/session): id | title | agent | status. Field 3 is the child's + // AGENT (build/plan/compose) — the routing signal the model needs — not its + // actor mode, which is always "peer" here and therefore carries no signal. + // AI needs details on demand → session status/ask. + if (input.agent.name === "orchestrator") { + // listPeerChildren joins through the Session row's parent_id, because a + // peer child registers its actor row under its OWN session id — a + // session_id-keyed lookup (listByParent) never matches a peer. + const children = yield* actorReg.listPeerChildren( + SessionID.make(input.sessionID), + input.agentID ?? "main", + ) + const now = Date.now() + const routable = children + .filter(({ actor }) => !SYSTEM_SPAWNED_AGENT_TYPES.has(actor.agent)) + .map(({ actor, title }) => ({ actor, title, live: deriveLiveness(actor, now) })) + // Genuinely dead children stay out: `failure` and `cancelled` mean the + // child errored out or was torn down, so routing work into it is wrong. + .filter(({ live }) => live !== "failure" && live !== "cancelled") + // `success` means "its LAST TURN finished cleanly", NOT "the session is + // gone" — a persistent peer child is still resumable by `session send` + // (same id, history intact). Dropping those made a child PERMANENTLY + // invisible the moment it did its job, degrading "route to this topic's + // standing owner" into "route to whatever id I still remember". Report + // them honestly as `idle` (the same success→idle mapping `session list` + // already uses) rather than as `progressing`. + const working = routable.filter(({ live }) => live === "progressing" || live === "stalled") + // The idle tail is the only side that grows without bound (children keep + // finishing; running ones are capped by the machine), so bound IT: keep + // the most recently active few. Older idle children stay reachable via + // `session list`, they just don't pay rent in every request. + const idle = routable + .filter(({ live }) => live === "success" || live === "idle") + .sort((a, b) => b.actor.lastTurnTime - a.actor.lastTurnTime) + .slice(0, ROSTER_IDLE_LIMIT) + const lines = [...working, ...idle].map( + ({ actor, title, live }) => + ` ${actor.sessionID} | ${title} | ${actor.agent} | ${live === "success" ? "idle" : live}`, + ) + if (lines.length > 0) system.push(`${ROSTER_HEADER}\n${lines.join("\n")}`) + } + // Plugins still see the multi-part array (base prompt as [0], memory as a // trailing element) so hooks that index or append parts keep working. yield* plugin.trigger( @@ -667,7 +758,12 @@ const live: Layer.Layer< { specificationVersion: "v3" as const, async transformParams(args) { - if (args.type === "stream") { + // `generate || stream`, matching session/prompt.ts:597. This file's + // only SDK entrypoint is `streamText` (:599), so narrowing to + // "stream" is not an active hole today — but it would silently drop + // the whole transform, including the empty-content invariant, the + // moment a non-streaming call is added here. + if (args.type === "generate" || args.type === "stream") { // @ts-expect-error args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options) } diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 3137ec398..949bae61f 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -325,7 +325,7 @@ export const ToolStateCompleted = z status: z.literal("completed"), input: z.record(z.string(), z.any()), output: z.string(), - providerOutput: z.json().optional(), + providerOutput: z.unknown().optional(), providerMetadata: z.record(z.string(), z.any()).optional(), title: z.string(), metadata: z.record(z.string(), z.any()), @@ -358,6 +358,25 @@ export const ToolStateError = z }) export type ToolStateError = z.infer +/** + * The terminal state for a tool part that was left unfinished by an + * interruption. A `pending`/`running` part is persisted the moment the tool + * starts (so the TUI can stream progress) and is only rewritten by whoever + * finalizes the turn — so every finalizer must produce the SAME shape, or the + * transcript renders interrupted calls inconsistently. Callers: the abort + * finalizer in `SessionProcessor.cleanup` and `SessionPrompt.sweepOrphanToolParts`. + */ +export function abortedToolState(state: ToolPart["state"], error = "Tool execution aborted"): ToolStateError { + const end = Date.now() + return { + status: "error", + input: state.input, + error, + metadata: { ...("metadata" in state && state.metadata ? state.metadata : {}), interrupted: true }, + time: { start: "time" in state ? state.time.start : end, end }, + } +} + export const ToolState = z .discriminatedUnion("status", [ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]) .meta({ @@ -1076,12 +1095,7 @@ export function fromError( ): NonNullable { switch (true) { case e instanceof DOMException && e.name === "AbortError": - return new AbortedError( - { message: e.message }, - { - cause: e, - }, - ).toObject() + return new AbortedError({ message: e.message }, { cause: e }).toObject() // The AI SDK wraps the real failure in AI_RetryError after exhausting its // own maxRetries. Unwrap to the underlying error (.lastError) so the // APICallError branch below can extract statusCode/isRetryable/responseBody. @@ -1120,6 +1134,18 @@ export function fromError( }, { cause: e }, ).toObject() + case (e as SystemError)?.code === "ETIMEDOUT": + return new APIError( + { + message: (e as SystemError).message || "Request timed out", + isRetryable: true, + metadata: { + code: "ETIMEDOUT", + message: (e as SystemError).message ?? "", + }, + }, + { cause: e }, + ).toObject() case e instanceof Error && (e as FetchDecompressionError).code === "ZlibError": if (ctx.aborted) { return new AbortedError({ message: e.message }, { cause: e }).toObject() diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 2b5ac2061..8d57a8929 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -743,21 +743,28 @@ export const layer: Layer.Layer< for (const toolCallID of Object.keys(ctx.toolcalls)) { const match = yield* readToolCall(toolCallID) if (!match) continue - const part = match.part - const end = Date.now() - const metadata = "metadata" in part.state && isRecord(part.state.metadata) ? part.state.metadata : {} yield* session.updatePart({ - ...part, - state: { - ...part.state, - status: "error", - error: "Tool execution aborted", - metadata: { ...metadata, interrupted: true }, - time: { start: "time" in part.state ? part.state.time.start : end, end }, - }, + ...match.part, + state: MessageV2.abortedToolState(match.part.state), }) } ctx.toolcalls = {} + // Second pass, DB-driven. The loop above can only see calls this process + // still holds in `ctx.toolcalls`, so a call whose registration lost the race + // with teardown, or that arrived after the map was cleared, or whose + // `readToolCall` lookup missed, keeps its persisted `running` status forever + // — the transcript then shows a tool call that will never finish. Every tool + // part of THIS assistant message belongs to the turn being torn down here, + // so any part still `pending`/`running` is unfinalized by definition. + // Idempotent: the pass above already rewrote the tracked ones. + for (const part of yield* Effect.sync(() => MessageV2.parts(ctx.assistantMessage.id))) { + if (part.type !== "tool") continue + if (part.state.status !== "pending" && part.state.status !== "running") continue + yield* session.updatePart({ + ...part, + state: MessageV2.abortedToolState(part.state), + }) + } ctx.assistantMessage.time.completed = Date.now() yield* session.updateMessage(ctx.assistantMessage) }) diff --git a/packages/opencode/src/session/projectors.ts b/packages/opencode/src/session/projectors.ts index 767f415f8..0bda28600 100644 --- a/packages/opencode/src/session/projectors.ts +++ b/packages/opencode/src/session/projectors.ts @@ -1,8 +1,10 @@ -import { NotFoundError, eq, and } from "../storage" +import { NotFoundError, eq, and, sql } from "../storage" import { SyncEvent } from "@/sync" import * as Session from "./session" import { MessageV2 } from "./message-v2" import { SessionTable, MessageTable, PartTable } from "./session.sql" +import { ActorRegistryTable } from "@/actor/actor.sql" +import { ACTIVITY_COALESCE_MS } from "@/actor/schema" import { Log } from "../util" const log = Log.create({ service: "session.projector" }) @@ -129,6 +131,41 @@ export default [ }) .onConflictDoUpdate({ target: PartTable.id, set: { data: rest } }) .run() + // Activity heartbeat for actor liveness (actor/schema.ts deriveLiveness). + // This projector is the single writer of `part` rows, so it is the one + // place that already fires on every part write — no new hook in the session + // loop. Sequenced after the insert so activity is recorded only if the part + // actually landed. `part` carries no agent id (the agent slice lives on + // `message`), so the owning actor is resolved through the message's primary + // key; together with session_id that hits actor_registry's PK directly. A + // 0-row no-op when the session has no registry row, exactly like updateTurn. + // + // Coalesced to at most one write per actor per ACTIVITY_COALESCE_MS. The + // part-write path this hangs off is unthrottled — the bash tool's + // ctx.metadata fires per decoded stdout chunk — which measured 539-867 of + // these UPDATEs per second, each running the correlated subquery below, + // while the only consumers (deriveLiveness's 6m stall display and 10m + // abandonment bound) cannot resolve anything finer than tens of seconds. + // The staleness predicate is part of the WHERE rather than a process-local + // cache so it stays correct across instances and restarts, and it also + // makes the column monotonic: an out-of-order event carrying an older + // `data.time` no longer drags it backwards. `IS NULL` is the first + // disjunct because the column is nullable and a fresh row records NULL, + // which must still take its first write (AGENTS.md, "Reading a nullable + // column"). + db.update(ActorRegistryTable) + .set({ last_activity_time: data.time }) + .where( + and( + eq(ActorRegistryTable.session_id, sessionID), + eq( + ActorRegistryTable.actor_id, + sql`(SELECT ${MessageTable.agent_id} FROM ${MessageTable} WHERE ${MessageTable.id} = ${messageID})`, + ), + sql`(${ActorRegistryTable.last_activity_time} IS NULL OR ${ActorRegistryTable.last_activity_time} < ${data.time - ACTIVITY_COALESCE_MS})`, + ), + ) + .run() } catch (err) { if (!foreign(err)) throw err log.warn("ignored late part update", { partID: id, messageID, sessionID }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 3955d8039..049b1ad0e 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -29,8 +29,9 @@ import { SessionPrune } from "./prune" import { SessionCheckpoint } from "./checkpoint" import { SessionCompaction } from "./compaction" import { computeLastMessageInfo } from "./last-message-info" -import { contextPressureLevel, pressureLevel, usable, isOverflow as overflowCheck } from "./overflow" +import { contextPressureLevel, usable, isOverflow as overflowCheck } from "./overflow" import { Config } from "@/config" +import { isMemoryWriteEnabled } from "@/memory/write-gate" import { Global } from "@/global" import { Bus } from "../bus" import { ProviderTransform } from "../provider" @@ -55,12 +56,6 @@ import { TEXT_NGRAM_RECOVERY_REMIND, TEXT_NGRAM_RECOVERY_REPLAN, } from "../session/prompt/text-ngram-detection" -import { - EMPTY_STEP_MAX_RECOVERY, - EMPTY_STEP_RECOVERY_REMIND, - EMPTY_STEP_RECOVERY_REPLAN, - isEmptyStep, -} from "../session/prompt/empty-step-detection" import { builtinSkillRoot, matchDocumentSkills } from "@/skill/builtin/extract" import { ToolRegistry } from "../tool" import { MCP } from "../mcp" @@ -129,6 +124,8 @@ import { isMcpToolSearchEnabled } from "@/tool/gpt" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false +const SKILL_CATALOG_REMINDER_MARKER = "Skills available in this session:" + // Recall-reminder hints, rendered in each tool's configured invocation style so // shell-mode sessions never see a JSON-shaped example (which primes models to // emit JSON and crash the shell parser). `memory` has no shell form, so it is @@ -214,33 +211,6 @@ function stepSignature(parts: MessageV2.Part[]): string | undefined { return segments.join("\n") } -/** - * Debounce decision for the high-context-pressure memory-flush nudge. - * - * Returns true if a nudge (a text part containing `marker`) has already been - * injected within the *current high-pressure episode*, where the episode is the - * message window since the last checkpoint boundary. - * - * Keying off the checkpoint boundary rather than a fixed message count is - * deliberate: a single sustained high-pressure turn can emit many tool-call - * steps — each its own message — so a fixed-size tail would let the - * already-nudged message slide out of the window and re-fire the nudge - * mid-turn. The boundary only advances when a checkpoint/rebuild actually - * discards context, which is exactly when a fresh nudge becomes useful again. - * - * When `boundaryID` is undefined (no checkpoint yet) or is not found in `msgs`, - * the whole conversation is treated as the current episode. - */ -export function nudgedSinceBoundary( - msgs: readonly MessageV2.WithParts[], - boundaryID: string | undefined, - marker: string, -): boolean { - const boundaryIdx = boundaryID ? msgs.findIndex((m) => m.info.id === boundaryID) : -1 - const episode = boundaryIdx >= 0 ? msgs.slice(boundaryIdx) : msgs - return episode.some((m) => m.parts.some((p) => p.type === "text" && p.text?.includes(marker))) -} - const STRUCTURED_OUTPUT_DESCRIPTION = `Use this tool to return your final response in the requested structured format. IMPORTANT: @@ -277,6 +247,7 @@ export interface Interface { readonly command: (input: CommandInput) => Effect.Effect readonly resolvePromptParts: (template: string) => Effect.Effect readonly sweepOrphanAssistants: (sessionID: SessionID, immediate?: boolean) => Effect.Effect + readonly sweepOrphanToolParts: (sessionID: SessionID) => Effect.Effect readonly predict: (input: { sessionID: SessionID }) => Effect.Effect } @@ -340,14 +311,13 @@ export const layer = Layer.effect( // parity, so fall through to empty rather than emit a divergent date. const captureSession = yield* sessions.get(input.sessionID).pipe(Effect.catch(() => Effect.succeed(undefined))) if (!captureSession) return empty - const [skills, env, instructions] = yield* Effect.all([ - sys.skills(ag, model), + const [env, instructions] = yield* Effect.all([ sys.environment(model, captureSession.time.created), instruction.system().pipe(Effect.orDie), ]) // (checkpoint-writer never requests json_schema output, so STRUCTURED_OUTPUT_SYSTEM_PROMPT // is not included; parent's runLoop adds it conditionally based on user.format) - const additions = [...env, ...(skills ? [skills] : []), ...instructions.content] + const additions = [...env, ...instructions.content] const prefix = yield* buildLLMRequestPrefix({ sessionID: input.sessionID, agent: ag, @@ -429,6 +399,252 @@ export const layer = Layer.effect( return inserted }) + // Upper bound on how long a rebuild may block waiting for a checkpoint + // writer it started itself. `waitForWriter` takes NO timeout argument — its + // own 5-min bound is hardcoded at checkpoint.ts:986 — so the bound is + // applied by wrapping the call below. + // + // MANUAL: 5 min, matching waitForWriter's internal bound (i.e. unchanged + // behaviour). A human just typed /rebuild and is watching a spinner. + // + // AUTO: 3 min. The auto path fires mid-turn WITHOUT being asked, so the + // stall is unsolicited and must not be as generous as the manual one. + // 180s is exactly the top of the 60-180s band the writer documents for + // itself (checkpoint.ts:981), so it admits every writer that behaves as + // designed while refusing to hold an unrequested turn for the extra two + // minutes a watching human would tolerate. Abandoning the wait does not + // cancel the writer — it keeps running detached — so a bound that is too + // tight costs one degraded turn, not the checkpoint itself. + const MANUAL_WRITER_WAIT_MS = 300_000 + const AUTO_WRITER_WAIT_MS = 180_000 + + /** + * Outcome of a rebuild attempt that is allowed to WRITE a checkpoint first. + * + * Named for what the attempt DID, not for the state it started in: reading a + * call site, `writer-failed` has to say that a writer was started and awaited + * and only then gave up. An earlier name (`no-checkpoint`) described the entry + * condition instead, which made `if (attempt === "no-checkpoint") compact()` + * read as "no checkpoint, so compact immediately" — the writer attempt is + * invisible at the call site, and that is exactly how it was misread. + * + * - "rebuilt" a boundary was inserted; context is freed. + * - "writer-failed" there was no checkpoint, so a writer WAS STARTED AND + * AWAITED here (bounded by `writerWaitMs`), and it then + * failed / never ran / the bound expired. This is the ONLY + * state in which a caller may fall back to compaction. + * - "insert-failed" a checkpoint DOES exist but the boundary insert still + * refused (degraded, e.g. renderRebuildContext empty). + * Callers must report this honestly and must NOT compact. + * - "memory-write-off" nothing was attempted at all: memory writing is + * switched off, so a checkpoint cannot exist and cannot be + * produced. Callers may compact, and MUST say the switch is + * why — never that a writer failed. + */ + type RebuildAttempt = "rebuilt" | "writer-failed" | "insert-failed" | "memory-write-off" + + // The single place that decides whether a rebuild may degrade to + // compaction. Every caller — both auto context-overflow sites and the + // manual /rebuild command — goes through here, so the fallback condition + // is ONE condition rather than several lookalikes that can drift apart. + // + // Ordering matters: we try the on-disk checkpoint FIRST and only start a + // writer when there is no checkpoint at all. When a checkpoint already + // exists this deliberately does not block on an in-flight writer that is + // producing a fresher one — that separate, unchanged policy is documented + // on rebuildFromCheckpoint above and is NOT the justification for waiting + // here. Waiting here is justified only by the no-checkpoint case, where the + // alternative is `compaction.create`, which inserts a bare boundary marker + // and therefore drops all pre-boundary history with no summary at all. + const rebuildEnsuringCheckpoint = Effect.fn("SessionPrompt.rebuildEnsuringCheckpoint")(function* (input: { + sessionID: SessionID + msgs: MessageV2.WithParts[] + agentID?: string + agent: string + model: { providerID: string; id: string } + /** Upper bound on the writer wait; see {AUTO,MANUAL}_WRITER_WAIT_MS. */ + writerWaitMs: number + /** Run once, immediately before the wait begins, to explain the stall. */ + onWaitingForWriter?: Effect.Effect + }) { + // 0. Memory writing off → there is nothing to try. Bail out BEFORE any of + // the work below, because with the switch on every step of it is + // predetermined to be useless: no checkpoint can exist (the writer has + // never been allowed to write one), so `rebuildFromCheckpoint` fails, + // the hasCheckpoint/lastBoundary probes both come back empty, and + // `tryStartCheckpointWriter` short-circuits to "skipped" + // (checkpoint.ts:608) — after which `waitForWriter` still has to be + // awaited for a writer that was never started. That whole detour ends at + // the same compaction the guard reaches immediately, so it buys nothing + // and costs disk reads, DB reads and a wait. Reaching compaction + // immediately also means `onWaitingForWriter` is never run: telling the + // user we are waiting for a writer we are not going to start would be a + // lie. + // + // Default-enabled lives in isMemoryWriteEnabled (memory/write-gate.ts): + // only a literal `disable_write: true` takes this branch, so a missing + // or malformed value keeps the normal path rather than silently + // degrading every rebuild. + if (!isMemoryWriteEnabled(yield* config.get())) return "memory-write-off" as const + + // 1. Whatever is already on disk. + if (yield* rebuildFromCheckpoint(input).pipe(Effect.catch(() => Effect.succeed(false)))) + return "rebuilt" as const + + // 2. Distinguish "nothing to rebuild from" (may compact) from "checkpoint + // present but the insert failed" (must not compact). + // + // `hasCheckpoint` alone is NOT that distinction: it is a bare + // `Bun.file(...).exists()` (checkpoint.ts:1021), and + // `tryStartCheckpointWriter` scaffolds an EMPTY TEMPLATE at + // checkpoint.ts:650 *before* spawning the writer. Since + // `prune.fireCheckpoints` (prune.ts:289) runs immediately before the + // overflow check, "template on disk, watermark not yet written" is a + // NORMAL arrival state — and keying on the bare check classified it as + // `insert-failed`, which skipped the start-and-wait below entirely and + // silently defeated this whole helper. A usable checkpoint therefore + // requires the boundary too, which is exactly what + // `rebuildFromCheckpoint` needs (it reads `lastBoundary` at :411). + const hasCP = yield* checkpoint + .hasCheckpoint(input.sessionID) + .pipe(Effect.catch(() => Effect.succeed(false))) + const boundary = hasCP + ? yield* checkpoint.lastBoundary(input.sessionID).pipe(Effect.catch(() => Effect.succeed(undefined))) + : undefined + // Predicate note: this MUST be the same truthiness test that + // `rebuildFromCheckpoint` applies to the same value (`if (!boundary)` + // at :413), NOT `boundary !== undefined`. `lastBoundary` reads a + // nullable column and returned JS `null` for an unset watermark + // (checkpoint.ts:1422 — its declared `MessageID | undefined` was an + // unchecked cast), so `!== undefined` was true for EVERY session with a + // file on disk and this guard degenerated into the bare + // `hasCheckpoint` check it was written to replace. + if (hasCP && boundary) return "insert-failed" as const + + // 3. No checkpoint → produce one on the spot. Reentrancy: the + // isWriterRunning probe skips a redundant request, and + // tryStartCheckpointWriter is itself safe under concurrency — it + // returns "queued" instead of forking a second writer — so this can + // never start two writers for one session even when reached from + // successive runLoop iterations. + const writerRunning = yield* checkpoint + .isWriterRunning(input.sessionID) + .pipe(Effect.catch(() => Effect.succeed(false))) + if (!writerRunning) { + // promptOps is declared in TryStartCheckpointWriterInput but never read + // by the writer (it spawns as a subagent via spawnRef), so a stub + // suffices. + yield* checkpoint + .tryStartCheckpointWriter({ + sessionID: input.sessionID, + model: { providerID: input.model.providerID, modelID: input.model.id }, + promptOps: {} as never, + }) + .pipe(Effect.catch(() => Effect.succeed<"started" | "queued" | "skipped">("skipped"))) + } + + if (input.onWaitingForWriter) yield* input.onWaitingForWriter + + const writerOutcome = yield* checkpoint + .waitForWriter(input.sessionID) + .pipe( + Effect.timeout(input.writerWaitMs), + Effect.catch(() => Effect.succeed<"success" | "failure" | "no-writer">("failure")), + ) + if (writerOutcome !== "success") return "writer-failed" as const + + // 4. Writer wrote a checkpoint — rebuild from it. + if (yield* rebuildFromCheckpoint(input).pipe(Effect.catch(() => Effect.succeed(false)))) + return "rebuilt" as const + return "insert-failed" as const + }) + + /** + * What the user is told when a rebuild degrades to compaction *because the + * memory write switch is off* — not because anything failed. + * + * With `memory.disable_write` on, no checkpoint can ever exist for the + * session, so `rebuildEnsuringCheckpoint` returns "memory-write-off" on the + * spot and every overflow degrades to compaction. That is the switch working + * as asked, but the only trace of it was a log line ("memory writing + * disabled, skipping checkpoint") no user reads — and the one message that IS + * surfaced, `compactedInsteadMsg`, blames "the checkpoint writer failed", + * which reads like a bug worth reporting. So the two causes get two texts: + * this one names the switch. + * + * Single-language English, deliberately: this text is persisted into the + * session record, which the TUI, headless `run --format json`, and every + * other consuming client all read, and the engine does not know the reader's + * locale — the consuming client does, and already carries its own + * translations. So the engine emits one stable English string, exactly like + * its neighbours `compactedInsteadMsg` / `rebuildFailedMsg`, and + * localization stays with whoever renders it. + */ + const MEMORY_WRITE_OFF_FALLBACK_NOTICE = + "Memory writing is off, so no checkpoint can be written for this session and the context was compacted " + + "instead of rebuilt from one. Compaction is what runs whenever the context fills up: earlier turns leave " + + "the model's view without a summary, which can weaken continuity on long-running work. Nothing is broken " + + "and the session keeps working — to get checkpoint rebuilds back, set `memory.disable_write` to false in " + + "config." + + // Sessions that have already been told once, this process. + // + // The notice describes a CONFIG STATE, not an event: it says exactly the + // same thing at every boundary, and the automatic overflow path can reach + // that boundary many times in one long session. Persisting it once per + // session keeps a long run from stacking identical warnings in the + // transcript. A fresh process (a resumed session, a later `run`) announces + // it again — the user may never have seen the earlier one, and the switch + // still shapes that run — so this is deliberately in-memory rather than a + // durable "already warned" flag. + const memoryWriteOffNoticed = new Set() + + /** + * Surface the memory-write-off degradation, and return the notice text so a + * caller holding its own user-facing channel can reuse the same wording. + * + * Only ever called on the "memory-write-off" branch, so it does not re-check + * the switch: the attempt value already carries that fact, decided by the + * guard at the top of `rebuildEnsuringCheckpoint`. Re-reading the config here + * would let a mid-rebuild config change mis-attribute the cause, and would + * imply this notice is reachable from a genuine `writer-failed` — it is not. + * + * Persisting the notice as a part is what makes it outlive the status-line + * flash: a `session.status` busy→idle pair is in-memory and never reaches + * the headless event stream, so on `run --format json` the degradation was + * literally unobservable. `ignored: true` keeps the part out of the model's + * context (message-v2.ts:709) — a notice addressed to the user must never + * reach the model as something the user instructed — and `time.end` is what + * makes the CLI emit it (cli/cmd/run.ts:498). + */ + const noticeMemoryWriteOffFallback = Effect.fn("SessionPrompt.noticeMemoryWriteOffFallback")(function* ( + sessionID: SessionID, + ) { + if (memoryWriteOffNoticed.has(sessionID)) return MEMORY_WRITE_OFF_FALLBACK_NOTICE + memoryWriteOffNoticed.add(sessionID) + const msgs = yield* sessions.messages({ sessionID, agentID: "main" }) + // Anchor on the compaction boundary this fallback just inserted — the + // notice exists to explain that boundary. Falling back to the newest + // message keeps the notice visible if the boundary insert itself was + // swallowed (compaction.create runs under Effect.ignore at every site). + const anchor = msgs.findLast((m) => m.parts.some((p) => p.type === "compaction")) ?? msgs[msgs.length - 1] + if (!anchor) return MEMORY_WRITE_OFF_FALLBACK_NOTICE + const now = Date.now() + yield* sessions + .updatePart({ + id: PartID.ascending(), + messageID: anchor.info.id, + sessionID, + type: "text", + text: MEMORY_WRITE_OFF_FALLBACK_NOTICE, + synthetic: true, + ignored: true, + time: { start: now, end: now }, + }) + .pipe(Effect.ignore) + return MEMORY_WRITE_OFF_FALLBACK_NOTICE + }) + const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) { const ctx = yield* InstanceState.context const parts: PromptInput["parts"] = [{ type: "text", text: template }] @@ -656,6 +872,43 @@ export const layer = Layer.effect( const userMessage = input.messages.findLast((msg) => msg.info.role === "user") if (!userMessage) return input.messages + const runtimeAgent = { + ...input.agent, + permission: Agent.runtimePermission(input.agent, input.session.permission), + } + const skills = yield* sys.skills(runtimeAgent, input.model) + const catalogText = skills + ? ["", SKILL_CATALOG_REMINDER_MARKER, skills, ""].join("\n") + : undefined + const existingCatalogs = input.messages.flatMap((message) => + message.parts.flatMap((part) => + part.type === "text" && part.synthetic && !part.ignored && part.text.includes(SKILL_CATALOG_REMINDER_MARKER) + ? [{ message, part }] + : [], + ), + ) + const retainedCatalog = catalogText + ? existingCatalogs.findLast(({ part }) => part.text === catalogText) + : undefined + for (const existing of existingCatalogs) { + if (existing !== retainedCatalog) { + const updated = yield* sessions.updatePart({ ...existing.part, ignored: true }) + const index = existing.message.parts.findIndex((part) => part.id === existing.part.id) + if (index >= 0) existing.message.parts[index] = updated + } + } + if (catalogText && !retainedCatalog) { + const part = yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: userMessage.info.id, + sessionID: userMessage.info.sessionID, + type: "text", + text: catalogText, + synthetic: true, + }) + userMessage.parts.push(part) + } + // Search reminders apply only to eligible direct user sessions and models. // They advise the primary agent when to search; the model still decides whether to call. const reminder = skillSearchReminderForSession(input) @@ -718,16 +971,17 @@ ${entries} } // Sole injection point for skill bodies — free-text mentions ("/foo ... /bar") and slash-command - // invocations alike. Runs every step, so the guard keeps step 2+ from restacking step 1's blocks. - const alreadyWrapped = userMessage.parts.some( - (p) => p.type === "text" && p.text.startsWith(' 0) { + const loaded = new Set( + userMessage.parts.flatMap((part) => { + if (part.type !== "text" || !part.synthetic || part.ignored) return [] + return part.text.match(/^\n/)?.[1] ?? [] + }), + ) + const bodyText = userMessage.parts + .flatMap((p) => (p.type === "text" && !p.synthetic && !p.ignored ? [p.text] : [])) .join("\n") const stripped = bodyText .replace(/```[\s\S]*?```/g, " ") @@ -748,6 +1002,7 @@ ${entries} const toLoad = mentioned.slice(0, MAX_AUTOLOAD) const overflow = mentioned.slice(MAX_AUTOLOAD) for (const name of toLoad) { + if (loaded.has(name)) continue const info = allSkills.find((s) => s.name === name) if (!info) continue const part = yield* sessions.updatePart({ @@ -755,13 +1010,20 @@ ${entries} messageID: userMessage.info.id, sessionID: userMessage.info.sessionID, type: "text", - text: `\n${info.content}\n`, + text: `\n\n${info.content}\n\n`, synthetic: true, }) userMessage.parts.push(part) } - if (mentioned.length >= 2) { + const alreadyPlanned = userMessage.parts.some( + (part) => + part.type === "text" && + part.synthetic && + !part.ignored && + part.text.includes("The user has explicitly referenced multiple skills in this message:"), + ) + if (mentioned.length >= 2 && !alreadyPlanned) { const loadedHint = toLoad.length > 0 ? `SKILL.md for [${toLoad.join(", ")}] has been auto-loaded above.` : "" @@ -791,7 +1053,6 @@ Keep planning proportional to task complexity: for simple combinations, two or t userMessage.parts.push(part) } } - } } if (input.agent.name !== "plan" && assistantMessage?.info.agent === "plan") { @@ -937,6 +1198,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the messages: MessageV2.WithParts[] agentID?: string task_id?: string + mcpContext: MCP.TurnContext }) { using _ = log.time("resolveTools") const tools: Record = {} @@ -944,6 +1206,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the const loadedMcpTools = new Set() const mcpSearchEntries: McpToolSearchEntry[] = [] const mcpCatalog = { current: createMcpToolSearchCatalog([]) } + // exec's request-scoped MCP view. Holder object (same pattern as + // mcpCatalog above): referenced by the context() closure below, filled + // at the end of this pass once activeTools is settled. Travels through + // ctx.extra — NOT a module-level ref, which concurrent sessions in the + // same process would overwrite (request state must never live in a + // global; see toolWhitelist/mcpToolSearch precedent). + const execMcp: { current: Record } = { current: {} } const useMcpToolSearch = isMcpToolSearchEnabled( Flag.MIMOCODE_EXPERIMENTAL_MCP_TOOL_SEARCH, input.model.id, @@ -1013,6 +1282,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the promptOps, ...(whitelist ? { toolWhitelist: [...whitelist] } : {}), mcpToolSearch: mcpCatalog.current, + execMcp, }, agent: input.agent.name, actorID: input.agentID, @@ -1157,7 +1427,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the } const localToolNames = new Set(Object.keys(tools)) - const mcpTools = Object.entries(yield* mcp.tools()) + const mcpTools = Object.entries(yield* mcp.tools(input.mcpContext)) const agentToolAllowlist = input.agent.toolAllowlist ? new Set(input.agent.toolAllowlist) : undefined const disabledMcpTools = Permission.disabled( mcpTools.map(([key]) => key), @@ -1388,6 +1658,16 @@ NOTE: At any point in time through this workflow you should feel free to ask the } loadedMcpTools.forEach((name) => activeTools.add(name)) + // Fill exec's request-scoped MCP view (holder declared at the top of + // this pass, delivered via ctx.extra.execMcp): exactly the MCP tools + // active for this request. Under mcp_tool_search gating that means only + // search-loaded tools — exec must not bypass the discovery gate. + for (const [key] of mcpTools) { + if (!tools[key] || !activeTools.has(key)) continue + if (key === MCP_TOOL_SEARCH_ID) continue + execMcp.current[key] = tools[key] + } + return { tools, activeTools: [...activeTools].filter((name) => tools[name]), @@ -1787,17 +2067,40 @@ NOTE: At any point in time through this workflow you should feel free to ask the providerID: ProviderID, modelID: ModelID, sessionID: SessionID, + terminalUser?: MessageV2.User, ) { const exit = yield* provider.getModel(providerID, modelID).pipe(Effect.exit) if (Exit.isSuccess(exit)) return exit.value const err = Cause.squash(exit.cause) if (Provider.ModelNotFoundError.isInstance(err)) { const hint = err.data.suggestions?.length ? ` Did you mean: ${err.data.suggestions.join(", ")}?` : "" + const error = new NamedError.Unknown({ + message: `Model not found: ${err.data.providerID}/${err.data.modelID}.${hint}`, + }).toObject() + if (terminalUser) { + const ctx = yield* InstanceState.context + const now = Date.now() + yield* sessions.updateMessage({ + id: MessageID.ascending(), + sessionID, + parentID: terminalUser.id, + agentID: terminalUser.agentID, + role: "assistant", + mode: terminalUser.agent, + agent: terminalUser.agent, + variant: terminalUser.model.variant, + path: { cwd: ctx.directory, root: ctx.worktree }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID, + providerID, + time: { created: now, completed: now }, + error, + }) + } yield* bus.publish(Session.Event.Error, { sessionID, - error: new NamedError.Unknown({ - message: `Model not found: ${err.data.providerID}/${err.data.modelID}.${hint}`, - }).toObject(), + error, }) } return yield* Effect.failCause(exit.cause) @@ -2243,6 +2546,50 @@ NOTE: At any point in time through this workflow you should feel free to ask the } }) + // A tool part is persisted as `running` the moment the tool STARTS (so the TUI + // can stream progress) and is only rewritten by the abort finalizer in + // `SessionProcessor.cleanup`. Every exit path that skips that finalizer — process + // kill, crash, dev restart — leaves the row `running` forever, so the transcript + // permanently shows tool calls that will never finish. Nothing else repairs them: + // the model-message converter (`MessageV2.toModelMessages`) synthesizes an + // `output-error` for `pending`/`running` parts so the provider never sees a + // dangling `tool_use`, but it never touches the persisted row. + // + // SAFETY — a CURRENTLY EXECUTING tool part is also `running`, so an unscoped + // "rewrite every running row" sweep would corrupt live turns. Two guards, both + // required, both narrow: + // 1. session status must be `idle`. `busy`/`retry` mean an active runner owns + // this session, and a tool can only execute inside a runner's turn. This is + // the same gate `sweepOrphanAssistants`' caller relies on, kept INSIDE the + // function here because that is where the danger lives. + // 2. the MAIN slice only (`sessions.messages` default). `SessionProcessor` only + // publishes status for the main slice (`if (isMain) status.set(...)`), so a + // subagent slice can be executing tools while the session status reads + // `idle` — its parts are out of scope. + const sweepOrphanToolParts = Effect.fn("SessionPrompt.sweepOrphanToolParts")(function* (sessionID: SessionID) { + if ((yield* status.get(sessionID)).type !== "idle") return + for (const m of yield* sessions.messages({ sessionID })) { + if (m.info.role !== "assistant") continue + for (const part of m.parts) { + if (part.type !== "tool") continue + if (part.state.status !== "pending" && part.state.status !== "running") continue + yield* sessions + .updatePart({ ...part, state: MessageV2.abortedToolState(part.state) }) + .pipe( + Effect.catchCause((cause) => + elog.warn("orphan-tool-part-update-failed", { sessionID, partID: part.id, cause }), + ), + ) + yield* elog.info("orphan-tool-part-cleared", { + sessionID, + messageID: m.info.id, + partID: part.id, + tool: part.tool, + }) + } + } + }) + const prompt: (input: PromptInput) => Effect.Effect = Effect.fn("SessionPrompt.prompt")( function* (input: PromptInput) { const session = yield* sessions.get(input.sessionID) @@ -2253,6 +2600,20 @@ NOTE: At any point in time through this workflow you should feel free to ask the // so a fresh message is not rendered as stuck QUEUED behind it. const idle = (yield* status.get(input.sessionID)).type === "idle" yield* sweepOrphanAssistants(input.sessionID, idle) + // Same recovery point, same idleness argument: repair tool parts a killed + // process left stuck at `running`. Self-gated on idle (see the function). + // + // These two look mergeable into one message fetch. They are not: + // `sweepOrphanAssistants` reads EVERY slice (`agentID: "*"`) while this one + // reads the MAIN slice only, and that difference is load-bearing. + // `SessionProcessor` publishes status for the main slice alone, so a subagent + // slice can be mid-tool while the session status reads `idle` — scanning only + // main is what stops this sweep from rewriting a live subagent's `running` + // part. Sharing a fetch would mean taking the wider read and re-filtering + // here, which is precisely where that property would get lost. The cost is + // also smaller than it looks: this returns after one status lookup unless the + // session is genuinely idle. + yield* sweepOrphanToolParts(input.sessionID) } const message = yield* createUserMessage(input) yield* sessions.touch(input.sessionID) @@ -2324,20 +2685,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the // prose text instead of a structured tool_use). Local to runLoop so each // fresh user turn starts clean. let textToolCallRetries = 0 - // Consecutive empty/no-op tool-call steps in this turn. Counts steps - // where the model "called a tool" with empty/invalid input, or produced - // no valid tool part and no substantive output at all (see isEmptyStep). - // A single non-empty step resets it. Escalates soft (remind → replan) - // then hard-halts once it exceeds EMPTY_STEP_MAX_RECOVERY, mirroring the - // text-ngram ladder. Local to runLoop so a fresh user turn starts clean. - let emptyStepStreak = 0 - // Set true when a guard hard-halts the turn (currently the empty-step - // guard). A hard halt is terminal: it must break out immediately and - // NOT be re-entered by the goalGate ReAct gate, which would - // otherwise inject a fresh user turn and re-drive a still-degraded model - // into the same loop. - let hardHalt = false const resolvedAgentID = agentID ?? "main" + const mcpContext: MCP.TurnContext = { + sessionId: sessionID, + turnId: ulid(), + actorId: resolvedAgentID, + } // Tracks plugin-driven cancellation (session.pre OR any session.userQuery.pre) // so session.post reports outcome="cancelled" instead of "error". let cancelled = false @@ -2377,20 +2730,36 @@ NOTE: At any point in time through this workflow you should feel free to ask the : finalAsst ? sessionErrorText(finalAsst.error) : undefined - yield* plugin.trigger( - "session.post", - { - sessionID, - agentID: resolvedAgentID, - task_id, - outcome, - error, - finalText: finalAsst ? assistantFinalText(finalAsst, finalParts) : undefined, - assistantMessageID: finalAsst?.id, - trajectory: serializeTrajectoryMessages(sliceMsgs), - systemPrompt: lastSystemPrompt, - }, - {}, + const interrupted = Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause) + const lifecycleStatus: MCP.TurnStatus = + cancelled || interrupted ? "cancelled" : failed || finalIsError ? "error" : "completed" + yield* Effect.all( + [ + plugin + .trigger( + "session.post", + { + sessionID, + agentID: resolvedAgentID, + task_id, + outcome, + error, + finalText: finalAsst ? assistantFinalText(finalAsst, finalParts) : undefined, + assistantMessageID: finalAsst?.id, + trajectory: serializeTrajectoryMessages(sliceMsgs), + systemPrompt: lastSystemPrompt, + }, + {}, + ) + .pipe(Effect.ignore), + mcp + .clients() + .pipe( + Effect.flatMap((clients) => MCP.notifyTurnLifecycle(clients, mcpContext, lifecycleStatus)), + Effect.ignore, + ), + ], + { concurrency: "unbounded", discard: true }, ) }).pipe(Effect.ignore) @@ -2834,90 +3203,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the return true }) - // Empty/no-op tool-call loop guard. Symmetric across main and fork - // branches, mirroring handleTextRepeat's soft→hard ladder but keyed on - // *empty steps* (empty/invalid tool input, or a fully empty terminal) - // rather than repeated text n-grams — the gap TEXT_NGRAM and - // stepSignature both miss (an empty tool call has no text to match and - // is dropped by stepSignature's undefined path). - // - // Returns: - // "none" — the step was NOT empty; streak reset, caller continues - // normal classification. - // "continue" — empty step, still within the soft-nudge budget; a - // remind/replan reminder was injected, caller should loop. - // "halt" — empty streak exceeded EMPTY_STEP_MAX_RECOVERY; a - // terminal error was published, caller must break. - const handleEmptyStep = Effect.fn("SessionPrompt.handleEmptyStep")(function* (input: { - lastUser: MessageV2.User - assistant: MessageV2.Assistant - }) { - // Never mask a genuine terminal outcome as an "empty loop": an errored - // step, a content-filter/error finish, or an already-resolved - // structured/summary step must fall through to its own classifier - // handler (writeContentFilterError / writeModelError / final). Those - // are terminal safety/error events, not a spinning no-op. - if ( - input.assistant.error || - input.assistant.summary || - input.assistant.structured !== undefined || - input.assistant.finish === "content-filter" || - input.assistant.finish === "error" - ) { - return "none" as const - } - const parts = MessageV2.parts(input.assistant.id) - if (!isEmptyStep(parts)) { - emptyStepStreak = 0 - return "none" as const - } - emptyStepStreak++ - if (emptyStepStreak > EMPTY_STEP_MAX_RECOVERY) { - yield* slog.info("empty step: max recovery exceeded, terminating", { streak: emptyStepStreak }) - hardHalt = true - // Discard the empty turn from request history so it can neither - // strand the conversation on an assistant prefill nor poison later - // context (toModelMessages skips a message whose info.error is set). - if (!input.assistant.error) { - input.assistant.error = new NamedError.Unknown({ - message: `Empty tool call loop detected: ${emptyStepStreak} consecutive empty/no-op steps after ${EMPTY_STEP_MAX_RECOVERY} recovery attempts. Session terminated.`, - }).toObject() - yield* sessions.updateMessage(input.assistant) - } - yield* bus.publish(Session.Event.Error, { - sessionID, - error: new NamedError.Unknown({ - message: `Empty tool call loop detected: ${emptyStepStreak} consecutive empty/no-op steps after ${EMPTY_STEP_MAX_RECOVERY} recovery attempts. Session terminated.`, - }).toObject(), - }) - return "halt" as const - } - const recoveryText = - emptyStepStreak === 1 ? EMPTY_STEP_RECOVERY_REMIND : EMPTY_STEP_RECOVERY_REPLAN - const reentry = yield* sessions.updateMessage({ - id: MessageID.ascending(), - role: "user" as const, - sessionID, - agentID: input.lastUser.agentID, - agent: input.lastUser.agent, - model: input.lastUser.model, - tools: input.lastUser.tools, - format: input.lastUser.format, - time: { created: Date.now() }, - }) - yield* sessions.updatePart({ - id: PartID.ascending(), - messageID: reentry.id, - sessionID, - type: "text", - synthetic: true, - text: recoveryText, - } satisfies MessageV2.TextPart) - yield* slog.info("empty step: recovery injected", { streak: emptyStepStreak }) - return "continue" as const - }) - - // content-filter is terminal on first occurrence: re-sending the same // turn would just get filtered again, so there is no nudge / counter. // Write a user-visible error (rendered via the session.error toast) and @@ -2989,6 +3274,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the } if (!lastUser) throw new Error("No user message found in stream. This should never happen.") + const usageRecovered = + !!lastFinished && + msgs.some( + (msg) => + msg.info.id > lastFinished.id && + msg.parts.some((part) => part.type === "checkpoint" || part.type === "compaction"), + ) // Per-user-message active recall reminder. Once the session has // any memory artifacts (memory dir populated OR tasks recorded), @@ -3145,9 +3437,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the } } - const model = yield* getModel(lastUser.model.providerID, lastUser.model.modelID, sessionID) + const model = yield* getModel(lastUser.model.providerID, lastUser.model.modelID, sessionID, lastUser) lastModelForPrune = model - lastFinishedForPrune = lastFinished + lastFinishedForPrune = usageRecovered ? undefined : lastFinished const task = tasks.pop() if (task?.type === "subtask") { @@ -3180,64 +3472,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the continue } - // Memory flush nudge at high context pressure. - // - // Purpose: at high context fill, the session may soon checkpoint and - // discard old context, so remind the model to externalize durable - // learnings to memory BEFORE that happens. This is a *save-your-work* - // reminder, NOT a signal to wrap up. - // - // Two failure modes this guards against (both observed in prod): - // 1. Wording that reads as "we're about to reset — wind down" made - // models prematurely end their turn and hand control back to the - // user mid-task. The text below is explicit: persist memory, then - // KEEP GOING; do not end the turn. - // 2. Re-injecting the nudge on every user turn while pressure stays - // high turned a one-time heads-up into per-turn nagging. We now - // dedup across the recent conversation window, not just the - // current user message. - if (lastFinished && lastFinished.summary !== true && model) { - const cfg = yield* config.get() - const pressure = pressureLevel({ cfg, tokens: lastFinished.tokens, model }) - if (pressure >= 2) { - // De-bounce: nudge at most once per high-pressure episode (the - // window since the last checkpoint boundary). See - // nudgedSinceBoundary for why the boundary — not a fixed message - // count — is the right anchor. - const NUDGE_MARKER = "Context is filling up" - const boundaryID = yield* checkpoint - .lastBoundary(sessionID) - .pipe(Effect.catch(() => Effect.succeed(undefined))) - const alreadyNudged = nudgedSinceBoundary(msgs, boundaryID, NUDGE_MARKER) - const lastUserMsg = msgs.findLast((m) => m.info.role === "user") - if (lastUserMsg && !alreadyNudged) { - lastUserMsg.parts.push({ - id: PartID.ascending(), - messageID: lastUserMsg.info.id, - sessionID, - type: "text", - synthetic: true, - text: [ - "", - `Context is filling up (${pressure >= 3 ? ">85%" : ">70%"}).`, - "If you have important learnings or decisions from this session that are", - "not yet in memory, write them now (they may be summarized on the next", - "checkpoint). This is a save-your-work reminder only.", - "IMPORTANT: After writing to memory, CONTINUE with the current task in the", - "same turn. Do NOT stop, wrap up, or hand control back to the user because", - "of this reminder — only finish when the actual work is done.", - "", - ].join("\n"), - }) - } - } - } - // Repeated-step nudge: if the last REPEATED_STEP_THRESHOLD finished // assistant steps made an identical tool call, the model is likely - // stuck looping. Inject a reminder on the last user message asking it - // to change approach. Mirrors the memory-flush nudge above (synthetic - // text part, deduped per build). + // stuck looping. Inject a synthetic reminder on the last user message + // asking it to change approach, deduped per build. if (lastFinished) { const recentSignatures: string[] = [] for (let i = msgs.length - 1; i >= 0 && recentSignatures.length < REPEATED_STEP_THRESHOLD; i--) { @@ -3289,10 +3527,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the agent?.native === true && agent?.hidden === true // Fire background checkpoint writers for any newly-crossed thresholds - // based on the latest completed assistant message's tokens. Must run - // BEFORE the overflow/maxThreshold check below so maxCrossed flag is - // set in time to trigger rebuild on this same iteration. - if (!skipOverflowCheck && !isBoundedComputation && lastFinished && lastFinished.tokens) { + // based on the latest completed assistant message's tokens. These + // thresholds only keep the checkpoint fresh; `overflowCheck` below is + // the single trigger for rebuilding the active context. + if (!skipOverflowCheck && !usageRecovered && !isBoundedComputation && lastFinished && lastFinished.tokens) { const fireOps = yield* ops() yield* prune .fireCheckpoints({ @@ -3307,11 +3545,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the if ( !skipOverflowCheck && + !usageRecovered && !isBoundedComputation && lastFinished && lastFinished.summary !== true && - (overflowCheck({ cfg: yield* config.get(), tokens: lastFinished.tokens, model }) || - (yield* prune.maxThresholdCrossed(sessionID))) + overflowCheck({ cfg: yield* config.get(), tokens: lastFinished.tokens, model }) ) { // Subagent overflow → per-actor compaction (lossy LLM summarization // scoped to the actor's (sessionID, agent_id) slice). Subagents @@ -3340,34 +3578,65 @@ NOTE: At any point in time through this workflow you should feel free to ask the // Main-agent overflow: insert a checkpoint boundary marker (never // deletes DB messages) so the next iteration rebuilds from the - // freshest checkpoint. Shared with the manual `/rebuild` command via - // rebuildFromCheckpoint so logic/boundary conditions can't drift. - // Falls back to compaction only when no boundary can be produced. - const inserted = yield* rebuildFromCheckpoint({ + // freshest checkpoint. When NO checkpoint exists yet this now starts + // a writer and waits for it (bounded) rather than degrading + // immediately — the same on-the-spot behaviour the manual /rebuild + // command has, via the shared rebuildEnsuringCheckpoint helper so + // logic/boundary conditions can't drift. + const attempt: RebuildAttempt = yield* rebuildEnsuringCheckpoint({ sessionID, msgs, agentID: lastUser.agentID, agent: lastUser.agent, model: { providerID: model.providerID, id: model.id }, + writerWaitMs: AUTO_WRITER_WAIT_MS, + // The turn is mid-flight, so explain the stall: without this the + // TUI would sit on a bare spinner for minutes with no reason. + onWaitingForWriter: status + .set(sessionID, { type: "busy", message: "Writing checkpoint\u2026" }) + .pipe(Effect.catch(() => Effect.void)), }) - if (inserted) { + if (attempt === "rebuilt") { skipOverflowCheck = true continue } - // F39: no checkpoint — fall back to compaction (LLM-driven lossy summary). - // Better than mechanical trim: preserves semantic content via summary. - yield* compaction - .create({ - sessionID, - agent: lastUser.agent, - model: { providerID: model.providerID, modelID: model.id }, - auto: true, - agentID: lastUser.agentID, - }) - .pipe(Effect.ignore) - skipOverflowCheck = true - continue + // A writer was started and awaited above (AUTO_WRITER_WAIT_MS) and + // still produced nothing — or memory writing is off, so nothing was + // attempted at all. Either way this is the ONE state that may compact. + if (attempt === "writer-failed" || attempt === "memory-write-off") { + // THE single compaction fallback: no checkpoint existed AND the + // writer failed / never ran / the bound expired / was never + // allowed to run at all. Note this is a bare boundary insert, not + // an LLM summary — everything before it is dropped unsummarized + // (compaction.ts:499, message-v2.ts:1037), which is exactly why + // we try to write a checkpoint first whenever we are allowed to. + yield* compaction + .create({ + sessionID, + agent: lastUser.agent, + model: { providerID: model.providerID, modelID: model.id }, + auto: true, + agentID: lastUser.agentID, + }) + .pipe(Effect.ignore) + // Was the switch the reason no checkpoint existed? Then say so — + // this path is otherwise completely silent (no status message at + // all mid-turn), which is how "compaction instead of rebuild" + // became invisible to the user. A genuine writer failure keeps its + // existing behaviour untouched. + if (attempt === "memory-write-off") + yield* noticeMemoryWriteOffFallback(sessionID).pipe(Effect.ignore) + skipOverflowCheck = true + continue + } + + // "insert-failed": a checkpoint DOES exist, so compaction is not + // permitted here — it would amputate history we hold a usable + // checkpoint for. Nothing freed context, so do NOT `continue` into + // an identical overflow check; fall through and let the model call + // proceed. The provider-signalled overflow handler below is the + // backstop if the request is actually rejected. } skipOverflowCheck = false @@ -3422,6 +3691,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the messages: msgs, agentID: lastUser.agentID, task_id, + mcpContext, }) const tools = resolvedTools.tools const activeTools = resolvedTools.activeTools @@ -3624,14 +3894,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the return "break" as const } - // Empty/no-op tool-call loop guard (fork branch). Intercept before - // classify would `continue` an empty tool-calls step: soft-nudge - // within budget, hard-halt once exceeded. A non-empty step returns - // "none" and falls through to normal classification. - const forkEmptyStep = yield* handleEmptyStep({ lastUser, assistant: handle.message }) - if (forkEmptyStep === "halt") return "break" as const - if (forkEmptyStep === "continue") return "continue" as const - const forkClassification = classifyAssistantStep({ phase: "after-process", lastUser, @@ -3683,12 +3945,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the agentID: lastUser.agentID, }) .pipe(Effect.ignore) + skipOverflowCheck = true } return "continue" as const } - const [skills, env, instructions] = yield* Effect.all([ - sys.skills(agent, model), + const [env, instructions] = yield* Effect.all([ sys.environment(model, session.time.created), instruction.system().pipe(Effect.orDie), ]) @@ -3704,7 +3966,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the } const additions = [ ...env, - ...(skills ? [skills] : []), ...instructions.content, ...(format.type === "json_schema" ? [STRUCTURED_OUTPUT_SYSTEM_PROMPT] : []), ] @@ -3851,14 +4112,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the return "break" as const } - // Empty/no-op tool-call loop guard (main branch). Intercept before - // classify would `continue` an empty tool-calls step: soft-nudge - // within budget, hard-halt once exceeded. A non-empty step returns - // "none" and falls through to normal classification. - const emptyStep = yield* handleEmptyStep({ lastUser, assistant: handle.message }) - if (emptyStep === "halt") return "break" as const - if (emptyStep === "continue") return "continue" as const - const classification = classifyAssistantStep({ phase: "after-process", lastUser, @@ -3913,34 +4166,50 @@ NOTE: At any point in time through this workflow you should feel free to ask the agentID: lastUser.agentID, }) .pipe(Effect.ignore) + skipOverflowCheck = true return "continue" as const } // Main-agent provider-signalled overflow: prefer rebuild over - // compaction. Shared with the manual `/rebuild` command via - // rebuildFromCheckpoint (does not block on the writer; uses the - // on-disk checkpoint). Fall back to compaction only when no - // boundary can be produced. - const inserted2 = yield* rebuildFromCheckpoint({ + // compaction, via the same shared rebuildEnsuringCheckpoint helper + // the token-threshold path and manual /rebuild use — so the + // compaction fallback stays ONE condition, not three lookalikes. + const attempt2: RebuildAttempt = yield* rebuildEnsuringCheckpoint({ sessionID, msgs, agentID: lastUser.agentID, agent: lastUser.agent, model: { providerID: model.providerID, id: model.id }, + writerWaitMs: AUTO_WRITER_WAIT_MS, + onWaitingForWriter: status + .set(sessionID, { type: "busy", message: "Writing checkpoint\u2026" }) + .pipe(Effect.catch(() => Effect.void)), }) - if (inserted2) return "continue" as const + if (attempt2 === "rebuilt") { + skipOverflowCheck = true + return "continue" as const + } - // F39: no checkpoint — fall back to compaction (LLM-driven lossy summary). - yield* compaction - .create({ - sessionID, - agent: lastUser.agent, - model: { providerID: model.providerID, modelID: model.id }, - auto: true, - overflow: true, - agentID: lastUser.agentID, - }) - .pipe(Effect.ignore) + // Same as above: the writer ran and failed — not "no checkpoint" — + // or memory writing is off and nothing was attempted. + if (attempt2 === "writer-failed" || attempt2 === "memory-write-off") { + // THE single compaction fallback (see the token-threshold site). + yield* compaction + .create({ + sessionID, + agent: lastUser.agent, + model: { providerID: model.providerID, modelID: model.id }, + auto: true, + overflow: true, + agentID: lastUser.agentID, + }) + .pipe(Effect.ignore) + // Same reason-split as the token-threshold site. + if (attempt2 === "memory-write-off") + yield* noticeMemoryWriteOffFallback(sessionID).pipe(Effect.ignore) + skipOverflowCheck = true + } + // "insert-failed" → a checkpoint exists; must not compact. } return "continue" as const }).pipe(Effect.ensuring(instruction.clear(handle.message.id))) @@ -4006,9 +4275,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the } if (outcome === "break") { - // A hard halt is terminal — skip the ReAct re-entry gates so a - // degraded model can't be re-driven into the same empty loop. - if (hardHalt) break if (yield* goalGate(lastUser)) continue break } @@ -4135,41 +4401,144 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* goal.set(input.sessionID, condition) } - // /rebuild — manually rebuild the conversation context now, from the - // latest checkpoint. Reuses the SAME rebuildFromCheckpoint step as the - // automatic overflow path (identical logic + boundary conditions), so a - // user-triggered rebuild behaves exactly like an auto one: it inserts a - // checkpoint boundary at the watermark (recent messages after it are kept - // verbatim; earlier ones collapse to the checkpoint summary on the next - // turn). If no usable checkpoint exists yet, tell the user rather than - // silently doing nothing — the first checkpoint has to be produced by - // normal turns before there is anything to rebuild from. + // /rebuild — manually rebuild the conversation context ON THE SPOT, + // from the latest checkpoint. Implements the 3-case checkpoint-freshness + // semantics: + // 1. Usable checkpoint exists, no writer running → rebuild immediately. + // 2. No usable checkpoint → start a writer and wait for it, then rebuild. + // 3. Checkpoint exists + writer in-flight → wait (with timeout), rebuild + // with the fresher checkpoint if it arrives, else fall back to existing. + // Cases 1-3 live in the shared rebuildEnsuringCheckpoint helper, which the + // auto context-overflow paths use too, so the manual and automatic + // behaviours cannot drift and there is exactly ONE compaction fallback + // condition (no checkpoint AND the writer failed) in this file. + // + // Manual /rebuild mirrors the AUTO rebuild/compaction path exactly: it + // inserts the legitimate rebuild BOUNDARY (a role:"user" message carrying + // a `checkpoint` part, via rebuildFromCheckpoint → insertRebuildBoundary) + // and then lets the session settle — WITHOUT fabricating a second, + // standalone user turn. The auto path (~prompt.ts:3205/3778) `continue`s + // the runLoop because it has a PENDING user message to answer; a manual + // /rebuild is a user-initiated maintenance action with NO pending + // question, so after inserting the boundary it simply returns to idle + // (no model turn, no auto-reply). + // + // The outcome ("context rebuilt" / "compacted instead because the writer + // failed" / "checkpoint written but rebuild failed") is surfaced to the + // user through the SessionStatus / Bus status channel — the same + // busy-status mechanism that drives "Rebuilding context…" / + // "Writing checkpoint…" — NOT through a persisted synthetic user message. + // The busy status is set BEFORE any work so the TUI spinner lights up + // immediately; because the runLoop is never entered, its onIdle won't + // clear busy status, so every return path clears idle explicitly. if (input.command === Command.Default.REBUILD) { const msgs = yield* sessions.messages({ sessionID: input.sessionID, agentID: "main" }) const lastUser = msgs.findLast((m) => m.info.role === "user") const model = yield* lastModel(input.sessionID) - const inserted = yield* rebuildFromCheckpoint({ + + // Emit the terminal outcome on the status channel, then return to idle. + // Returns the message the handler should hand back (never a fabricated + // user turn): the freshly-inserted boundary on success, else the + // existing last user message so callers still receive a WithParts. + const settle = Effect.fn("SessionPrompt.rebuild.settle")(function* (message: string) { + yield* status.set(input.sessionID, { type: "busy", message }).pipe(Effect.catch(() => Effect.void)) + yield* status.set(input.sessionID, { type: "idle" }).pipe(Effect.catch(() => Effect.void)) + }) + const compactedInsteadMsg = + "No checkpoint could be written (the checkpoint writer failed), so the context was compacted instead — earlier messages were dropped rather than rebuilt from a checkpoint." + const rebuildFailedMsg = + "A checkpoint was written but the context could not be rebuilt from it. Context is unchanged and nothing was compacted — retry /rebuild, or report this if it repeats." + const rebuiltMsg = + "Context rebuilt from the latest checkpoint. Recent messages are preserved; earlier context is now summarized." + + // Set busy status so the TUI shows a spinner while we wait on the + // writer (cases 2/3) or assemble context (case 1). + yield* status.set(input.sessionID, { type: "busy", message: "Rebuilding context\u2026" }).pipe( + Effect.catch(() => Effect.void), + ) + + // Cases 1-3 all run through the shared rebuildEnsuringCheckpoint helper: + // it rebuilds from an existing checkpoint, or — on a cold session — spawns + // a writer, waits for it (bounded), and rebuilds from the fresh + // checkpoint. That is the user-decided semantics: /rebuild on a cold + // session produces the first checkpoint on the spot rather than deferring. + const attempt: RebuildAttempt = yield* rebuildEnsuringCheckpoint({ sessionID: input.sessionID, msgs, agentID: lastUser?.info.agentID ?? "main", agent: agentName, model: { providerID: model.providerID, id: model.modelID }, - }).pipe(Effect.catch(() => Effect.succeed(false))) - return yield* prompt({ - sessionID: input.sessionID, - messageID: input.messageID, - agent: agentName, - parts: [ - { - type: "text", - text: inserted - ? "Context rebuilt from the latest checkpoint. Recent messages are preserved; earlier context is now summarized." - : "No checkpoint is available to rebuild from yet — continue the conversation and a checkpoint will be written automatically.", - synthetic: true, - }, - ], - noReply: true, - }) + writerWaitMs: MANUAL_WRITER_WAIT_MS, + onWaitingForWriter: status + .set(input.sessionID, { type: "busy", message: "Writing checkpoint\u2026" }) + .pipe(Effect.catch(() => Effect.void)), + }).pipe(Effect.catch(() => Effect.succeed("insert-failed" as const))) + + // A writer was started and awaited above (MANUAL_WRITER_WAIT_MS) and + // still produced nothing — or memory writing is off, so no writer was + // started at all. Only in those two states may /rebuild degrade to + // compaction. + if (attempt === "writer-failed" || attempt === "memory-write-off") { + // No checkpoint AND the writer genuinely failed / never ran / the bound + // expired / was never allowed to run — the ONE fallback condition, + // shared with the auto overflow paths. An earlier revision of this + // branch deliberately did NOT compact here, reasoning that /rebuild + // means "rebuild from a checkpoint" so substituting a lossy summary + // would misreport what happened. The user overruled that tradeoff: if + // the writer genuinely failed, a truncating compaction beats doing + // nothing. We keep the report honest by naming the substitution on the + // status channel instead of silently swapping the mechanism, and — per + // the branch's existing noReply decision (3244ca732) — fabricate + // neither an assistant reply nor a synthetic user turn. + yield* compaction + .create({ + sessionID: input.sessionID, + agent: agentName, + model: { providerID: model.providerID, modelID: model.modelID }, + // Not user-requested: the user asked for a rebuild, the system + // chose this degradation. + auto: true, + agentID: lastUser?.info.agentID ?? "main", + }) + .pipe(Effect.ignore) + // The two causes are very different and the user has to be able to + // tell them apart: a writer that genuinely broke (report it) versus the + // memory write switch being off (expected — you turned it off). When + // it's the switch, its notice replaces `compactedInsteadMsg`, whose + // "the checkpoint writer failed" would be a false alarm here. + const msg = + attempt === "memory-write-off" + ? yield* noticeMemoryWriteOffFallback(input.sessionID).pipe( + Effect.catch(() => Effect.succeed(MEMORY_WRITE_OFF_FALLBACK_NOTICE)), + ) + : compactedInsteadMsg + yield* settle(msg) + return lastUser ?? msgs[msgs.length - 1]! + } + + if (attempt === "insert-failed") { + // A checkpoint EXISTS but the boundary insert refused (e.g. + // renderRebuildContext returned empty — degraded state). NOT a + // fallback case: compacting would drop history that a usable + // checkpoint was available for. Report the degraded state accurately + // and return to idle. + yield* settle(rebuildFailedMsg) + return lastUser ?? msgs[msgs.length - 1]! + } + + // Boundary inserted (Step A — the shared, correct mechanism). A MANUAL + // /rebuild is a user action whose whole intent is to free/rebuild the + // context: the user asked no question, so the model must NOT reply and + // NO second user turn is fabricated. We surface the "context rebuilt" + // outcome on the status channel and return the boundary message itself + // (the newest role:"user" message carrying a checkpoint part), then go + // idle. The runLoop is never entered — mirroring the transparent + // boundary insertion the auto/compaction paths perform, minus their + // pending-message `continue`. + yield* settle(rebuiltMsg) + const after = yield* sessions.messages({ sessionID: input.sessionID, agentID: "main" }) + const boundaryMessage = after.findLast((m) => m.parts.some((p) => p.type === "checkpoint")) + return boundaryMessage ?? lastUser ?? after[after.length - 1]! } const raw = input.arguments.match(argsRegex) ?? [] @@ -4303,6 +4672,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the command, resolvePromptParts, sweepOrphanAssistants, + sweepOrphanToolParts, predict, }) sessionPromptRef.current = { loop: impl.loop } @@ -4322,7 +4692,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the }), ) -export const defaultLayer = Layer.suspend(() => +/** App composition variant with MCP supplied by the process-wide layer. */ +export const appLayer = Layer.suspend(() => layer.pipe( Layer.provide(SessionRunState.defaultLayer), Layer.provide(SessionStatus.defaultLayer), @@ -4330,9 +4701,8 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(SessionCheckpoint.defaultLayer), Layer.provide(SessionCompaction.defaultLayer), Layer.provide(SessionProcessor.defaultLayer), - Layer.provide(Command.defaultLayer), + Layer.provide(Command.appLayer), Layer.provide(Permission.defaultLayer), - Layer.provide(MCP.defaultLayer), Layer.provide(LSP.defaultLayer), Layer.provide(ToolRegistry.defaultLayer), Layer.provide(Truncate.defaultLayer), @@ -4360,6 +4730,8 @@ export const defaultLayer = Layer.suspend(() => ), ), ) + +export const defaultLayer = appLayer.pipe(Layer.provide(MCP.defaultLayer)) /** * Returns true when at least one resolved user-message part carries substantive * content that will survive the send-side filter (message-v2.ts). Used by diff --git a/packages/opencode/src/session/prompt/anthropic.txt b/packages/opencode/src/session/prompt/anthropic.txt index 9315a59d8..1c65914bd 100644 --- a/packages/opencode/src/session/prompt/anthropic.txt +++ b/packages/opencode/src/session/prompt/anthropic.txt @@ -1,154 +1,39 @@ -You are MiMoCode, the best coding agent on the planet. - -You are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user. - -IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files. +You are an interactive agent that helps users with software engineering tasks. IMPORTANT: Assist with authorized security testing, defensive security, CTF challenges, and educational contexts. Refuse requests for destructive techniques, DoS attacks, mass targeting, supply chain compromise, or detection evasion for malicious purposes. Dual-use security tools (C2 frameworks, credential testing, exploit development) require clear authorization context: pentesting engagements, CTF competitions, security research, or defensive use cases. -When the user directly asks about MiMoCode (eg. "can MiMoCode do...", "does MiMoCode have..."), or asks in second person (eg. "are you able...", "can you do..."), or asks how to use a specific MiMoCode feature (eg. implement a hook, write a slash command, or install an MCP server), use the WebFetch tool to gather information to answer the question from MiMoCode docs. The list of available docs is available at https://MiMoCode.ai/docs - -# Tone and style -- Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked. -- Your output will be displayed on a command line interface. Your responses should be short and concise. You can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification. -- Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session. -- NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one. This includes markdown files. - -# Text output -Assume users can't see most tool calls — only your text output. Before your first tool call, state in one sentence what you're about to do. While working, give short updates at key moments: when you find something, when you change direction, or when you hit a blocker. Brief is good — silent is not. One sentence per update is almost always enough. - -Don't narrate your internal deliberation. User-facing text should be relevant communication to the user, not a running commentary on your thought process. State results and decisions directly. - -End-of-turn summary: one or two sentences. What changed and what's next. Nothing else. - -In code: default to writing no comments. Never write multi-paragraph docstrings or multi-line comment blocks — one short line max. Don't create planning, decision, or analysis documents unless the user asks for them — work from conversation context, not intermediate files. - -# Professional objectivity -Prioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. It is best for the user if MiMoCode honestly applies the same rigorous standards to all ideas and disagrees when necessary, even if it may not be what the user wants to hear. Objective guidance and respectful correction are more valuable than false agreement. Whenever there is uncertainty, it's best to investigate to find the truth first rather than instinctively confirming the user's beliefs. - -# Task Management -You have access to the `task` tool to manage and plan work. Use it frequently to track progress and give the user visibility into what you are doing. -It is also EXTREMELY helpful for planning work, and for breaking down larger complex tasks into smaller steps. If you do not use it when planning, you may forget to do important work - and that is unacceptable. - -It is critical that you mark a task done as soon as you are finished with it. Do not batch up multiple tasks before marking them done. - -Examples: - - -user: Run the build and fix any type errors -assistant: I'm going to use the `task` tool to register the work items: -- task create "Run the build" -- task create "Fix any type errors" - -I'm now going to run the build using Bash. - -Looks like I found 10 type errors. I'm going to use the `task` tool to register 10 work items, one per error. - -I'll mark the first one in_progress with `task start T1`. - -Let me start working on the first item... - -The first item has been fixed, let me mark it done with `task done T1`, and move on to the second item... -.. -.. - -In the above example, the assistant completes all the work, including the 10 error fixes and running the build and fixing all errors. - - -user: Help me write a new feature that allows users to track their usage metrics and export them to various formats -assistant: I'll help you implement a usage metrics tracking and export feature. Let me first use the `task` tool to plan this work. -Registering the following tasks: -- task create "Research existing metrics tracking in the codebase" -- task create "Design the metrics collection system" -- task create "Implement core metrics tracking functionality" -- task create "Create export functionality for different formats" - -Let me start by researching the existing codebase to understand what metrics we might already be tracking and how we can build on that. - -I'm going to search for any existing metrics or telemetry code in the project. - -I've found some existing telemetry code. Let me mark the first task in_progress with `task start T1` and start designing our metrics tracking system based on what I've learned... - -[Assistant continues implementing the feature step by step, marking tasks in_progress and done as they go] - - - -# Doing tasks -The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended: - -- Use the `task` tool to plan the work if required - -For exploratory questions ("what could we do about X?", "how should we approach this?", "what do you think?"), respond in 2-3 sentences with a recommendation and the main tradeoff. Present it as something the user can redirect, not a decided plan. Don't implement until the user agrees. - -You are highly capable and often allow users to complete ambitious tasks that would otherwise be too complex or take too long. You should defer to user judgement about whether a task is too large to attempt. - -# Code quality -- Don't add features, refactor, or introduce abstractions beyond what the task requires. A bug fix doesn't need surrounding cleanup; a one-shot operation doesn't need a helper. Three similar lines is better than a premature abstraction. -- Don't add error handling, fallbacks, or validation for scenarios that can't happen. Trust internal code and framework guarantees. Only validate at system boundaries (user input, external APIs). Don't use feature flags or backwards-compatibility shims when you can just change the code. -- Avoid backwards-compatibility hacks like renaming unused _vars, re-exporting types, adding // removed comments for removed code. If something is unused, delete it completely. - -- Tool results and user messages may include tags. tags contain useful information and reminders. They are automatically added by the system, and bear no direct relation to the specific tool results or user messages in which they appear. - -# Executing actions with care - -Carefully consider the reversibility and blast radius of actions. Generally you can freely take local, reversible actions like editing files or running tests. But for actions that are hard to reverse, affect shared systems beyond your local environment, or could otherwise be risky or destructive, check with the user before proceeding. The cost of pausing to confirm is low, while the cost of an unwanted action (lost work, unintended messages sent, deleted branches) can be very high. - -A user approving an action once does NOT mean they approve it in all contexts. Authorization stands for the scope specified, not beyond. Match the scope of your actions to what was actually requested. - -Examples of risky actions that warrant user confirmation: -- Destructive operations: deleting files/branches, dropping database tables, rm -rf, overwriting uncommitted changes -- Hard-to-reverse operations: force-pushing, git reset --hard, amending published commits, removing packages -- Actions visible to others: pushing code, creating/closing PRs or issues, sending messages to external services +# Harness + - Text you output outside of tool use is displayed to the user as Github-flavored markdown in a terminal. + - Tools run behind a user-selected permission mode; a denied call means the user declined it — adjust, don't retry verbatim. + - The system may send updates, reminders, or modifications to rules via mid-conversation system turns. These are system-controlled, unlike function results. Hooks may intercept tool calls; treat hook output as user feedback. + - Prefer the dedicated file/search tools over shell commands when one fits. Independent tool calls can run in parallel in one response. + - Reference code as `file_path:line_number` — it's clickable. -When you encounter an obstacle, do not use destructive actions as a shortcut. Identify root causes rather than bypassing safety checks (e.g. --no-verify). If you discover unexpected state like unfamiliar files, branches, or configuration, investigate before deleting or overwriting — it may represent the user's in-progress work. +Write code that reads like the surrounding code: match its comment density, naming, and idiom. -Report outcomes faithfully: if tests fail, say so with the output; if a step was skipped, say that; when something is done and verified, state it plainly without hedging. Before deleting or overwriting, look at the target — if what you find contradicts how it was described, or you didn't create it, surface that instead of proceeding. +When you use a pronoun for someone — the user or anyone else you mention — and their pronouns haven't been stated, use they/them. A name doesn't tell you someone's pronouns; a wrong guess misgenders a real person in a way the neutral default never does, so never infer pronouns from a name. This applies to all user-visible text, including visible thinking. -# Git safety +For actions that are hard to reverse or outward-facing, confirm first unless durably authorized or explicitly told to proceed without asking; approval in one context doesn't extend to the next. Sending content to an external service publishes it; it may be cached or indexed even if later deleted. Before deleting or overwriting, look at the target. Report outcomes faithfully: if tests fail, say so with the output; if a step was skipped, say that; when something is done and verified, state it plainly without hedging. -- NEVER update the git config -- CRITICAL: Always create NEW commits rather than amending, unless the user explicitly requests. When a pre-commit hook fails, the commit did NOT happen — so --amend would modify the PREVIOUS commit, destroying prior work. After hook failure: fix the issue, re-stage, and create a NEW commit. -- When staging files, prefer adding specific files by name rather than "git add -A" or "git add .", which can accidentally include sensitive files (.env, credentials) or large binaries. -- Never use the -uall flag with git status as it can cause memory issues on large repos. -- Never use git commands with the -i flag (git rebase -i, git add -i) since they require interactive input which is not supported. -- Before running destructive operations (e.g., git reset --hard, git push --force, git checkout --), consider whether there is a safer alternative. Only use destructive operations when truly the best approach. -- NEVER commit changes unless the user explicitly asks you to. +# Session-specific guidance + - If you need the user to run a shell command themselves (e.g., an interactive login), suggest they type `! ` in the prompt — the `!` prefix runs the command in this session so its output lands directly in the conversation. + - When the user types `/`, invoke it via Skill. Only use skills listed in the user-invocable skills section — don't guess. -# Avoid unnecessary sleep commands -- Do not sleep between commands that can run immediately — just run them. -- If your command is long running, use run_in_background. No sleep needed. -- Do not retry failing commands in a sleep loop — diagnose the root cause. -- If waiting for a background task, you will be notified when it completes — do not poll. -- If you must sleep, keep the duration short to avoid blocking the user. +# Context management +When the conversation grows long, some or all of the current context is summarized; the summary, along with any remaining unsummarized context, is provided in the next context window so work can continue — you don't need to wrap up early or hand off mid-task. -# Tool usage policy -- When doing file search, prefer to use the actor tool in order to reduce context usage. -- You should proactively use the actor tool with specialized agents when the task at hand matches the agent's description. +# Delivering work +Do ordinary work as asked, acting on the actual request rather than on speculation about what lies behind it. The requested scope is the deliverable — don't quietly narrow, widen, or transform it. Interpret ambiguity the way a careful colleague would: make routine judgment calls yourself, and check in only when different readings would lead to materially different work. If you find a real problem with the task as specified, state the concern in a sentence or two, then keep building: deliver the complete work under explicitly stated assumptions, flagging important factors for the user. Finish the whole task, not just easy parts — report completion only when fully done. If part of the scope turns out to be blocked or problematic, finish every other part in full and say explicitly what you left out and why — scaling the work down is the user's call, not yours. Stop short of actions or changes clearly beyond what the user's ask implies. -- When WebFetch returns a message about a redirect to a different host, you should immediately make a new WebFetch request with the redirect URL provided in the response. -- You can call multiple tools in a single response. If you intend to call multiple tools and there are no dependencies between them, make all independent tool calls in parallel. Maximize use of parallel tool calls where possible to increase efficiency. However, if some tool calls depend on previous calls to inform dependent values, do NOT call these tools in parallel and instead call them sequentially. For instance, if one operation must complete before another starts, run these operations sequentially instead. Never use placeholders or guess missing parameters in tool calls. -- If the user specifies that they want you to run tools "in parallel", you MUST send a single message with multiple tool use content blocks. For example, if you need to launch multiple agents in parallel, send a single message with multiple actor tool calls. -- Use specialized tools instead of bash commands when possible, as this provides a better user experience. For file operations, use dedicated tools: Read for reading files instead of cat/head/tail, Edit for editing instead of sed/awk, and Write for creating files instead of cat with heredoc or echo redirection. Reserve bash tools exclusively for actual system commands and terminal operations that require shell execution. NEVER use bash echo or other command-line tools to communicate thoughts, explanations, or instructions to the user. Output all communication directly in your response text instead. -- VERY IMPORTANT: When exploring the codebase to gather context or to answer a question that is not a needle query for a specific file/class/function, it is CRITICAL that you use the actor tool instead of running search commands directly. - -user: Where are errors from the client handled? -assistant: [Uses the actor tool to find the files that handle client errors instead of using Glob or Grep directly] - - -user: What is the codebase structure? -assistant: [Uses the actor tool] - +If you find an uncertainty mid-task, first do everything that doesn't depend on the answer; for what does, state your assumption or ask your question to the user at the right time. Reserve blocking questions — stopping with nothing delivered until the user answers — for cases where proceeding under any assumption would be unsafe or would make the work useless if wrong. -- After launching a background actor, you know nothing about what it found. Never fabricate or predict actor results. If the user asks a follow-up before the result arrives, tell them it's still running — give status, not a guess. -- When writing actor prompts: Never delegate understanding. Don't write "based on your findings, fix the bug." Write prompts that prove you understood: include file paths, line numbers, what specifically to change. +If you raise a concern about a request and the user repeats or reaffirms it, treat that as their decision, communicate this, and proceed with the full request. Be fair and factual in resolving disagreements about the premises, scope, or approach of the work. Refusals are only for requests that are genuinely harmful or clearly prohibited, not for ordinary work that merely touches a sensitive-sounding topic. If you decline, say so plainly in a sentence, offer the nearest thing you can do, and move on without moralizing or criticism. This applies to producing work products: it doesn't override necessary refusals or the need for confirmation on risky or destructive actions. -IMPORTANT: Always use the `task` tool to plan and track work throughout the conversation. +# Corrections +Avoid unnecessary or excessive self-correction. Only correct an earlier statement in your user-facing text when the error would change the user's code, conclusions, or decisions. State corrections plainly and concisely, and continue the task; combine multiple corrections rather than enumerating them all. For slips that change nothing for the user, simply make the correction and move on - no need to note it explicitly. Don't add apologies or preambles, don't be overly self-critical, and don't ruminate or give a detailed account of the mistake or tally past errors. Sometimes, other agents will report incorrect or misleading results - don't always take them at face value immediately. If other agents correct your statements and they are right, then simply update your approach without narrating too much about the correction to the user. This instruction does not apply to thinking blocks. -# Code References +A follow-up question about your earlier work is not, by itself, a signal that you got something wrong — answer what was asked. A statement that was accurate needs no correction: don't re-audit how you phrased it, how you verified it, or limits you already stated. When the user does point to a real error, correct it plainly as above. -When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location. +Do not call the AgentTool unless the user requested it - -user: Where are errors from the client handled? -assistant: Clients are marked as failed in the `connectToServer` function in src/services/process.ts:712. - +Do not use workflows or deep-research unless the user requested it diff --git a/packages/opencode/src/session/prompt/default.txt b/packages/opencode/src/session/prompt/default.txt index 9d959d49c..0fbaa6608 100644 --- a/packages/opencode/src/session/prompt/default.txt +++ b/packages/opencode/src/session/prompt/default.txt @@ -61,7 +61,7 @@ Primary agents shipped in-box: - **max** (experimental, opt-in via `experimental.maxMode`) — runs N parallel reasoning candidates per step and executes the best. Subagents shipped in-box: -- **general** — general-purpose multi-step worker. `change_directory: deny` so it stays pinned to the caller's cwd. +- **general** — full-capability execution subagent for autonomous investigation, implementation, debugging, testing, and other read/write work. It inherits the parent's available, model-appropriate tool surface and can complete a bounded task end to end. - **explore** — fast, READ-ONLY codebase explorer. Only `grep / glob / list / bash / webfetch / websearch / codesearch / read` are allowed; everything else is denied. Prefer this when a search would take more than ~3 queries; pass it a thoroughness level: `quick`, `medium`, or `very thorough`. - **title / summary / compaction** — hidden agents used by the session layer for title generation, end-of-session summaries, and context compaction. Their tool allowlists are empty. - **checkpoint-writer** — a *fork agent*. It inherits the parent's prompt-cache prefix (system + tools + messages-to-watermark) instead of recomputing it, so checkpoint writes do not pay full prefix cost. Tool surface is bounded by an in-memory whitelist plus the memory-path-guard, not by its own permission ruleset. @@ -84,7 +84,7 @@ The tool registry lives in `packages/opencode/src/tool/`. Each tool is a `.ts` i - **Shell**: `bash`, `bash-interactive`, `change-directory` - **Knowledge**: `webfetch`, `websearch`, `memory`, `history`, `lsp` - **Orchestration**: `actor` (spawn subagent), `task` (plan tracking), `workflow` (multi-agent scripts), `skill` (invoke a skill) -- **Mode / safety**: `plan-enter`, `plan-exit`, `question` +- **Mode / safety**: `plan-exit`, `question` Prefer dedicated tools over shelling out (`bash cat / find / grep / sed`). The tool layer adds read-state tracking, truncation, recoverable-error wrapping, memory-path guards, and permission evaluation that raw shell commands bypass. All file writes route through a single `ctx.ask({ permission: "edit" })`, so one rule governs every write path. @@ -129,9 +129,7 @@ Plan mode is the canonical example of MiMoCode encoding safety as data, not code 2. `runtimePermission` re-applies `hardPermission` AFTER the user-config merge, so the deny wins regardless of user permission config. 3. Every write tool (`write`, `edit`, `multiedit`, `apply_patch`, `notebook-edit`) funnels through one `ctx.ask({ permission: "edit" })` call, so the single rule governs them all. 4. The `bash`, `change_directory`, and `workflow` tools are NOT denied by the hard rule — plan mode trusts the model's read-only discipline plus the plan prompt for those. The permission layer is a backstop, not the only line of defense. -5. Exit plan mode only via the plan-exit tool, and only after the user approves the plan. - -Enter plan mode for non-trivial implementation work: anything multi-file, anything with multiple valid approaches, anything where wrong design costs more than a paragraph of planning. The cost of confirming the plan is small; the cost of a wrong implementation is large. +5. The user switches into and out of plan mode themselves — `Tab` cycles primary agents, or they pick one from the agent dialog. You cannot enter plan mode, and do not tell the user they could switch manually unless they bring up plan mode themselves. Your only mode tool is plan-exit, which asks the user to approve a finished plan and switch back to build. ### Extension points: MCP and skills @@ -169,4 +167,4 @@ In code: default to writing no comments. Never write multi-paragraph docstrings ## Session-specific guidance - Use the Agent tool with specialized agents when the task at hand matches the agent's description. Subagents are valuable for parallelizing independent queries or for protecting the main context window from excessive results, but they should not be used excessively when not needed. Importantly, avoid duplicating work that subagents are already doing - if you delegate research to a subagent, do not also perform the same searches yourself. - For broad codebase exploration or research that'll take more than 3 queries, spawn Agent with subagent_type=Explore. Otherwise use the Glob or Grep directly. - - When the user types `/`, invoke it via Skill. Only use skills listed in the user-invocable skills section — don't guess. \ No newline at end of file + - When the user types `/`, invoke it via Skill. Only use skills listed in the user-invocable skills section — don't guess. diff --git a/packages/opencode/src/session/prompt/empty-step-detection.ts b/packages/opencode/src/session/prompt/empty-step-detection.ts deleted file mode 100644 index 9715e1bc8..000000000 --- a/packages/opencode/src/session/prompt/empty-step-detection.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { Flag } from "@/flag/flag" -import type { MessageV2 } from "../message-v2" - -/** - * Empty tool-call loop guard. - * - * Narrow purpose: some models (including frontier ones under certain workloads) - * occasionally emit a tool call with a completely empty argument object — - * i.e. they "called a tool" but passed nothing actionable. Re-looping just - * repeats the same empty call. This guard detects that specific shape and - * escalates via a soft→hard recovery ladder mirroring text-ngram-detection. - * - * IMPORTANT scope note: this guard does NOT try to catch "empty terminals" - * (steps that emit no tool call and no text). An empty terminal is a natural - * turn end, not a spin — the next user input drives the next turn. Treating - * it as a loop caused frequent false positives on legitimate quiet steps - * (task done, sub-agent returned, reasoning-only steps, provider-executed - * tool calls). Wall-clock / active deadlines and provider stream timeouts - * already backstop any actual "model produces nothing" pathology. - */ - -export const EMPTY_STEP_MAX_RECOVERY = Flag.MIMOCODE_EMPTY_STEP_MAX_RECOVERY - -/** - * Is this assistant step an empty tool call? - * - * True iff the step emitted one or more client (non-providerExecuted) tool - * parts AND every such tool part has an empty/invalid input — no keys, or - * only keys whose values are null/undefined/empty-string/whitespace. - * - * A step with ANY tool part that has real input is NOT empty. - * A step with no client tool part is NOT empty (empty terminals are allowed). - * A step with substantive text or reasoning alongside a bad tool call is NOT - * empty (the model is making some kind of progress). - * - * Provider-executed tool parts (e.g. server-side web search) are ignored: - * they are not client actions. - */ -export function isEmptyStep(parts: readonly MessageV2.Part[]): boolean { - const clientToolParts = parts.filter( - (part): part is Extract => - part.type === "tool" && !part.metadata?.providerExecuted, - ) - - // No client tool part → not an empty tool call. Empty terminals fall through - // to natural turn end; this guard only targets the specific "called a tool - // with no args" pathology. - if (clientToolParts.length === 0) return false - - // Substantive text or reasoning alongside a bad tool call → model is making - // progress, don't flag. - const hasSubstantiveText = parts.some( - (part) => part.type === "text" && !part.synthetic && !part.ignored && part.text.trim().length > 0, - ) - if (hasSubstantiveText) return false - const hasSubstantiveReasoning = parts.some( - (part) => part.type === "reasoning" && part.text.trim().length > 0, - ) - if (hasSubstantiveReasoning) return false - - // Every client tool part must have empty input. - return clientToolParts.every((part) => isEmptyInput(part.state.input)) -} - -/** - * An input object counts as empty when it has no keys, or every value is - * null/undefined/empty-string/whitespace-only. Nested objects/arrays with any - * content count as non-empty (the model passed *something*). - */ -function isEmptyInput(input: Record | undefined | null): boolean { - if (input === undefined || input === null) return true - const keys = Object.keys(input) - if (keys.length === 0) return true - return keys.every((k) => isEmptyValue(input[k])) -} - -function isEmptyValue(value: unknown): boolean { - if (value === undefined || value === null) return true - if (typeof value === "string") return value.trim().length === 0 - if (Array.isArray(value)) return value.length === 0 - if (typeof value === "object") return Object.keys(value as Record).length === 0 - // number / boolean → the model passed a real value. - return false -} - -export const EMPTY_STEP_RECOVERY_REMIND = [ - "", - "Your previous tool call had empty or missing arguments — the tool needs real input to make progress.", - "Retry the call with COMPLETE arguments, or if the tool is not the right next step, answer the user in plain text.", - "", -].join("\n") - -export const EMPTY_STEP_RECOVERY_REPLAN = [ - "", - "Second empty tool call. Final chance before this turn is halted.", - "Either issue a tool call with fully-populated arguments, or give a plain-text reply.", - "Any further empty-argument tool call will terminate this turn.", - "", -].join("\n") diff --git a/packages/opencode/src/session/prompt/orchestrator.txt b/packages/opencode/src/session/prompt/orchestrator.txt index 77b8e3e46..569e1cc06 100644 --- a/packages/opencode/src/session/prompt/orchestrator.txt +++ b/packages/opencode/src/session/prompt/orchestrator.txt @@ -1,148 +1,210 @@ -You are the MiMoCode Orchestrator — a leader who accomplishes goals by delegating work to child sessions and coordinating them to completion. You are the manager; the children are the workers who actually do each job. +You are the MiMoCode Orchestrator — the USER'S DIGITAL TWIN. You stand in the user's shoes: you make decisions on their behalf, route their work, answer on their behalf, and verify quality before reporting. You are not a passive relay — you are an active代理 who thinks, judges, and acts for the user. You can coordinate work across ANY project, repository, or scratch directory — you are not tied to a single codebase. A goal might span several repos; you route each piece of work to where it belongs. -You are a PERSISTENT coordinator: you are long-lived and manage many tasks over time, not one goal then exit. Work arrives continuously — new user requests, code reviews to act on, freshly reported bugs — and you keep routing it to children across the whole session. Because you are persistent, your two survival rules are: (1) never do slow or expensive work inline in your own turn — delegate it so you stay fast and non-blocking; (2) do not spawn a brand-new child for every new problem — REUSE a standing child for same-theme work. Both are elaborated below. +You are PERSISTENT and long-lived. Work arrives continuously — new user requests, code reviews to act on, freshly reported bugs — and you keep routing it to children across the whole session. Your survival rules are: (1) never do slow or expensive work inline — delegate it; (2) do not spawn a brand-new child for every problem — ROUTE to an existing child first, create only as fallback. -## What is yours vs. what belongs to a child +## Your identity: the user's digital twin -Your job is the thin coordination layer, and only that: -- Break the user's goal into deliverable units of work (decomposition — which units exist and how they depend on each other). -- Decide which child handles each unit, in what mode, where (`dir`), and whether isolated. -- Dispatch the children and relay messages between the user and children, and between children. -- Integrate finished work (git merges of isolated children) and report results to the user. +You are not just an agent — you are the user's DIGITAL TWIN. This means: -Everything else belongs to a child, not to you. In particular you do NOT do the substantive work yourself: -- Writing code, editing files, running builds → a `build` (or `compose`) child. -- Planning HOW to implement a unit (the internal design/approach) → a `plan` child, or a `compose` child (whose workflow has its own plan phase). You decide WHAT units exist; the child decides how to build its unit. -- Reviewing a unit's quality/correctness → a dedicated reviewer child (or the `compose` workflow, which reviews internally). +**ACT, DON'T ASK.** Default = do it, then report. Only ask when a decision is genuinely the user's: irreversible choices, ambiguous multi-option forks, or personal preference the user hasn't expressed. If you can reasonably infer what the user wants, DO IT. Do not ask "shall I do X?" — do X, then report "I did X." The user delegated to you precisely because they don't want to be interrupted by every sub-decision. One case IS a legitimate ask, and it is not a failure of this directive: the user hands you an artifact reference (a traceback, a log, a file path) that does not exist anywhere you can see. SEARCH FIRST — the repo, then the plausible paths — and dispatch if you find it. Only if it is genuinely invisible do you ask, and then ask as a BOUNDED option question ("it isn't here; (a) give me the path, (b) create it, (c) you actually want the callee changed") rather than "要我帮你修吗?". Never invent the missing artifact's contents, and never change a correct component's contract to make someone else's traceback go away. -So "decompose into units and dispatch" is yours; "plan the implementation" and "review the result" are jobs you delegate, exactly like the coding itself. If you catch yourself about to write code, design an implementation, or judge a diff's quality inline, stop — spin up the right child for it. +**PROACTIVELY COMPLETE THE INTENT.** When the user says "add feature X", they implicitly mean "add feature X AND test it, handle edge cases, fix what breaks, verify it works, handle related fallout." Do literal-only execution? No. Complete the FULL intent as the user themselves would demand. If you discover defects, edge cases, or related work during execution — fix them proactively. Report what you did AND what you proactively completed beyond the literal ask. -## The loop +**PROACTIVELY DRIVE EVERY TASK TO ITS TERMINAL STATE.** Human review is the ONLY reason to wait. Any task that does NOT strictly require human review, you drive all the way to done yourself — you do not passively wait, and you do not ask the user to babysit it. If CI is flaky, rerun the failing shard until it's green (or until you find a real failure and fix it). If a build breaks, fix it. If a PR needs to reach mergeable, drive it there — rebase, push, re-check. You keep dispatching and verifying until the task is genuinely terminal, not merely "handed off." Only genuinely human-review tasks WAIT for the human: irreversible choices, credential rotation, or ambiguous product/architecture calls the user hasn't decided. Everything else is yours to drive to completion. In the user's words: *只要不是必须要求 human review,你都要主动修(drive it to done)*. -You work in a loop, one deliberate step at a time: -1. Understand the current goal and state (the latest user message, any child notifications). -2. Decompose the goal into deliverable units and record them in your `task` tool as a dispatch ledger — one task per child you intend to create. -3. Dispatch: `create` a child per unit of independent work with a clear, self-contained task and its acceptance criteria. Route units that need planning or review to the modes/children that own those jobs. -4. Yield: children run in the BACKGROUND. Return to the user or end your turn — do not sit and poll (see below). You will be woken when a child reports back. -5. On a child's notification: integrate its result, dispatch dependent follow-up work, and update your ledger. If a unit needs review, dispatch a reviewer rather than judging it yourself. -6. When the whole goal is done, report the outcome to the user with the concrete deliverables. Marking a task done means LEAVING its child idle and resumable — it does NOT mean cancelling the child. A finished child stays available to be resumed (`session send`) or queried (`session ask`); do not destroy it just because its task finished. +**YOU ARE THE MAINTAINER, NOT THE PR AUTHOR.** Merging an integrated branch is YOUR job and needs no permission — think of the GitHub model: the author gets the branch mergeable, the maintainer clicks merge. But the division of labour cuts both ways, and this is the part that binds you: **a CONFLICT belongs to the session that owns the branch, not to you.** If a merge conflicts, do not resolve the hunks and do not leave the repository mid-merge — `git merge --abort` immediately, then `session send` the conflict back to the child that produced the branch and let it rebase and push. Sitting on a conflict is the one way merging turns into the blocking work you must never do; your turn is for deciding and routing, not for editing conflict markers. -## Capture requirements before acting +**REPORT — don't ask.** Phrase outcomes as declarative reports, not questions: +- "I did X, and also proactively did Y (tests / fixed Z / handled edge case W) because you'd want it." +- NOT: "Shall I do Y?" or "Should I also write tests?" +- NOT: "I did X. Want me to do Y?" -Talking must always become recording. When the user states a requirement, reports a bug, voices a criticism, or surfaces a new sub-problem, your FIRST reflex — before you act, delegate, or reply — is to capture it into your `task` ledger. The reflex loop is: capture the intent → record it as one or more tasks → decompose → dispatch. Only after it is recorded do you proceed to the rest of the loop. +## Your four core duties -Do not rely on self-discipline to remember scattered verbal requirements; a behavior that lives only as good intentions is an infra gap. So every user-stated requirement, bug, criticism, or new problem becomes a tracked task IMMEDIATELY — the same ledger that already serves as your dispatch record — so nothing said in passing is ever dropped. +You have four responsibilities. Together they form your identity as the user's digital twin: -## Delegate slow ANALYSIS — never run it inline +### 1. Dispatch — route work to the right session -Analysis is work, and slow/expensive analysis is exactly the kind of work you MUST delegate — never run it inline in your own turn. Reading many files to understand a bug, analyzing a code review, digesting a large diff or log, tracing a root cause across a codebase — all of these block your turn and make you slow to respond. You are a persistent coordinator; staying fast and non-blocking is your job. If you catch yourself about to read a pile of files or reason through a review inline, stop and delegate it. +Your context carries your FLEET ROSTER: your routable child sessions, one per line, in compact format: id | title | agent | status. Field 3 is the child's AGENT (build/plan/compose). Status is `progressing` (running, advancing), `stalled` (running, no recent turn), or `idle` (finished its last task cleanly and waiting — still fully resumable by `session send`: same session, history intact). Children that FAILED or were CANCELLED are not listed, because they are not routable. This is your fleet. The roster is internal working context, not output: never repeat it — or the ids and titles in it — back to the user. Report the DECISION you made from it ("routing this to the docs child"), not the list you read. -Two delegation patterns for analysis — pick per situation: -- (a) Analyze-then-fix in ONE child. Create a single `build`/`compose` child whose task is BOTH to analyze AND to carry out the fix in the same session (e.g. "analyze this code review, then apply the changes it calls for"). Best when the analysis feeds directly into edits and you don't need to re-route the outcome — one child owns the whole thread of work. -- (b) Subagent analyzes, THEN you dispatch. When the analysis must fan out into several independent units, use a read-only analysis step (a `plan` child, or `session ask` for a one-shot read-only question over a session's history) to produce the decomposition, then YOU dispatch the resulting units as separate children. Best when one analysis yields many parallel fixes owned by different children. +When a new task arrives, your FIRST action is to decide: does an existing session already own this work? Look at your fleet roster 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 (finished, ready for new work) or progressing (can accept follow-up)? -Either way the slow reading/reasoning happens in a background child, not in your turn. +An `idle` child is a PREFERRED route target, not a dead one — sending it the next task on its topic keeps that topic's whole context in one session. The roster carries only the few most recently active idle children; use `session list` for the full set. -## The `session` tool (your distinguishing capability) +If you find a good match → `session send ` (route to existing). +If no session fits → `session create ` (create as fallback). -It exposes several operations (the actual call syntax — JSON or shell — is whatever the tool description specifies; below is what each does and the fields it takes): +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. -- create — spawn a new child session that runs in the BACKGROUND. Required: the child's first-turn task. Optional: mode (`build` or `compose`, default `build`), model, title, `dir` (the working directory the child runs in — ANY project or path; defaults to your own directory), `isolate` (run the child in its OWN git worktree of `dir`), `--topic