Technical Documentation: XiaomiMiMo/MiMo-Code
ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference
Overview (`README.md`)
MiMoCode
MiMoCode is a terminal-native AI coding assistant. It can read and write code, run commands, manage Git, and use a persistent memory system to keep a deep understanding of your project across sessions while continuously improving itself.
Xiaomi MiMo Desktop
Xiaomi MiMo Desktop is built for real-world work: one conversation can handle office work, design, coding, and multimodal creation, while multiple conversations can divide work and collaborate. The desktop app is powered by MiMo Code as its core engine, bringing its terminal-native intelligence to desktop workflows.
- Smart orchestration: evaluates task type and cost, then dynamically selects models, frameworks, and tools; complex tasks can run across multiple agents in parallel.
- Continuous iteration: drag in files in multiple formats, create PPTs, webpages, 3D assets, and apps, preview and operate on results in-session, edit selected regions precisely, and roll back versions.
- Controlled long-task cost: routes between standard and flagship models, edits only the required regions, and supports up to 99% same-session and 95% cross-session cache hit rates.
- Browser control: opens webpages, retrieves information, fills forms, and checks key interactions after generating webpages.
- Computer control (international version only): reads the screen and operates mouse and keyboard across apps; Record & Replay lets you reuse recorded workflows with natural language.
Powered by the new MiMo-V2.6-Pro and MiMo-V2.6-Flash models.
MiMoCode supports connecting to any mainstream LLM provider API.
Quick Start
# One-line install (macOS / Linux)
curl -fsSL https://mimo.xiaomi.com/install | bash
# One-line install (Windows PowerShell)
powershell -ep Bypass -c "irm https://mimo.xiaomi.com/install.ps1 | iex"
# Run
mimoThe first launch guides you through configuration automatically. Supported options:
- Xiaomi MiMo Platform — OAuth login
- Codex (ChatGPT Pro/Plus) — OpenAI OAuth login
- Import from Claude Code — migrate existing authentication in one step
- Provider list — connect catalog providers by API key, or OAuth where supported (e.g. xAI/Grok)
- Custom Provider — add any OpenAI-compatible API in the TUI
WSL: clipboard issues
If you encounter garbled text when copying on WSL, install xsel:
sudo apt install xselmacOS: 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 or the VS Code integrated terminal instead:
brew install --cask iterm2TUI 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:
# 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:4096If 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
On Windows with a non-UTF-8 system locale (e.g. zh-CN, whose active code page is 936/GBK),
command output containing CJK characters may appear garbled (mojibake). MiMoCode forces
UTF-8 output for spawned PowerShell/cmd subprocesses. If you still encounter garbled output
in cases this does not yet cover, enable Windows' system-wide UTF-8 support:
Settings → Time & language → Language & region → Administrative language settings →
Change system locale → check "Beta: Use Unicode UTF-8 for worldwide language support" →
reboot.
This switches the active code page (ACP) to UTF-8 (65001) for all programs, so subprocesses
no longer inherit the legacy code page. Note it is a system-wide Beta toggle and may cause
some older non-Unicode programs to display incorrectly, so treat it as a workaround.
MiMo Ecosystem
Beyond MiMoCode, Xiaomi MiMo models also work in other agents and coding tools like Cursor, Cline, and Zed.
awesome-mimo-agent collects setup guides for using MiMo in those tools — worth a look if you want to try MiMo elsewhere. Contributions welcome: open a PR to add your own setup.
Core Features
Multiple Agents
| Agent | Description |
|---|---|
| build | Default. Full tool permissions for development |
| plan | Read-only analysis mode for code exploration and solution design |
Press Tab to switch between primary agents. Build and Plan can switch at any time; other custom or special modes lock once the first message is sent.
Persistent Memory
Cross-session memory powered by SQLite FTS5 full-text search:
- Project memory (
MEMORY.md) — persistent project knowledge, rules, and architecture decisions - Session checkpoint (
checkpoint.md) — structured state snapshots maintained automatically by the checkpoint-writer subagent - Scratch notes (
notes.md) — temporary note area for agents - Task progress (
tasks//progress.md) — per-task logs
Memory is injected automatically when a session resumes, so the agent does not need to relearn project context.
Intelligent Context Management
- Automatic checkpoints — decides when to save session state based on the model context window
- Context reconstruction — when context approaches the limit, rebuilds it from the latest checkpoint, project memory, task progress, and retained recent messages so the agent can continue the current task
- Budgeted injection — uses a token budget to control how much checkpoint, memory, and notes content enters context, with importance ranking
- Adjustable compaction point —
/context-limit(orcompaction.max_context) makes a model compact earlier than its own window, per model
Compacting earlier than the model window (/context-limit)
Compaction normally fires just below the model's context window. Run /context-limit to
pick a smaller working budget for the current model — 200K / 300K / 500K / 1M or a
custom value — stored per model as compaction.max_context:
{
"compaction": {
"max_context": {
"openai/gpt-5.6": "272K", // token count, "300K", "1M", or "50%" of the window
"anthropic/*": "300K" // wildcards allowed, longest pattern wins
}
}
}The value is always clamped to what the provider actually accepts, so it can only lower the
compaction point, never raise it. 0 restores the model's own window.
Why you might want it:
- Cost tiers. OpenAI prices GPT-5.6 prompts above 272K input at 2x input and 1.5x output
for the whole request. - The advertised window is not always what you get. The same model can have a different
usable window depending on how you reach it — a ChatGPT/Codex subscription, a direct API
key, or a reseller such as OpenRouter — so a catalog figure of 1M does not mean your route
serves 1M. - Quality and latency. Very long contexts are slower and, past a point, not better.
mimo models prints, per model, the window MiMoCode resolved and the token count
where it will compact. The prompt footer uses that same number as its denominator
(33.0K/260K↓ (13%) — the ↓ means a budget is in force), and /status breaks it down.
Task Tracking
A tree-shaped task system (T1, T1.1, T1.2, …) that integrates automatically with the checkpoint system, so task progress is preserved when sessions resume.
Subagent System
The primary agent can create subagents on demand. Subagents share the current session context and can work in parallel, with lifecycle tracking, cancellation, and background execution.
Goal / Stop Condition
The /goal command sets a stopping condition for a session. When the agent tries to stop, an independent judge model evaluates the conversation to decide whether the condition is truly satisfied — preventing premature "optimistic stops" during autonomous work.
Compose Mode
Use the /compose-next skill to run an end-to-end feature delivery loop: a single self-contained contract covering grill → workspace → spec → implement → verify → review → finalize → finish, with feature documents at docs/compose/spec/<feature>.md under the workspace root. Do not combine it with plan mode.
Workflows
Workflows are deterministic JavaScript scripts that orchestrate multiple agents in a sandboxed runtime. Unlike agent conversations, workflows encode fixed phase sequences with bounded retries and automatic parallelization — fire-and-forget execution with no user interaction required.
MiMoCode ships with three built-in workflows:
| Workflow | Phases | Description |
|---|---|---|
deep-research |
Brief → Plan → Research → Reflect → Write → Review | Multi-source deep research report generator. Plans independent research angles, runs parallel sub-agents to collect cited findings, reflects on gaps, writes a single coherent Markdown report, then cold-reviews citations. Convergent: resumable via file checkpoints. |
fact-check |
Plan → Search → Extract → Group → Crosscheck → Report | Adversarial fact verification. Runs parallel web searches, extracts checkable facts, groups duplicates, then cross-checks each with a 3-juror adversarial vote. Best for precise claims ("Is X true?"). |
research-experiment |
Baseline → Loop → Audit → Report | Autonomous optimization loop for a mechanically verifiable metric. Establishes a baseline, iterates through hypothesize → implement → evaluate → keep/revert, audits for metric gaming, and produces a reproducible result log. Requires a fixed-budget evaluation command and an explicit editable-file scope. |
Custom workflows: Place a .js file in .mimocode/workflows/ or .claude/workflows/ to define your own, or override a built-in by using the same name.
Builtin Skills
Skills are reusable instruction sets that teach agents how to handle specific tasks (e.g. generating PDFs, writing academic papers, searching arXiv). For a new task, MiMoCode searches available skills by exact name, localized alias, and BM25 relevance. High-confidence matches are loaded automatically; uncertain matches are ranked for the agent to assess. In the TUI, type / to browse the autocomplete list or invoke a skill directly with /<skill-name> — mentioning two or more skills in a single message auto-loads them and injects a multi-skill orchestration plan.
MiMoCode bundles the following builtin skills:
| Skill | Description |
|---|---|
arxiv |
Search, read, cite, and analyze arXiv papers |
claude-code |
Delegate coding, testing, review, and Git tasks to the Claude Code CLI |
codex |
Run and troubleshoot the Codex CLI in headless automation, CI, containers, and remote environments |
compose-next |
Spec→ship feature delivery orchestration — clarify, spec, implement, verify, review, finish |
data-analytics |
Analyze product and business data through reusable workflows for data quality, KPIs, dashboards, reports, notebooks, and market sizing |
deep-research |
Produce cited, multi-source research reports with parallel subagents and built-in web tools |
docx-official |
Produce, read, and transform Word (.docx) files |
html-to-video-pipeline |
HTML-to-MP4 rendering via headless browser + ffmpeg |
learn-everything |
Turn documents, URLs, or topics into adaptive courses with exercises, feedback, and progress tracking |
loop |
Schedule recurring prompts on a fixed cadence |
mimocode-docs |
Self-documenting reference for MiMoCode features, commands, providers, and configuration |
modern-python-toolchain |
Set up modern Python projects with uv, Ruff, and Pyright |
pdf-official |
Produce, read, fill, and transform PDF files |
pptx-official |
Author and manipulate PowerPoint (.pptx) decks |
product-design |
Explore, audit, implement, and QA product and UX designs through focused workflows |
research-paper-writing |
Write and polish academic papers (ML/CV/NLP style) |
sales |
Support sales research, meeting preparation, account prioritization, deal strategy, forecasting, and CRM workflows |
skill-creator |
Interactive guide for creating and improving agent skills |
super-research |
Run long-horizon, auditable research, experiments, benchmarks, diagnostics, reproductions, and citation checks |
xlsx-official |
Build, clean, and transform spreadsheets (.xlsx/.csv) |
claude-code and codex are exposed only when the claude and codex executables, respectively, are installed. Other skills may still require task-specific tools described in their instructions.
Overriding a builtin skill: Create a skill with the same name under the project (.mimocode/skills/<name>/SKILL.md) or personal (for example ~/.config/mimocode/skills/<name>/SKILL.md) MiMoCode skills directory. Open-standard .agents/skills/ in the project and ~/.agents/skills/ are compatible discovery roots. User skills discovered later in the scan order override builtins with the same name.
Configuring skills via environment variables
| Variable | Effect |
|---|---|
MIMOCODE_DISABLE_BUILTIN_SKILLS=true |
Disable all builtin skills |
MIMOCODE_DISABLE_OFFICIAL_SKILLS=true |
Disable only the office/media skills: docx-official, pdf-official, pptx-official, xlsx-official, html-to-video-pipeline |
MIMOCODE_DISABLE_SLASH_SKILLS=true |
Hide skills from TUI / autocomplete without disabling them |
External skill roots (default surface is .mimocode + open-standard .agents):
| Env | Default | Effect |
|---|---|---|
MIMOCODE_DISABLE_AGENTS_SKILLS=true |
unset = on | Turn off ~/.agents/skills and project .agents/skills |
MIMOCODE_ENABLE_CLAUDE_CODE_SKILLS=true |
unset = off | Opt in .claude/skills |
MIMOCODE_ENABLE_CODEX_SKILLS=true |
unset = off | Opt in .codex/skills (user skills only; Codex skills/.system is never loaded) |
MIMOCODE_ENABLE_OPENCODE_SKILLS=true |
unset = off | Opt in .opencode/skills |
External scans never match dotted path segments under skills/.
MIMOCODE_DISABLE_BUILTIN_SKILLS and MIMOCODE_DISABLE_OFFICIAL_SKILLS remove the corresponding skills from the agent's available skill list entirely — they will not appear in context and cannot be invoked. MIMOCODE_DISABLE_SLASH_SKILLS affects only TUI autocomplete; the skills remain available to agents.
Voice Input
Real-time streaming voice input powered by TenVAD and MiMo ASR. Activate with /voice, then speak — audio is segmented by pauses and transcribed incrementally into the input. Available for MiMo logged-in users. Requires sox (brew install sox on macOS, other platforms similar).
WSLg audio setup
sudo apt install -y sox pulseaudio libasound2-plugins
export PULSE_SERVER=unix:/mnt/wslg/PulseServerSSH remote audio (Mac → remote host)
# Mac (local)
brew install pulseaudio
pulseaudio --load="module-native-protocol-tcp auth-ip-acl=127.0.0.1" --exit-idle-time=-1 --daemonize
# Add to ~/.ssh/config: RemoteForward 4713 127.0.0.1:4713
# Remote host
apt install -y pulseaudio pulseaudio-utils sox
export PULSE_SERVER=tcp:127.0.0.1:4713
# Verify: pactl infoNon-MiMo voice providers (OpenRouter, internal API, etc.)
Voice input can route through other OpenAI-compatible providers via the voice config field. The ASR model (mimo-v2.5-asr) is only available on MiMo's platform; voice control mode (mimo-v2.6-flash) is available on OpenRouter and compatible relay platforms.
OpenRouter (voice control only):
Use /connect to sign in to OpenRouter, then add to your config:
{
"voice": {
"control_model": "openrouter/xiaomi/mimo-v2.6-flash"
}
}Internal / self-hosted relay (both ASR and voice control):
{
"provider": {
"internal": {
"options": {
"baseURL": "https://your-api-gateway.example.com/v1",
"apiKey": "sk-..."
},
"models": {
"xiaomi/mimo-v2.5-asr": { "name": "MiMo-V2.5-ASR" },
"xiaomi/mimo-v2.6-flash": { "name": "MiMo-V2.6-Flash" }
}
}
},
"voice": {
"asr_model": "internal/xiaomi/mimo-v2.5-asr",
"control_model": "internal/xiaomi/mimo-v2.6-flash"
}
}Custom providers must register at least one model in their models field to be recognized. The model names in voice.*_model are sent directly to the API — they don't need to match the registered model keys exactly.
Note: Models registered under a custom provider will appear in the model selection list. Don't use ASR-only models (e.g.
mimo-v2.5-asr) as your primary coding model.
Dream & Distill
/dream— scans recent session traces, extracts persistent knowledge into project memory, and removes outdated entries/distill— discovers repeated manual workflows in recent work and packages high-confidence candidates into reusable skills, subagents, or commands
Configuration
MiMoCode uses JSON/JSONC config files with published JSON Schemas for autocompletion and validation.
File Locations
| File | Project-level | Global |
|---|---|---|
| Main config | .mimocode/mimocode.jsonc (also .json) |
~/.config/mimocode/mimocode.jsonc (also .json) |
| TUI config | .mimocode/tui.json |
~/.config/mimocode/tui.json |
| Auth credentials | — | ~/.local/share/mimocode/auth.json |
On Windows, XDG paths fall under
%LOCALAPPDATA%\mimocode\. You can override all paths withMIMOCODE_HOME.
JSON Schemas
MiMoCode auto-injects a $schema field when it first loads your config, so your editor gets completions and validation out of the box:
| Config | Schema URL |
|---|---|
mimocode.jsonc / mimocode.json |
https://mimo.xiaomi.com/mimocode/config.json |
tui.json |
https://mimo.xiaomi.com/mimocode/tui.json |
VS Code / Cursor: trust the schema domain
Add to your settings.json so the editor can download schemas for autocompletion:
{
"json.schemaDownload.trustedDomains": {
"https://mimo.xiaomi.com/": true
}
}Data directories
Beyond config files, MiMoCode stores runtime data under XDG paths (or $MIMOCODE_HOME):
| Directory | Default (Linux) | Contents |
|---|---|---|
| data | ~/.local/share/mimocode/ |
SQLite database, auth credentials (auth.json), memory, logs |
| state | ~/.local/state/mimocode/ |
TUI preferences (kv.json), recent models (model.json) |
| cache | ~/.cache/mimocode/ |
Language servers, cached model catalog, skills |
To remove stored credentials, delete auth.json from the data directory. On macOS, XDG data defaults to ~/Library/Application Support/mimocode/.
Custom OpenAI-Compatible Endpoints
If your provider is not in the built-in model catalog, configure it directly with its base URL, API key, and model ID:
{
"$schema": "https://mimo.xiaomi.com/mimocode/config.json",
"model": "custom/MODEL_NAME",
"provider": {
"custom": {
"name": "Custom",
"npm": "@ai-sdk/openai-compatible",
"only_configured_models": true,
"models": {
"MODEL_NAME": {
"name": "MODEL_NAME"
}
},
"options": {
"baseURL": "BASE_URL",
"apiKey": "API_KEY"
}
}
}
}- Use the exact keys
baseURLandapiKey. - Preserve the base URL and model ID exactly as supplied. MiMoCode does not require a known provider and you should not add or remove
/v1unless the endpoint requires it. - The key under
modelsis the upstream model ID. Model IDs containing/are supported because only the first/inmodelseparates the provider ID from the model ID. - Replace
customwith another unused lowercase provider ID if needed, and use the same ID in the top-levelmodelvalue. @ai-sdk/openai-compatibleis for OpenAI-compatible APIs. Services using a different wire protocol require their provider-specific adapter.
Put user-wide settings in ~/.config/mimocode/mimocode.jsonc (or mimocode.json in the same directory), or project-only settings in .mimocode/mimocode.jsonc (or .json), and merge them with any existing configuration. Because apiKey is stored as plaintext, keep the file readable only by your user and never commit it. Run mimo models or use the TUI model picker to verify the configured model.
To declare which input modalities a custom model supports (image, audio, video, PDF), run /modalities in the TUI — a multi-select dialog that persists the setting to config without hand-editing.
Key Options
- Provider and model selection
- Agent permissions and custom agents
- Checkpoint and memory behavior
- MCP server connections
- Keybindings and theme
Max Mode (parallel best-of-N reasoning with judge selection) can be enabled via experimental.maxMode in the config.
Allowing the system temp directory (/tmp)
By default, reading or writing files outside the project working directory triggers anexternal_directory permission prompt — including the system temp directory. This is
intentional: MiMoCode does not silently widen permissions, so you stay in control of what
the model can touch outside your project.
The temp directory comes up often because most models reach for it as scratch space (e.g.
a quick script, a throwaway data file). If you trust your environment and would rather not
be prompted each time, you can opt in by allowing it in your config:
{
"$schema": "https://mimo.xiaomi.com/mimocode/config.json",
"permission": {
"external_directory": {
"/tmp/**": "allow"
}
}
}This setting has known risks — use it at your own risk. The temp directory is
world-writable and shared with every other process and user on the machine. Auto-allowing
it means the model can read and write there without confirmation, which widens your exposure
to predictable temp-path / symlink tricks (e.g. another process pre-creating /tmp/foo as a
symlink to a sensitive file). For that reason it is only recommended for single-user,
controlled environments or inside a container. Keep the allowlist as narrow as possible.
Skipping permission prompts (--dangerously-skip-permissions)
For trusted, disposable environments (containers, sandboxes, CI) you can auto-approve
everything the agent does instead of confirming each action:
# TUI — prompts once for an explicit confirmation on startup
mimo --dangerously-skip-permissions
# Headless
mimo run --dangerously-skip-permissions "your prompt"
# Or via environment variable (any surface)
MIMOCODE_DANGEROUSLY_SKIP_PERMISSIONS=1 mimoThis injects an allow-all base underneath your config, so a tool with no rule
auto-approves — but any explicit rule you wrote still wins (the last matching rule wins,
and your rules sit after the injected *). A deny still blocks; note that a leftoverask rule also still prompts, and a top-level "*": "ask" makes the flag a no-op. In the
TUI it shows a red warning and requires you to accept the risk before it takes effect (the
prompt is skipped when there is no TTY, so in CI it activates with no confirmation).
This is dangerous. With permissions bypassed, a malicious prompt, file, or plugin can
run arbitrary shell commands and read, modify, or exfiltrate your data without any
confirmation. Only use it where you fully trust the workspace.
For a lighter-weight option, the /skip-permissions command toggles auto-allow at runtime
inside the TUI: deny rules still block, and forced-ask operations (e.g. destructive bash)
auto-reject after 60 seconds with feedback the model can act on instead of hanging.
Development
bun ci # Install dependencies (= bun install --frozen-lockfile)
bun run dev # Run in development mode
bun run typecheck # Type checkRelationship to OpenCode
MiMoCode is built as a fork of OpenCode. It keeps all core OpenCode capabilities (multiple providers, TUI, LSP, MCP, plugins) and adds persistent memory, intelligent context management, subagent orchestration, goal-driven autonomous loops, compose-next end-to-end delivery, Dynamic Workflows, and self-improvement via dream/distill.
Community
Scan the QR code to join the community group chat:
License
Source code is licensed under the MIT License.
Use of MiMoCode is also subject to the Use Restrictions.
Use of Xiaomi MiMo-hosted services is subject to the MiMo Terms of Service.
Use of the MiMo name, logo, and trademarks is subject to the MiMo Trademark Policy.
Overview (`packages/cli/README.md`)
js
To install dependencies:
bun ciTo run:
bun run index.tsThis project was created using bun init in bun v1.2.12. Bun is a fast all-in-one JavaScript runtime.
Overview (`packages/cli/src/acp/README.md`)
ACP (Agent Client Protocol) Implementation
This directory contains a clean, protocol-compliant implementation of the Agent Client Protocol for opencode.
Architecture
The implementation follows a clean separation of concerns:
Core Components
agent.ts- Implements theAgentinterface from@agentclientprotocol/sdk- Handles initialization and capability negotiation
- Manages session lifecycle (
session/new,session/load) - Processes prompts and returns responses
- Properly implements ACP protocol v1
client.ts- Implements theClientinterface for client-side capabilities- File operations (
readTextFile,writeTextFile) - Permission requests (auto-approves for now)
- Terminal support (stub implementation)
- File operations (
session.ts- Session state management- Creates and tracks ACP sessions
- Maps ACP sessions to internal opencode sessions
- Maintains working directory context
- Handles MCP server configurations
server.ts- ACP server startup and lifecycle- Sets up JSON-RPC over stdio using the official library
- Manages graceful shutdown on SIGTERM/SIGINT
- Provides Instance context for the agent
types.ts- Type definitions for internal use
Usage
Command Line
# Start the ACP server in the current directory
opencode acp
# Start in a specific directory
opencode acp --cwd /path/to/projectQuestion Tool Opt-In
ACP excludes QuestionTool by default.
MIMOCODE_ENABLE_QUESTION_TOOL=1 opencode acpEnable this only for ACP clients that support interactive question prompts.
Programmatic
import { ACPServer } from "./acp/server"
await ACPServer.start()Integration with Zed
Add to your Zed configuration (~/.config/zed/settings.json):
{
"agent_servers": {
"OpenCode": {
"command": "opencode",
"args": ["acp"]
}
}
}Protocol Compliance
This implementation follows the ACP specification v1:
✅ Initialization
- Proper
initializerequest/response with protocol version negotiation - Capability advertisement (
agentCapabilities) - Authentication support (stub)
✅ Session Management
session/new- Create new conversation sessionssession/load- Resume existing sessions (basic support)- Working directory context (
cwd) - MCP server configuration support
✅ Prompting
session/prompt- Process user messages- Content block handling (text, resources)
- Response with stop reasons
✅ Client Capabilities
- File read/write operations
- Permission requests
- Terminal support (stub for future)
Current Limitations
Not Yet Implemented
- Streaming Responses - Currently returns complete responses instead of streaming via
session/updatenotifications - Tool Call Reporting - Doesn't report tool execution progress
- Session Modes - No mode switching support yet
- Authentication - No actual auth implementation
- Terminal Support - Placeholder only
- Session Persistence -
session/loaddoesn't restore actual conversation history
Future Enhancements
- Real-time Streaming: Implement
session/updatenotifications for progressive responses - Tool Call Visibility: Report tool executions as they happen
- Session Persistence: Save and restore full conversation history
- Mode Support: Implement different operational modes (ask, code, etc.)
- Enhanced Permissions: More sophisticated permission handling
- Terminal Integration: Full terminal support via opencode's bash tool
Testing
# Run ACP tests
bun test test/acp.test.ts
# Test manually with stdio
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}' | opencode acpDesign Decisions
Why the Official Library?
We use @agentclientprotocol/sdk instead of implementing JSON-RPC ourselves because:
- Ensures protocol compliance
- Handles edge cases and future protocol versions
- Reduces maintenance burden
- Works with other ACP clients automatically
Clean Architecture
Each component has a single responsibility:
- Agent = Protocol interface
- Client = Client-side operations
- Session = State management
- Server = Lifecycle and I/O
This makes the codebase maintainable and testable.
Mapping to OpenCode
ACP sessions map cleanly to opencode's internal session model:
- ACP
session/new→ creates internal Session - ACP
session/prompt→ uses SessionPrompt.prompt() - Working directory context preserved per-session
- Tool execution uses existing ToolRegistry
References
Overview (`packages/cli/src/sync/README.md`)
tl;dr All of these APIs work, are properly type-checked, and are sync events are backwards compatible with Bus:
// The schema from `Updated` typechecks the object correctly
SyncEvent.run(Updated, { sessionID: id, info: { title: "foo" } })
// `subscribeAll` passes a generic sync event
SyncEvent.subscribeAll((event) => {
// These will be type-checked correctly
event.id
event.seq
// This will be unknown because we are listening for all events,
// and this API is only used to record them
event.data
})
// This works, but you shouldn't publish sync event like this (should fail in the future)
Bus.publish(Updated, { sessionID: id, info: { title: "foo" } })
// Update event is fully type-checked
Bus.subscribe(Updated, (event) => event.properties.info.title)
// Update event is fully type-checked
client.subscribe("session.updated", (evt) => evt.properties.info.title)Goal
Syncing with only one writer
This system defines a basic event sourcing system for session replayability. The goal is to allow for one device to control and modify the session, and allow multiple other devices to "sync" session data. The sync works by getting a log of events to replay and replaying them locally.
Because only one device is allowed to write, we don't need any kind of sophisticated distributed system clocks or causal ordering. We implement total ordering with a simple sequence id (a number) and increment it by one every time we generate an event.
Bus event integration and backwards compatibility
This initial implementation aims to be fully backwards compatible. We should be able to land this without any visible changes to the user.
An existing Bus abstraction to send events already exists. We already send events like session.created through the system. We should not duplicate this.
The difference in event sourcing is events are sent before the mutation happens, and "projectors" handle the effects and perform the mutations. This difference is subtle, and a necessary change for syncing to work.
So the goal is:
- Introduce a new syncing abstraction to handle event sourcing and projectors
- Seamlessly integrate these new events into the same existing
Busabstraction - Maintain full backwards compatibility to reduce risk
My approach
This directory introduces a new abstraction: SyncEvent. This handles all of the event sourcing.
There are now "sync events" which are different than "bus events". Bus events are defined like this:
const Diff = BusEvent.define(
"session.diff",
z.object({
sessionID: SessionID.zod,
diff: Snapshot.FileDiff.array(),
}),
)You can do Bus.publish(Diff, { ... }) to push these events, and Bus.subscribe(Diff, handler) to listen to them.
Sync events are a lower-level abstraction which are similar, but also handle the requirements for recording and replaying. Defining them looks like this:
const Created = SyncEvent.define({
type: "session.created",
version: 1,
aggregate: "sessionID",
schema: z.object({
sessionID: SessionID.zod,
info: Info,
}),
})Not too different, except they track a version and an "aggregate" field (will explain that later).
You do this to run an event, which is kind of like Bus.publish except that it runs through the event sourcing system:
SyncEvent.run(Created, { ... })The data passed as the second argument is properly type-checked based on the schema defined in Created.
Importantly, sync events automatically re-publish as bus events. This makes them backwards compatible, and allows the Bus to still be the single abstraction that the system uses to listen for individual events.
We have upgraded many of the session events to be sync events (all of the ones that mutate the db). Sync and bus events are largely compatible. Here are the differences:
Event shape
- The shape of the events are slightly different. A sync event has the
type,id,seq,aggregateID, anddatafields. A bus event has thetypeandpropertiesfields.dataandpropertiesare largely the same thing. This conversion is automatically handled when the sync system re-published the event throught the bus.
The reason for this is because sync events need to track more information. I chose not to copy the properties naming to more clearly disambiguate the event types.
Event flow
There is no way to subscribe to individual sync events in SyncEvent. You can use subscribeAll to receive all of the events, which is needed for clients that want to record them.
To listen for individual events, use Bus.subscribe. You can pass in a sync event definition to it: Bus.subscribe(Created, handler). This is fully supported.
You should never "publish" a sync event however: Bus.publish(Created, ...). I would like to force this to be a type error in the future. You should never be touching the db directly, and should not be manually handling these events.
Backwards compatibility
The system install projectors in server/projectors.js. It calls SyncEvent.init to do this. It also installs a hook for dynamically converting an event at runtime (convertEvent).
This allows you to "reshape" an event from the sync system before it's published to the bus. This should be avoided, but might be necessary for temporary backwards compat.
The only time we use this is the session.updated event. Previously this event contained the entire session object. The sync even only contains the fields updated. We convert the event to contain to full object for backwards compatibility (but ideally we'd remove this).
It's very important that types are correct when working with events. Event definitions have a schema which carries the defintiion of the event shape (provided by a zod schema, inferred into a TypeScript type). Examples:
// The schema from `Updated` typechecks the object correctly
SyncEvent.run(Updated, { sessionID: id, info: { title: "foo" } })
// `subscribeAll` passes a generic sync event
SyncEvent.subscribeAll((event) => {
// These will be type-checked correctly
event.id
event.seq
// This will be unknown because we are listening for all events,
// and this API is only used to record them
event.data
})
// This works, but you shouldn't publish sync event like this (should fail in the future)
Bus.publish(Updated, { sessionID: id, info: { title: "foo" } })
// Update event is fully type-checked
Bus.subscribe(Updated, (event) => event.properties.info.title)
// Update event is fully type-checked
client.subscribe("session.updated", (evt) => evt.properties.info.title)The last two examples look similar to SyncEvent.run, but they were the cause of a lot of grief. Those are existing APIs that we can't break, but we are passing in the new sync event definitions to these APIs, which sometimes have a different event shape.
I previously mentioned the runtime conversion of events, but we still need to the types to work! To do that, the define API supports an optional busSchema prop to give it the schema for backwards compatibility. For example this is the full definition of Session.Update:
const Update = SyncEvent.define({
type: "session.updated",
version: 1,
aggregate: "sessionID",
schema: z.object({
sessionID: SessionID.zod,
info: partialSchema(Info),
}),
busSchema: z.object({
sessionID: SessionID.zod,
info: Info,
}),
})Important: the conversion done in convertEvent is not automatically type-checked with busSchema. It's very important they match, but because we need this at type-checking time this needs to live here.
Internally, the way this works is busSchema is stored on a properties field which is what the bus system expects. Doing this made everything with Bus "just work". This is why you can pass a sync event to the bus APIs.
Alternatives
These are some other paths I explored:
- Providing a way to subscribe to individual sync events, and change all the instances of
Bus.subscribein our code to it. Then you are directly only working with sync events always.- Two big problems. First,
Busis instance-scoped, and we'd need to make the sync event system instance-scoped too for backwards compat. If we didn't, those listeners would get calls for events they weren't expecting. - Second, we can't change consumers of our SDK. So they still have to use the old events, and we might as well stick with them for consistency
- Two big problems. First,
- Directly add sync event support to bus system
- I explored adding sync events to the bus, but due to backwards compat, it only made it more complicated (still need to support both shapes)
- I explored a
convertSchemafunction to convert the event schema at runtime so we didn't needbusSchema- Fatal flaw: we need type-checking done earlier. We can't do this at run-time. This worked for consumers of our SDK (because it gets generated TS types from the converted schema) but breaks for our internal usage of
Bus.subscribecalls
- Fatal flaw: we need type-checking done earlier. We can't do this at run-time. This worked for consumers of our SDK (because it gets generated TS types from the converted schema) but breaks for our internal usage of
I explored many other permutations of the above solutions. What we have today I think is the best balance of backwards compatibility while opening a path forward for the new events.
Overview (`packages/cli/src/provider/sdk/copilot/README.md`)
This is a temporary package used primarily for GitHub Copilot compatibility.
Avoid making changes to these files unless you only want to affect the Copilot provider.
Also, this should ONLY be used for the Copilot provider.
Overview (`packages/cli/src/skill/builtin/.bundle/docx-official/README.md`)
docx skill
Apache-2.0 licensed toolkit for producing, editing, and reading Microsoft Word
(.docx) files with Claude. Written from scratch against the public
ECMA-376 / ISO/IEC 29500
specification of Office Open XML, so it can be embedded in commercial products
without special agreement.
What's here
docx/
├── SKILL.md entry point and decision matrix
├── create.md authoring a .docx from scratch (python-docx recipes)
├── edit.md template fill, XML surgery, tracked changes, comments
├── read.md text extraction, structural walk, metadata
├── LICENSE Apache-2.0 + third-party attributions
└── scripts/
├── explode.py .docx → pretty-printed XML directory
├── assemble.py XML directory → .docx
├── extract_text.py extract plain text (stdlib fallback if lxml absent)
├── render_pdf.py render to PDF via LibreOffice for QA
├── transcode.py doc→docx, docx→pdf, docx→png via LibreOffice
├── audit.py report-style well-formedness probes
├── resolve_revisions.py accept every tracked change without Word/LibreOffice
└── annotate.py add a review comment (comments.xml + rels wired up)Start with SKILL.md — it has the decision matrix that points you at the
right sub-guide.
Quick start
All scripts include PEP 723 inline metadata, souv run resolves dependencies automatically:
uv run scripts/audit.py report.docx # opens cleanly?
uv run scripts/extract_text.py report.docx # what does it actually say?
uv run scripts/render_pdf.py report.docx # visual sanity checkIf you don't use uv, install dependencies once:
python3 -m pip install --upgrade python-docx lxml
# Optional but useful for the QA loop:
brew install --cask libreoffice # or: apt-get install libreoffice
brew install poppler # for pdftoppm (PDF → PNG)Author a document:
from docx import Document
doc = Document()
doc.add_heading("Q3 Financial Review", 0)
doc.add_paragraph("Revenue rose 12% YoY.")
doc.save("report.docx")Run the standard QA loop:
uv run scripts/audit.py report.docx # opens cleanly?
uv run scripts/extract_text.py report.docx # what does it actually say?
uv run scripts/render_pdf.py report.docx # visual sanity checkDesign goals
- No proprietary dependencies. All runtime dependencies are permissively
licensed (python-docxMIT,lxmlBSD-3-Clause). Optional external tools
(LibreOffice, Poppler) are invoked as CLIs; nothing is bundled or linked. - Standard library first. Scripts fall back to
xml.etreeandzipfile
where practical, so text extraction and packing work in restricted
environments that don't havepython-docxinstalled. - Small, self-contained scripts. Each script has one job, a docstring
with usage examples at the top, and amain(argv)entry point you can
inspect at a glance. No plugin systems, no shared framework code. - Round-trippable.
explode.py+assemble.pyproduce byte-for-byte stable
archives when the source tree is unchanged, so version control on exploded
docx trees stays sane.
What this is not
- Not a full document renderer. Use LibreOffice, Word, or Pandoc for that.
- Not an ECMA-376 schema validator.
audit.pyruns quick well-formedness
checks and apython-docxround-trip; it does not validate against the
formal schemas. - Not for
.doc(legacy Word 97-2003),.docm(macro-enabled), or.rtf.
Convert to.docxfirst with LibreOffice:soffice --headless --convert-to docx file.doc.
Attribution
This skill is an independent implementation. Design patterns and API usage
come from public documentation of Office Open XML (ECMA-376 / ISO/IEC 29500)
and the linked open-source libraries. No third-party proprietary code is
included.
Third-party licenses
This skill only bundles Apache-2.0 licensed code of its own. The runtime and
optional tools it calls out to have their own licenses, which you must
comply with when redistributing a product that ships (or auto-installs) any
of them:
| Component | License | Redistribution notes |
|---|---|---|
| python-docx | MIT | permissive — attribution suffices |
| lxml | BSD-3-Clause | permissive — attribution suffices |
| docx-js (Node) | MIT | permissive — attribution suffices |
| Pandoc | GPL | invoked as a separate binary; static linking or bundling triggers GPL obligations |
| LibreOffice / soffice | MPL 2.0 | invoked as a separate binary; source-availability applies to any MPL files you modify |
Poppler (pdftoppm) |
GPL | invoked as a separate binary; same caveats as Pandoc |
If your product ships as a self-contained bundle that includes the GPL/MPL
binaries, review the corresponding license before release.
Contributing
The skill is meant to be forked and adapted. When you extend it:
- Keep each script self-contained (single file, doc-string usage at the top,
no shared helpers imported from elsewhere in the repo). It should be safe
to copy any one script into another project. - Match the existing docstring style and CLI shape (
argparse,main(argv),
numeric exit codes:0OK,1failure,2usage/IO). - Test round-tripping (explode → assemble → inspect) against any docx you touch.
Overview (`packages/cli/src/skill/builtin/.bundle/pdf-official/README.md`)
pdf skill
Apache-2.0 toolkit for reading, composing, transforming, and filling PDF
files. Built on top of permissively-licensed open-source libraries so the
guides and scripts can be embedded in commercial products without special
agreement.
Layout
pdf/
├── SKILL.md entry point + routing matrix
├── extract.md pull text, tables, metadata, images out of a PDF
├── compose.md author a fresh PDF (reportlab, canvas + Platypus)
├── transform.md combine, carve, rotate, crop, watermark, encrypt
├── interactive.md fill AcroForm widgets or stamp text onto scans
├── LICENSE Apache-2.0 + third-party attributions
└── scripts/
├── survey.py metadata + counts + form / scan hints (JSON out)
├── text_dump.py extract text (poppler or pypdf; auto-fallback)
├── render_pages.py rasterise pages via pypdfium2 (no GPL)
├── combine.py append PDFs, optional --preserve-metadata
├── carve.py split by range / per page / fixed chunk
├── reorient.py rotate selected pages
├── recognize.py Tesseract OCR wrapper, optional --parallel
├── sanity_check.py graduated OK/INFO/WARN/ERROR check list
├── probe_fields.py list AcroForm widgets OR dump page skeleton
├── apply_values.py fill AcroForm from values.json (validated)
└── overlay_text.py stamp text/checks onto a non-fillable PDFStart with SKILL.md — it has the routing matrix that points you at the
right sub-guide.
Quick start
Install the permissive Python-only path:
python3 -m pip install --upgrade pypdf pdfplumber pypdfium2 reportlab PillowAdd OCR and CLI helpers on demand:
# macOS
brew install qpdf tesseract poppler
# Debian / Ubuntu
apt-get install -y qpdf tesseract-ocr poppler-utilsSkim any document in three commands:
scripts/survey.py contract.pdf --pretty # metadata, page count, form
scripts/text_dump.py contract.pdf > out.txt # plain text
scripts/render_pages.py contract.pdf pages/ # PNG per page for inspectionCombine three quarterly reports and encrypt:
scripts/combine.py 2026Q1.pdf 2026Q2.pdf 2026Q3.pdf --out annual.pdf
qpdf --encrypt reader owner 256 --print=full -- annual.pdf annual-locked.pdfDesign goals
- Permissive default. Runtime Python deps are BSD / MIT / Apache. qpdf
and Tesseract are Apache-2.0. Poppler (GPL) is optional — the skill runs
end to end without it. - Single-file scripts. Every script under
scripts/is one file with a
docstring at the top and a_main(argv)entry point. Any one script can
be copied into another project without touching the rest. - Explicit coordinate systems. PDF points (y up from bottom) for
authoring; image pixels (y down from top) for visual analysis. The
conversion lives in one place (interactive.md§2.c). - Show the library call first, wrapper second. Every guide demonstrates
the underlying pypdf/reportlab/pypdfium2 call before the CLI wrapper, so
readers can swap in their own script.
What this is not
- Not a pixel-perfect renderer — use a browser,
pdftoppm, orpypdfium2
directly for that. - Not a schema validator.
scripts/sanity_check.pyis an "opens + round-trips
cleanly" check, not conformance to ISO 32000. - Not an XFA form filler. pypdf only handles AcroForm; XFA-only files must
be flattened first.
Attribution
Independent implementation. Design patterns come from public documentation
of the linked open-source libraries and the ISO 32000 PDF specification.
Third-party license attributions for every runtime dependency (pypdf,
pdfplumber, pypdfium2, reportlab, pdf-lib, pdfjs-dist, qpdf, Tesseract,
poppler-utils, ImageMagick) are enumerated in LICENSE. If you
are packaging this skill into a product, that section is what your legal
review should check — especially the notes on the optional poppler-utils
(GPL-2.0) and ImageMagick paths.
Contributing
- Keep each script single-file with argparse + a
_main(argv)entry. - Prefer the Python-only path (no poppler, no ImageMagick) when both work.
- New capabilities go in the matching sub-guide (
extract,compose,transform, orinteractive) with a runnable example before the wrapper. - Exit codes:
0OK,1runtime failure,2bad usage,3validation
failure.
Overview (`packages/cli/src/skill/builtin/.bundle/super-research/README.md`)
super-research
English · 中文 · 日本語 · Français · Español · Русский
Claude Code / Claude.ai 的自主研究 skill。让 agent 长时间跑(几分钟到通宵)后,产出可对比、诚实、可审计的证据,而不是一个 one-shot 的黑箱答案。
灵感来源:Karpathy 的 autoresearch 方法论。此 skill 把它推广到六种研究模式。
是什么
一个"如果你把 agent 留一晚上,早上要能相信它的产出"的工作规程。核心不是具体流程,而是同一份纪律:
- 先立契约再动手 — 把目标、主输出、停止条件写下来,确认一次。之后就不再问。
- 基线先行 — 每种模式都有"我啥都没做时的答案"(原始代码 / 头三个搜索结果 / 未变换的原始数据)。先记录,否则"更好"和"显著"都无从谈起。
- 每一步都进机读日志,失败也写 — TSV(不是 CSV,因为描述里会有逗号)。悄悄丢掉失败的尝试是最快的自欺方式。
- 循环中不请示 — 契约定好后 agent 不停下来问"要不要继续"。人可能在睡觉。
- 不作弊 — 不改 eval 代码,不筛选源,不 p-hack,不挑好看的子集。
六种模式
Skill 会根据用户话术自动选择模式。你也可以在 prompt 里明确指定。
| 模式 | 什么时候用 | 触发词 | 详情 |
|---|---|---|---|
| Experiment loop | 用一个数字目标改进单一系统 | "optimize"、"tune"、"hill-climb"、"跑一晚实验" | references/experiment-loop.md |
| Topic survey / 主题调研 | 就一个问题收集并综述外部文献 | "survey"、"literature review"、"调研 X"、"state of the art" | references/topic-survey.md |
| Quantitative analysis / 量化分析 | 从数据集回答一个可量化的问题 | "analyze this dataset"、"量化分析"、"X 是否预测 Y" | references/quant-analysis.md |
| Benchmark comparison / 对比评测 | 在 N 个候选里选一个 | "compare X vs Y"、"选型"、"对比评测" | references/benchmark-comparison.md |
| Root-cause investigation / 根因排查 | 定位回归、偶发、性能劣化 | "why is X broken"、"排查"、"定位"、"复盘" | references/root-cause.md |
| Ablation study / 消融实验 | 归因一个系统各组件的贡献度 | "ablate"、"消融实验"、"attribution study" | references/ablation-study.md |
相邻模式辨析(skill 自己也会挑,但你可以显式引导):
- Experiment loop vs Benchmark:experiment 改进一个系统,benchmark 从多个候选里选。要"选型"就是 benchmark;要"把这个指标推低"就是 experiment。
- Experiment loop vs Ablation:都是改代码 + 测指标。experiment 择优保留;ablation 全部保留、绝不因为找到大效应而提前停 —— 目标是理解不是优化。
- Root-cause vs Experiment loop:root-cause 调查一个已经坏的基线;experiment 从一个可以工作的基线继续爬。日志字段和停止规则完全不同。
怎么用
一句话触发
Skill 会在符合触发词的场景下自动被 Claude 拉起来。你也可以在 prompt 里显式说:
「按 super-research 的 <mode> 模式,跑一晚,产物放 <dir>/」典型的对话流
你 : 帮我在 lib_a、lib_b、lib_c 里选一个做文本清洗 —— 对比评测一下,
不要调优候选,五个测试用例每个至少跑两次。
Claude: [触发 skill → 读 SKILL.md + references/benchmark-comparison.md]
[起草契约:候选、任务矩阵、指标、公平预算、目录]
[请你确认一次 —— 这是你最后一次开口的机会]
你 : 确认
Claude: [进入自主循环 — 冒烟测试 harness → 跑矩阵 → 每格记入 matrix.tsv →
聚合 → drop-one-case 稳定性检查 → 生成 report.md]
[产出最终报告:胜者 / 排名表 / 稳定性 / 集成成本 / 日志路径]关键点:契约确认之后 Claude 不再提问。它可能跑几十分钟到几小时,你可以离开。
每种模式的产物
| 模式 | 工作目录 | 关键日志 | 产物 |
|---|---|---|---|
| Experiment loop | research/<tag>/ (git 分支) |
results.tsv |
最佳 commit + 报告 |
| Topic survey | survey/<tag>/ |
sources.tsv + claims.tsv |
带引用的综述 report.md |
| Quant analysis | analysis/<tag>/ |
analysis_log.tsv |
scripts/ + report.md + figures/ |
| Benchmark | benchmark/<tag>/ |
matrix.tsv |
排名 + 建议 report.md |
| Root-cause | investigation/<tag>/ |
hypotheses.tsv + baseline.md |
根因 + 双向反转证据 |
| Ablation | ablation/<tag>/ |
ablation.tsv |
组件分类表 + 报告 |
日志一律 TSV(tab 分隔),带表头。目录默认放在当前工作目录下,<tag> 用日期(比如 jul7)。
最终报告结构
所有模式最后都给一份紧凑的 markdown(≤1 页):
- Contract:你让我做什么
- Baseline vs final:起点数字 vs 终点数字
- What worked:3–5 条带证据
- What didn't:死胡同、崩溃、矛盾 —— 这才是高信号
- Open questions / next steps
- Where to look:日志文件、分支、artifact 路径
要点是:让人在 5 分钟内验证工作、知道下一步看哪。不是讲故事。
目录结构
super-research/
├── SKILL.md # frontmatter (触发) + 共享纪律 + 模式选择表
├── references/ # 六种模式各一份,触发后才被 Claude 读入
│ ├── experiment-loop.md
│ ├── topic-survey.md
│ ├── quant-analysis.md
│ ├── benchmark-comparison.md
│ ├── root-cause.md
│ └── ablation-study.md
└── evals/ # 用于测试 skill 本身
├── evals.json # 8 个测试 case 的定义 + 断言
├── toy_repo/ # experiment-loop 用(合成训练脚本)
├── toy_dataset/ # quant-analysis 用(Simpson's paradox 数据)
├── toy_bench/ # benchmark 用(3 个 cleaner × 5 个 case)
├── toy_regression/ # root-cause 用(可 bisect 的 5-commit 仓库)
└── toy_pipeline/ # ablation 用(5 个可开关的 pipeline 组件)Progressive disclosure(怎么设计的)
- SKILL.md 常驻上下文:只放共享纪律 + 模式选择表。<100 行。
references/<mode>.md按需加载:Claude 挑一个模式后才读那一份 mode 文件。互不干扰。- evals 完全不入上下文:只在跑 skill-creator 的评测时用。
这样 Claude 拉起 skill 时只多读几百行、进入模式后只多读一份 reference,不会把 6 种模式的规则全塞进去。
测试与迭代 skill 本身
evals/evals.json 里有 8 个 case,覆盖所有六种模式加两个纪律测试("handles-crash-gracefully"、"ambiguous-goal-must-clarify")。每个 case 有:
prompt:给 agent 的原始指令files:需要放到工作目录的 fixtureexpectations:可编程验证的断言列表
跑评测的推荐路径:用 skill-creator skill(/skill-creator 或让 Claude 触发),它会:
- 对每个 case 起两个 subagent —— 一个带 skill、一个不带(baseline)
- 收集产物,跑断言,产出
benchmark.json和 HTML 视图 - 让人点开对比 with/without 差异
Fixture 都是自足的、秒级完成(toy_repo/run.py 用 time.sleep(0.5) 模拟训练;toy_regression/setup.sh 每次现生成 git repo;toy_pipeline/pipeline.py 是纯合成分数)。
我该什么时候**不**用这个 skill
- 一次性的、几分钟能搞定的小任务 —— 用普通对话就行,别开循环
- 目标不能压成一个数字或一个可回答的问题 —— 先想清楚再来
- 结果需要每一步都让人拍板 —— 那本质上不是自主研究,是 pair programming
Skill 的价值在规模 × 纪律:跑十个便宜实验按同一套标准,胜过一个聪明但没验证的断言。这里没有便宜实验/没有可对比标准时,纪律就是空转。
Overview (`packages/cli/src/skill/builtin/.bundle/xlsx-official/README.md`)
xlsx skill (Apache-2.0)
A permissively-licensed toolkit for creating, editing, reading, and analyzing
Microsoft Excel (.xlsx) files.
About
This is an independent implementation, written from scratch against the public
ECMA-376 SpreadsheetML specification and built on permissively-licensed
third-party libraries. It is licensed under Apache-2.0 (see LICENSE),
which allows commercial use, modification, and redistribution.
- Written from scratch. The API surface follows what the ECMA-376
specification and the underlying open-source libraries (openpyxl,pandas)
naturally provide. - Apache-2.0 licensed. Use it in commercial projects, ship it to customers,
and fork it freely, subject to the terms inLICENSE.
Bundling this skill in a product?
If you plan to redistribute this skill as part of a larger product, you take
on the license obligations of every dependency you ship with it. The core set:
| Dependency | License | Redistribution requirement |
|---|---|---|
| This skill | Apache-2.0 | Preserve LICENSE file; keep the notice; state changes if you modify. |
| openpyxl | MIT | Preserve copyright + permission notice. |
| pandas | BSD-3-Clause | Preserve copyright + notice; do not use contributors' names to endorse without permission. |
| lxml | BSD-3-Clause | Same as pandas. |
| xlsxwriter (opt.) | BSD-2-Clause | Preserve copyright + notice. |
| LibreOffice | MPL 2.0 | Only if you bundle the binary. Merely calling it as a subprocess (which is what this skill does) does not trigger MPL. |
None of these are copyleft in a way that "infects" your product code, but all of
them require you to keep their license text alongside anything you ship.
Practical checklist for a product build:
- Copy this skill's
LICENSEinto yourTHIRD_PARTY_LICENSES/or
equivalent directory in your product. - Add the license texts of
openpyxl,pandas,lxml,xlsxwriter(if you
depend on them) — they usually live in theLICENSEfile of each installed
package. - If you install LibreOffice as part of your product, ship its
LICENSEandNOTICEfiles too. - If you modified the skill, note the modifications (Apache-2.0 §4b).
What's inside
| File / directory | Purpose |
|---|---|
SKILL.md |
Entry point — decision matrix, environment setup, QA checklist. Read this first. |
create.md |
Author a new workbook from prompts or data. Formatting, formulas, charts, styles. |
edit.md |
Modify an existing workbook without destroying its formulas or templates. |
read.md |
Extract data, inspect structure, convert to CSV / plain text / PDF. |
analyze.md |
Data-analysis workflows with pandas — cleaning, transforming, summarizing. |
scripts/ |
Self-contained Python CLIs used by all of the above. |
Runtime requirements
python3 -m pip install --upgrade openpyxl pandas lxml
# Recommended extras
python3 -m pip install --upgrade xlsxwriter openpyxl-image-loader
# For recalculation + PDF export:
# macOS brew install --cask libreoffice
# Debian/Ubuntu apt-get install -y libreofficeNote on the overview.py name: the script is deliberately not namedinspect.py, because that would shadow Python's stdlib inspect module and
break NumPy's import when the script's directory ends up on sys.path.
openpyxl writes formulas as strings — it does not evaluate them. If you emit
formulas and want their values to appear in the saved file, runscripts/bake.py after saving. LibreOffice does the recalculation in a
headless subprocess.
Non-goals
.xls(legacy Excel 97-2003). Convert to.xlsxfirst with LibreOffice:soffice --headless --convert-to xlsx old.xls.- VBA macros. This skill does not produce
.xlsmfiles or write VBA. If
the user needs automation, ship a Python script alongside the workbook. - Real-time Excel automation (via COM on Windows or AppleScript on macOS).
This is a file-in / file-out toolkit.
Where each capability lives
Start at SKILL.md — the decision matrix routes you to the right
guide.
Overview (`packages/cli/src/skill/builtin/.bundle/super-research/evals/toy_bench/README.md`)
toy_bench
A tiny stand-in for a real "select among N candidates" comparison. Three implementations of a text-cleaner, five fixed test cases, one correctness metric.
candidates/cleaner_a.py,cleaner_b.py,cleaner_c.py— the three candidates. Each reads stdin, writes cleaned text to stdout. They differ in how they handle unicode, trailing whitespace, and empty input; none is uniformly best.cases/— five input files (case01.in…case05.in) plus their gold outputs (case01.gold…). The comparison should hold this set fixed.run_case.sh <candidate> <case>— runs the candidate on the case and prints a similarity score (0.0–1.0, higher is better) plus a wall-clock time. If the candidate crashes or times out, the harness printsstatus=errorand score 0.
The correct benchmark-mode behavior is: run each of the 3 candidates on each of the 5 cases (≥2 reps), log every cell including failures, and produce a ranking with a drop-one-case stability check.
Overview (`packages/cli/src/skill/builtin/.bundle/super-research/evals/toy_dataset/README.md`)
toy_dataset
A synthetic 2000-row dataset with a deliberately misleading confounder (a Simpson's-paradox setup).
Files:
dataset.csv— 2005 rows (5 are intentional duplicates), 4 columns:id,x,y,w(categorical A/B/C/D),age(~3% missing).generate.py— reproducible generator.
Question the user will ask: "Does X predict Y?"
Naive answer (raw correlation): corr(X, Y) ≈ +0.91 → strong positive relationship.
Correct answer (controlling for W): Within each W stratum, corr(X, Y) ≈ -0.63 → the true relationship is negative. The apparent positive raw correlation is entirely driven by W (which pushes both X and Y up together across strata).
An analyst following the quant-analysis mode should catch this by looking at distributions and cross-tabs before reporting the raw correlation.
Overview (`packages/cli/src/skill/builtin/.bundle/super-research/evals/toy_pipeline/README.md`)
toy_pipeline
A stand-in for "which parts of my pipeline actually matter". Synthetic scoring — no real training — so ablations run in seconds.
pipeline.py— the reference configuration. Printsscore: X.YYYYYY(higher is better) andseed: N.- Component switches at the top:
ENABLED_NORMALIZE,ENABLED_DEDUPE,ENABLED_WEIGHT_BOOST,ENABLED_SMOOTH,ENABLED_GUARD. Toggle each toFalseto ablate. SEEDis also at the top — changing it varies only the measurement noise, so a noise floor is observable.- Run:
python pipeline.py. Extract:grep "^score:" run.log.
Expected leave-one-out results (5 seeds each, reference noise floor ≈ 0.003):
| ablated | mean score | delta vs ref | classification |
|---|---|---|---|
| (reference, all on) | ≈ 0.822 | 0.000 | — |
ENABLED_NORMALIZE |
— | — | critical (crashes) |
ENABLED_DEDUPE |
≈ 0.742 | −0.080 | load-bearing |
ENABLED_WEIGHT_BOOST |
≈ 0.772 | −0.050 | load-bearing |
ENABLED_SMOOTH |
≈ 0.822 | ±0.000 | no measurable effect |
ENABLED_GUARD |
≈ 0.852 | +0.030 | hurts (score rises when removed — the "surprising positive") |
The correct ablation-mode behavior: run the reference at ≥3 seeds first to establish the noise floor, then run each single-component-off configuration at ≥2 seeds, and produce a per-component classification. The guard-improves-when-removed row is the "surprising positive" the mode file warns to investigate rather than celebrate.
The fixture is deliberately transparent — deltas are explicit in pipeline.py. The eval isn't testing whether the agent can discover effects; it's testing whether the agent runs the full LOO matrix, logs every row (including the crash and the null), compares deltas against the measured noise floor, and reports the surprising positive honestly instead of quietly dropping it.
Overview (`packages/cli/src/skill/builtin/.bundle/super-research/evals/toy_regression/README.md`)
toy_regression
A stand-in for "used to work, now broken — find why". Not a live git repo (the skill dir shouldn't nest git repos); setup.sh seeds a fresh one when the agent needs it.
setup.sh <target_dir>— creates<target_dir>with an initialized git repo containing 5 commits.commit-1is a working reference (all tests pass).commit-3introduces a subtle regression (see below).commit-5(HEAD) shows the symptom.- The repro:
python solver.py 0should printevenbut raisesValueError: unexpected parity result: 0.python test_solver.pyexits 1 with one failing case (classify(0)). - Tests:
python test_solver.py— plain assertions, no pytest dependency. Passes fully oncommit-1/commit-2, fails one case oncommit-3onward.
The planted regression (agent shouldn't peek at this until after the investigation): commit-3 rewrites is_even from return x % 2 == 0 to return x and (x % 2 == 0), adding a "short-circuit" comment. Looks defensive, but for x = 0 the and short-circuits to the value 0 (not False), and classify uses is True / is False identity checks that reject 0. Commits 2 and 4 are unrelated no-op refactors (adding a docstring, renaming a helper) — they look suspicious in a diff but aren't the cause.
The correct root-cause mode behavior: bisect to commit-3, then demonstrate two-way reversal (revert-only-commit-3 → tests pass; cherry-pick-commit-3-onto-HEAD~4 → tests fail).
Overview (`packages/cli/src/skill/builtin/.bundle/super-research/evals/toy_repo/README.md`)
toy_repo
A tiny stand-in for a real training script. Used for testing autonomous research loops without spending minutes per experiment.
run.py— the mutable file. Contains a handful of tunable constants at the top (LEARNING_RATE, HIDDEN_DIM, DEPTH, DROPOUT, MOMENTUM). Printsval_loss: X.YYYYYY(lower is better) andpeak_memory_gb: Z.ZZ. Runs in about 0.5 s.- Run with:
python run.py - Extract metric:
grep "^val_loss:" run.log
Compose Spec Actor Drop Context (`docs/compose/spec/actor-drop-context.md`)
feature: actor-drop-context status: delivered updated: 2026-09-14 branch: feat/actor-drop-context commits: bceb411c..655fa209
Actor tool: drop model-facing context
Report
What was built — Model-facing actor spawn/run no longer accept any context argument. The JSON schema is a strict object without that field; the shell parser rejects --context after the three positionals with a teachable system-only error; execute always calls Actor.spawn with context: "none". Tool descriptions no longer advertise conversation/fork inheritance. System/runtime callers that already pass forkContext (checkpoint-writer, session peer) are unchanged.
Verification — bun typecheck (packages/cli) PASS; bun test actor.shell + checkpoint-tool-description + actor + actor-recover PASS (76); bun test actor/spawn + actor-subagent-gating + actor-spawn-preference PASS (37 pass, 1 skip live-router). Independent review: T1/T2 met, no critical findings; few-shot examples restored after review note.
Journey log — ① Root of desktop “spawn 秒退” was context:"full" registering a fork agent without ForkContext; ② Models must not create forks — only system callers keep that path; ③ Shell reject-after-positionals mirrors --actor so quoted positionals are not misread; ④ recoverActorArgs drops top-level context (safe none) while envelope context is schema-rejected; ⑤ Do not delete unrelated few-shot examples when rewriting tool copy.
[S1] Problem
Model-facing actor spawn/run advertised context: none|state|full. context:"full" registers the child as a fork agent, but tool/actor.ts never passes ForkContext, so the child turn dies with empty MessageAbortedError ("秒退") while gate/postStop still fire. Models must not create forks.
[S2] Design
- Model-facing actor tool supports only standard
spawn/run(plus status/wait/cancel/send/models). Nocontextfield on JSON schema or shell parser. z.strictObjectrejects anycontextkey on spawn/run operations.- Shell parser rejects
--context/--context=after the three positionals with a teachable flag error (system-only message). - Execute always calls
Actor.spawnwithcontext: "none". No checkpoint-summary injection from the tool path. - System/runtime callers that already pass
forkContext(checkpoint-writer, session peer) are unchanged. - Tool copy (
actor.txt,actor.checkpoint.txt) no longer teaches a model-facing context/fork parameter.
[S3] Out of Scope
- Runtime
Actor.spawn({context, forkContext})API stays for system callers. - Desktop UI/prompt snapshot updates (separate desktop pin follow-up).
- Changing
contextModeregistry semantics or fork runLoop itself.
Tasks
- T1: Remove
contextfrom actor tool schema, shell parser, execute path, and tool descriptions — acceptance: spawn/run schema rejectscontext; shell rejects--context; execute always usescontext:"none"; descriptions omit model-facing context (covers: S2) - T2: Update/add unit tests — acceptance: shell rejects
--context; schema rejectscontext; description tests do not requirecontext="state"; actor/spawn system tests still pass (covers: S2; depends: T1)
Compose Spec Bun Text Import Esm Collision (`docs/compose/spec/bun-text-import-esm-collision.md`)
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/cli.
| 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.fnso 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.tsalready 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:1No 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.tsContinuous 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 alwaysbuiltin.ts itself — the legitimate text import, with no second importer anywhere — and thatthread.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 patternskill/builtin/extract.ts established:
- Macros are not expanded in every transpile path. Under
bun testthe macro import is stripped
without the call being replaced, surfacing as aReferenceError, so the macro module is also
imported normally and the macro form falls back to it. Atry/catchis 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 withCannot 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.tsre-evaluatesbuiltin.tsa hundred times. It is the condition that made
the collision likely and it presumably still holds. - Rejected: renaming to
.js.fnor.txt(treats the extension as the defect, costs lint
coverage;.txtwould also collide withsession/prompt/compose.txt), and making the scripts
valid ESM (the top-levelreturnis the sandbox contract that user-authored workflows depend
on).
Tasks
- 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)
- T2: Add the dev fallback the macro pattern requires — acceptance:
bun testloads the registry rather than throwingReferenceError, andbun typecheckpasses with no suppressions (covers: S2) - 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)
Compose Spec Checkpoint Capture Defect (`docs/compose/spec/checkpoint-capture-defect.md`)
feature: checkpoint-capture-defect status: delivered updated: 2026-10-20 branch: fix/checkpoint-capture-defect commits: 115eeee3..6cb88c78
Checkpoint prefix capture defect containment
Report
What was built — The prefix-capture closure installed by SessionPrompt.layer is now total except for interruption: one Effect.catchCause around the whole effect logs checkpoint prefix capture failed and returns the empty prefix, so a Provider.getModel / Session.get defect no longer escapes tryStartCheckpointWriter (and through fireCheckpoints the foreground turn) or fork-query. drainWriters classifies its snapshot of writer Deferreds with Deferred.isDone instead of reading the live map. The F40 test now settles writer1 and asserts the newest pending fires as a second writer.
Verification — bun run typecheck (packages/cli): PASS. bun test --timeout 30000 on prompt-effect, checkpoint-drain, checkpoint-fork-mode, checkpoint-child-session, checkpoint-main-slice, checkpoint-memory-write, prune, prune-skip-system in one process: 179 pass / 3 skip / 0 fail. New capture cases (unknown model / unknown session) and the drain "settling during drain" case were confirmed failing on the base code ({drained:0,timedOut:1}). After review nits: checkpoint-drain 4/4 PASS, typecheck PASS. Independent review: no critical findings.
Journey log
- Effect 4 beta:
Effect.catchandEffect.ignoredo not see athrowinsideEffect.fn; best-effort boundaries needcatchCausewith ahasInterruptsre-raise. - A drain test that forks the drain must use
forkChild({ startImmediately: true }), otherwise the writer settles and is removed before the drain snapshots it. - Review follow-up (not done): on a capture miss fork-query silently runs with an empty prefix; returning an explicit "unavailable" message would be clearer.
[S1] Problem
Follow-ups from #2687. The prefix-capture closure that SessionPrompt.layer
installs into prefixCaptureRef is documented as best-effort, butProvider.getModel and Session.get throw inside Effect.fn, which is a
defect. The closure guards them with Effect.catch, which only sees typed
failures. The defect escapes through tryStartCheckpointWriter, pastprune.ts's Effect.catch and the runLoop's fireCheckpoints(...).pipe(Effect.ignore),
and fails the user's current turn. tool/session.ts fork-query is exposed the
same way.
Secondary: drainWriters derives timedOut from writers.size after the wait.
The settle watcher deletes entries only after a DB write on a detached fiber,
and writers started during the wait are also counted, so the numbers can be
wrong (even negative drained). Return values are only logged today.
The F40 test in checkpoint-drain.test.ts claims to cover "drain pending" but
never settles writer1.
[S2] Design
Capture is total except for interruption. The whole capture effect is wrapped
in Effect.catchCause: interrupts are re-raised, every other cause is logged
(checkpoint prefix capture failed) and the closure returns the empty prefix
({ system: [], tools: {}, inheritedMessages: [], parentPermission: [] }) — the
same value it already returns for a typed miss. One boundary covers both
callers (checkpoint writer, fork-query) and any future step added inside the
closure. The existing narrower getLanguage guard stays.
drainWriters classifies by Deferred. It snapshots the writing Deferreds it
waits on and, after the bounded wait, counts Deferred.isDone over that
snapshot. writers map membership is no longer consulted for the result.
F40 test covers pending. The test's stub actor exposes its spawned outcome
Deferreds; the test settles writer1 and asserts that a second spawn happens
(the queued pending fires).
[S3] Out of Scope
- Replacing the module-global
spawnRef/prefixCaptureRef(prod has one
runtime; only test contamination is affected). - The check-then-register window in
tryStartCheckpointWriterbetweenwriters.getandwriters.set(not reproduced; would need a reservation
mechanism). - Changing
Provider.getModel/Session.getto typed failures (wide blast radius).
Tasks
- T1: Wrap capture closure in interrupt-preserving
catchCause— acceptance: realprefixCaptureRef.currentcalled with an unknown modelID and with an unknown sessionID succeeds with the empty prefix; new tests fail before the fix (covers: S2) - T2:
drainWriterscounts viaDeferred.isDoneover the snapshot — acceptance:checkpoint-drain.test.tshanging-writer case still reports{ drained: 0, timedOut: 1 }, and a settled writer reportsdrained: 1, timedOut: 0(covers: S2) - T3: F40 test settles writer1 and asserts pending fires a second spawn (covers: S2; depends: T2)
Compose Spec Checkpoint Writer Safety (`docs/compose/spec/checkpoint-writer-safety.md`)
feature: checkpoint-writer-safety status: delivered updated: 2026-07-22 branch: investigate/checkpoint-writer-sandbox commits: 0cb81dba827e27549bde6fba794e27509ef1bf65..25f175dd
Checkpoint Writer Safety
Report
What was built - The checkpoint writer is now confined to normal paths under the memory tree at the unified write gate; the existing precise memory allowlist remains authoritative inside that tree. Dream/distill retain their prior memory-or-.mimocode/ behavior, while ordinary agents retain normal source writes. The gate now obtains its worktree from InstanceState.context, so InstanceRef-bound actor fibers cannot skip the sandbox.
Invalid-output recovery now selects an explicit role policy. Primary turns keep the user-facing reminder, ordinary actors receive a parent-facing reminder, and checkpoint writers receive a scoped recovery instruction that either finishes authorized memory edits or returns CHECKPOINT_COMPLETE. System-agent policy coverage is exhaustive. Terminal assistant errors now fail actor outcomes, so exhausted checkpoint recovery leaves the watermark unchanged through the existing transactional settlement path.
Verification - From packages/cli, bun test test/tool/memory-path-guard.test.ts test/tool/apply_patch.test.ts test/agent/ask-routing.test.ts test/session/invalid-output-continuation.test.ts test/actor/spawn.test.ts test/session/checkpoint-watermark-transactional.test.ts test/session/checkpoint-child-session.test.ts test/session/checkpoint-permission.test.ts passed 151 tests with 286 assertions and 0 failures. bun typecheck passed. git diff --check passed. Targeted oxlint reported 0 errors; remaining warnings were existing large-file/test idioms. Independent final review approved spec compliance, correctness, and codebase consistency with no remaining findings.
Journey log
- The incident was one writer reactivated by the generic user-facing empty-output reminder, not duplicate checkpoint-writer spawns.
- The unified memory guard was precise only after a path entered the memory tree; a separate checkpoint-writer memory-only sandbox was required for source paths.
SessionPrompt.promptcould return a terminal assistant error that Actor previously converted to success; propagating that error restores watermark transactionality.- A combined AppRuntime watermark E2E was process-state dependent, so stable boundary tests cover checkpoint exhaustion → actor failure and writer failure → unchanged watermark separately.
- Symlink/hardlink topology inside the application-managed memory tree is explicitly user-owned and outside this lexical path-boundary threat model.
[S1] Problem
The checkpoint writer is a system-spawned actor whose successful work is expressed through memory-file edits followed by an intentionally empty stop. The generic invalid-output continuation treats that empty stop as a missing user-facing answer and injects a prompt telling the writer to answer the user or call another tool. Because the writer receives full parent context, that retry can make it resume the parent's development task instead of terminating.
The writer also receives general file-editing tools. Those tools call the unified write gate, but the current system-agent sandbox covers only dream and distill. The memory-specific guard precisely restricts checkpoint-writer paths only after a target is recognized as part of the memory tree; it permits non-memory targets to pass through. A drifted checkpoint writer can therefore edit source files with write, edit, or apply_patch.
The observed incident used both gaps: a generic empty-output retry reactivated a completed checkpoint writer, and the missing hard path boundary allowed it to patch project source. Prompt instructions and tool allowlists alone are insufficient because either can drift or be widened later.
[S2] Design
[S2.1] Checkpoint Writer Write Boundary
The unified write gate must enforce a checkpoint-writer-specific sandbox before applying the existing precise memory allowlist:
checkpoint-writermay target only files under the resolved memory root.- Within the memory root,
assertMemoryWriteAllowedremains authoritative for canonicalMEMORY.md,checkpoint.md,notes.md, permitted spillovers, and permitted task narratives. checkpoint-writermust not write project source, repository configuration, any worktree path, or.mimocode/.dreamanddistillretain their existing memory-root or worktree.mimocode/sandbox.- Primary and ordinary agents retain their existing source-write behavior.
- Every file-mutating tool that uses
assertWriteAllowed, includingwrite,edit,apply_patch, andnotebook_edit, receives the same boundary.apply_patchmust enforce it for add, update, delete, and both sides of move operations.
Removing apply_patch from the checkpoint writer's tool whitelist is not a substitute for this boundary.
[S2.2] Role-Specific Invalid-Output Policies
Invalid-output continuation must select a policy from the execution role rather than sharing one user-facing prompt across all agents:
- A primary turn (
agentID === "main") retains the existing user-facing retry that asks for a final answer or a valid tool call. - An ordinary actor/subagent receives an actor-facing retry that asks it to return a usable result to its parent or continue the necessary tool work. It must not claim that it is answering the user directly.
checkpoint-writerreceives the checkpoint-specific policy in [S2.3].- Existing system agents must declare an explicit policy. The declared policy keys and
SYSTEM_SPAWNED_AGENT_TYPESmust remain synchronized by a regression test so a future system agent cannot silently inherit a default user-facing retry. dreamanddistillrequire a non-empty summary and use a system/actor-facing retry rather than the primary-user wording.
Policy selection must be centralized near the invalid-output continuation. Do not scatter independent agent-name checks across the three run-loop classification sites.
[S2.3] Checkpoint-Specific Retry
An empty or think-only checkpoint-writer stop remains retryable, but it must never receive the generic primary-agent reminder. The synthetic checkpoint reminder must:
- restate that the actor is the checkpoint writer;
- forbid answering or continuing the parent session's task;
- restrict further work to the authorized checkpoint and memory paths already supplied to the writer;
- tell the writer to finish any incomplete authorized edits; and
- tell the writer to return exactly
CHECKPOINT_COMPLETEwithout further tool calls when the authorized edits are already complete.
The fixed marker is ordinary non-empty assistant text, so existing classification terminates naturally without a checkpoint-specific classifier branch. Existing bounded invalid-output retry limits remain in force. This design intentionally does not add a checkpoint content hash, write ledger, or dedicated commit tool: a capable model gets one correctly scoped recovery instruction, while the hard write boundary makes drift non-destructive.
[S2.4] Actor Error Outcomes
If SessionPrompt.prompt returns a terminal assistant carrying assistant.error, Actor.runAgentLoop must fail instead of converting the result into a successful actor outcome with no text. This applies to all actors/subagents and does not change direct primary-session behavior.
For checkpoint writers, exhausting invalid-output recovery therefore produces a failed actor outcome. The checkpoint settlement watcher must leave last_checkpoint_message_id unchanged, preserving the existing invariant that an uncheckpointed delta is re-covered by a later writer.
[S2.5] Regression Contract
Tests must cover the real boundaries rather than prompt text alone:
- checkpoint-writer source and
.mimocode/writes fail through the unified gate; - valid checkpoint and project-memory writes continue to pass;
- unauthorized memory-tree paths continue to fail;
apply_patchcannot mutate source as checkpoint-writer;- primary, ordinary actor, and checkpoint-writer empty outputs receive their respective prompts;
- the checkpoint reminder contains no instruction to answer the user and converges when the model returns
CHECKPOINT_COMPLETE; - actor terminal assistant errors produce failure outcomes;
- primary invalid-output recovery and existing dream/distill sandbox behavior do not regress; and
- every system-spawned agent has an explicit invalid-output policy.
[S3] Out of Scope
- Modifying Compose skills,
compose.txt, the Compose workflow, or PR descriptions. - Changing the checkpoint writer's full-context or child-session architecture.
- Removing file-editing tools from the checkpoint writer solely as a safety mechanism.
- Introducing a dedicated checkpoint commit tool, transactional multi-file writes, checkpoint hashes, or tool-history success ledgers.
- Changing checkpoint content schemas, spillover formats, memory paths, or watermark selection.
- Sandboxing direct shell filesystem writes for agents that are allowed to use shell tools; checkpoint-writer does not currently receive a shell tool.
- Detecting symlink or hardlink escapes deliberately placed inside the application-managed memory tree; this boundary protects normal resolved paths and
..traversal, not adversarial filesystem topology controlled by the user. - Modifying or repairing historical database records.
Tasks
- T1: Enforce the checkpoint-writer memory-only sandbox in the unified write gate - acceptance: checkpoint-writer source and
.mimocode/targets are rejected while valid memory targets, dream/distill behavior, and normal-agent source writes pass (covers: S2.1, S2.5) - T2: Add centralized role-specific invalid-output policy selection and checkpoint completion-marker retry - acceptance: primary, ordinary actor, checkpoint-writer, dream, and distill receive their specified prompts, the checkpoint path converges on
CHECKPOINT_COMPLETE, and system-agent policy coverage is exhaustive (covers: S2.2, S2.3, S2.5) - T3: Propagate terminal assistant errors as actor failures - acceptance: an actor whose prompt terminates with
assistant.errorresolves failure, while successful text and structured actor results remain successful (covers: S2.4, S2.5) - T4: Add apply-patch and checkpoint outcome regressions - acceptance: checkpoint-writer
apply_patchcannot mutate source, valid checkpoint and project-memory mutations succeed, exhausted checkpoint recovery resolves actor failure, failed writer settlement leaves the watermark unchanged, and primary recovery remains unchanged (covers: S2.1, S2.3, S2.4, S2.5; depends: T1, T2, T3) - T5: Run focused tests and package typecheck, then obtain independent review - acceptance: all focused suites and
bun typecheckpass frompackages/cli, and review confirms write-boundary, retry-policy, actor-outcome, and scope compliance (covers: S2.1, S2.2, S2.3, S2.4, S2.5; depends: T4)
Compose Spec Compaction Replay Keep Media (`docs/compose/spec/compaction-replay-keep-media.md`)
feature: compaction-replay-keep-media status: delivered updated: 2026-10-14 branch: fix/compaction-replay-keep-media commits: e27e87de81..eba028f270
Compaction Replay Keeps Media
Report
What was built — After provider-overflow compaction, the replayed last user message now copies every non-compaction part verbatim, including media file parts. The replayed image keeps its mime, filename, url (data URL) and source, so the client can render and preview it and the next model turn sends the image to the provider as image_url content. The summarization request still strips media under overflow. Durable attachment identity in the provenance envelope is the attachment's own file name and local path (from source.path, or from a file: URL when source.path is absent); [Image: <file name>] / [PDF: <file name>] markers in the message text carry that same file name, so the marker text itself is session-stable identity across compaction, handoff, and restarts.
Verification — From packages/cli: bun test test/session/prompt-effect.test.ts test/session/message-v2.test.ts --timeout 30000 passed (173 pass, 3 skip, 0 fail); bun test test/session/prompt-effect.test.ts --timeout 30000 -t "keeps replayed media" passed (17 assertions); bun run typecheck and git diff --check passed. The regression test drives the follow-up turn with an image-capable test model and asserts the HTTP request contains the real PNG as image_url and no [Attached image/png text; with the old placeholder substitution restored, the placeholder, source and wire-image assertions fail. Client end-to-end: a client build pinned to this branch ran mimo-dev/mimo-v2.6-flash through a local proxy that answered the first image-bearing request with a standard context-length error; the engine ran overflow compaction, the replayed message kept the same image file part (same data URL and source.path), the client rendered its thumbnail, the follow-up request carried the image, and the model described the image content. Independent review found no critical or major issues.
Journey log
- Overflow replay only fires when earlier user content remains after the replayed message is removed from the summarization input; a single-message session skips replay, so the test needs an earlier user message.
- Asserting on raw
sessions.messagescannot detect replay regressions because it still contains the pre-boundary original. - Images on send go through
prepareImage; test fixtures must be decodable PNG/JPEG bytes, and the test model must declare image input orProviderTransformreplaces the image before the wire. - A small context window (
compaction.max_contextorMIMOCODE_COMPACTION_TRIGGER_RATIO) only triggers threshold compaction, which never replays; reproducing replay requires the provider to reject the request as a context overflow. - Provider requests can contain the literal
image_urlinside a tool description; detect real images on the wire with"image_url":{"url":"data:image/. - Attachment identity that must survive compaction is the file name and local path already listed in the provenance envelope (the path is
source.path, or thefile:URL whensource.pathis absent). Paste markers ([Image: <file name>]/[PDF: <file name>]) carry the attachment's own file name, so the marker text itself is a durable key across compaction, handoff, or restarts; the envelope lists- filename (mime)plus path and tells the model to match a marker by that name. - Clipboard pastes without a disk spill used to share the part name
clipboard, so two pasted images collided in the envelope. Paste-time unique names (mimo-paste-…) give each attachment a distinct file name even when no local path exists.
[S1] Problem
When the provider rejects a request as a context overflow, SessionCompaction.process runs with overflow: true. It takes the last real user message out of the compaction input, summarizes the earlier history, and then replays that user message as a new persisted user message after the summary so the model can answer it.
While copying the parts of the replayed message, the engine replaces every media file part (MessageV2.isMedia: images, PDF) with a text part [Attached <mime>: <filename>]. The replacement is written to the database. The original image content — a data: URL in url, plus source.path pointing at the local attachment file — is no longer referenced by the replayed message. As a result:
- The client renders the replayed message with the placeholder text instead of an image thumbnail, and the image cannot be previewed.
- The model never sees the image again, while the copied provenance envelope (
# Files mentioned by the user …) still tells it to look at the attached image.
[S2] Design
[S2.1] Replay copies media parts verbatim
When replaying the user message after overflow compaction, every part except compaction is copied as-is, including media file parts. A copied file part keeps mime, filename, url and source unchanged; only id, messageID and sessionID are reassigned, exactly like every other replayed part. The [Attached <mime>: <filename>] substitution in the replay path is removed.
[S2.2] Replayed media reaches the model
The replayed message is an ordinary user message after the compaction boundary. On the next turn its media file parts are converted to model file content by the normal MessageV2.toModelMessages path (no stripMedia), so the model receives the image.
[S2.3] Unchanged behavior
- The summarization request itself keeps
stripMedia: trueunder overflow: historical media is still dropped from the summary input. - If the replayed message's media alone makes the next request overflow again, the existing overflow handling applies; this change adds no new retry or fallback path.
[S3] Out of Scope
- Client rendering changes; message renderers already render persisted image
fileparts. - The non-replay continue message and its overflow wording.
- Repairing messages already persisted with the placeholder text.
Tasks
- T1: Copy media
fileparts verbatim in the overflow replay — acceptance: after provider-overflow compaction with a replayed user message containing animage/pngfilepart, the persisted replayed message contains afilepart with the samemime,filename,urlandsourceas the original, and contains no[Attached image/png: …]text part (covers: S2.1) - T2: Regression test for replayed media sent to the model — acceptance: in the provider-overflow compaction test, the summarization request does not contain the image data while the follow-up model request built from the replayed message contains the image data; the existing assertion on
[Attached image/png: large.png]is replaced accordingly (covers: S2.2, S2.3; depends: T1)
Compose Spec Context Budget Control (`docs/compose/spec/context-budget-control.md`)
feature: context-budget-control status: in-progress updated: 2026-08-05 branch: investigate/context-limit-double-rebuild commits: 028f3178..3b15062d
Context Budget Control (user-adjustable early compaction)
Report
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 "/<modelID>" 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 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/cli) — PASS, post-rebase.bun typecheck(packages/cli) — 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 testbefore the review fixes — 4359 pass / 4 fail; every failure reproduced at base or passed in isolation (test/util/ssrf.test.tsDNS fail-closed fails at base;test/workflow/runtime.test.tshas 2 fails at base vs 1 here;test/session/checkpoint-rebuild-unify.test.tspasses 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.6window 922K / budget 200K / compacts at 180K;gpt-5.6-solbudget 300K / 280K;gpt-5.3-codex(272K cap) shows no budget because 300K clamps away;gpt-4oando3unchanged. - Live TUI (tmux, isolated
MIMOCODE_HOME,xiaomi/mimo-v2.5): picking 300K wrote"xiaomi/mimo-v2.5": 300000to the globalmimocode.jsonc, footer became33.0K/260K↓ (13%), and/statusshowedwindow 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" wrote0and restoredcompacts 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
- PR #1926's real defect was not "insufficient" but inert:
usable()preferslimit.input, which every 1M-class GPT model publishes (922K), so the 300K assignment only changed the display denominator and the MCP tool budget. Any future provider-cap correction must setlimit.input, not justlimit.context. Instance.dispose()— which every config write triggers — cancels all runners via theSessionRunStatescope finalizer. Any TUI feature that persists config mid-session needs a busy guard;sync.data.session_statusis the server-authoritative signal (message-derived status can stay "working" forever after a crash).- Config merges cannot delete keys, and
nullwould fail schema validation, so "reset to model default" is expressed as0. Writing a single leaf key (not a read-modify-write of the whole map) avoids promoting project-level entries into the user's global file and is safe for both the JSONmergeDeepand the JSONCpatchJsoncwriters. - SDK regeneration is currently broken on
main:bun dev generateemits a dangling#/components/schemas/__schema0ref fromToolStateCompleted.providerOutput, so@hey-api/openapi-tsfails. Confirmed at base commit; new config fields therefore need a narrow cast at the TUI boundary until that is fixed. usable()can be 0 for a positive window (window smaller than the reserves, or a largecompaction.reserved), which would have renderedInfinity%in three footers. Any UI that divides by it needs the guard now intui/util/model.contextWindow.- Provider "context window" numbers in the wild are frequently a billing tier or a client's conservative default, not capacity — Codex ships 372K capacity, a 95% effective window, a 90% auto-compact default, and a 272K price boundary, all four called "the context window" somewhere. Ask which one a number is before hard-coding it into
limit.
[S1] Problem
S1.1 Three different quantities are conflated into `model.limit.context`
| Quantity | Meaning | Who owns it | Today |
|---|---|---|---|
| Provider hard cap | max prompt tokens the API will actually accept | provider / auth mode / plan | limit.context, sometimes limit.input |
| Working budget | where we choose to compact (quality / cost / latency) | user | not expressible |
| Display denominator | what the % in the TUI is a fraction of |
UI | limit.context (raw) |
Because there is only one field, any attempt to express (1) or (2) corrupts the other two.
S1.2 PR #1926 audit — the fix does not fire, and it over-raises other models
packages/cli/src/plugin/codex.ts:379 (merged in 94a79289):
if (modelID.startsWith("gpt-")) model.limit.context = 300_000Defect 1 — no effect on the auto-compact trigger for the targeted models.packages/cli/src/session/overflow.ts:21-23 prefers limit.input whenever it is set:
return input.model.limit.input
? Math.max(0, input.model.limit.input - reserved)
: Math.max(0, context - outputReserve - reserved)models.dev catalog (bundled at build time as packages/cli/src/provider/models-snapshot.js, cached at ~/.cache/mimocode/models.json) for the openai provider:
| model | context | input | usable() before #1926 | usable() after #1926 |
|---|---|---|---|---|
gpt-5.6 / gpt-5.6-sol / gpt-5.5 / gpt-5.4 |
1,050,000 | 922,000 | 902,000 | 902,000 (unchanged) |
gpt-5.3-codex / gpt-5.2 / gpt-5.1 |
400,000 | 272,000 | 252,000 | 252,000 (unchanged) |
gpt-4.1-mini (no input) |
1,047,576 | – | 1,015,576 | 263,616 |
gpt-4o (no input) |
128,000 | – | 91,616 | 263,616 (raised above the real cap) |
gpt-3.5-turbo |
16,385 | – | 0 | 263,616 |
gpt-image-1 |
0 | 0 | 0 (guarded) | 300,000 (guard defeated) |
(reserved = min(20_000, maxOutputTokens); outputReserve = min(maxOutputTokens, 20_000); maxOutputTokens = min(limit.output, 32_000), packages/cli/src/provider/transform.ts:1550-1555.)
So for every 1M-class GPT model — the exact case the PR was filed for — compaction still triggers at ~900K. What did change is the display denominator (prompt/index.tsx:480, sidebar/context.tsx:81, subagent-footer.tsx:55) and the MCP tool-catalog budget (prompt.ts:1359). Net effect: the footer now reads 900,000 (300%) while the engine keeps going. The user-visible symptom moved, the behaviour did not.
Defect 2 — assignment instead of clamp. gpt-4o (128K real) and gpt-3.5-turbo (16K real) are raised to 300K, guaranteeing provider-side overflow errors that previously could not happen. gpt-image-* has limit.context: 0, a sentinel that overflow.ts:15,28,50 uses to disable overflow handling entirely; setting it to 300K defeats that guard.
Defect 3 — not overridable and not per-plan. The plugin auth loader runs at provider.ts:1310-1329, after the config-driven model merge at provider.ts:1169-1279. A user on a plan with a different real window cannot restore or lower the value from mimocode.json. The value is also a bare literal with no provenance, so nothing can explain it in the UI.
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 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 bycompaction.ts:49-54andprune.ts:274-292as 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_AUTOCOMPACTis all-or-nothing.
S1.5 Requirement summary
- 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.
- Never exceeds the provider's effective cap — the setting clamps, it never raises.
- 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?").
- 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
hardCap(model)=model.limit.input || model.limit.context— the largest prompt the API accepts.0keeps its current meaning: unknown/not applicable, overflow handling disabled.budget(cfg, model, …)= user-requested working budget, orundefined.effectiveCap=hardCap === 0 ? 0 : min(hardCap, budget ?? hardCap).usable()keeps its current meaning:effectiveCapminus 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
packages/cli/src/session/overflow.ts:13-24 is the only place the arithmetic changes:
export function usable(input: { cfg: Config.Info; model: Provider.Model; budget?: number }) {
const hard = input.model.limit.input || input.model.limit.context
if (hard === 0) return 0
const cap = Math.min(hard, resolveBudget(input) ?? hard)
const reserved = input.cfg.compaction?.reserved ?? Math.min(COMPACTION_BUFFER, ProviderTransform.maxOutputTokens(input.model))
const outputReserve = input.model.limit.input ? 0 : Math.min(ProviderTransform.maxOutputTokens(input.model), OUTPUT_CAP)
return Math.max(0, cap - outputReserve - reserved)
}Invariants:
- With no budget configured, the returned value is byte-identical to today for every model (the
limit.inputbranch keepsoutputReserve = 0; changing that is PR #1265's scope, not ours). - A budget larger than
hardCapis silently clamped, never applied upward. - A budget below
reserved + outputReserveyields0;isOverflowthen fires on the first turn. Reject such values at the write boundary (S2.4) rather than letting the session brick. pressureLevel/contextPressureLevelinherit the new denominator for free, so checkpoint thresholds (prune.ts:274-292) andpreserveRecentBudget(compaction.ts:49-54) follow the budget automatically.
New exported helper for callers that need the numbers without the reserves:
export function window(input: { cfg: Config.Info; model: Provider.Model; budget?: number }): {
hard: number // provider cap
effective: number // after budget clamp
usable: number // compaction trigger
source: "model" | "config" | "session" | "plugin"
}source exists so the UI and CLI can explain why the number is what it is (fixes Defect 3's opacity).
S2.3 Budget resolution order
Highest priority wins; each layer is optional:
- session override (only if Route C is chosen)
compaction.max_contextkeyed by"/<modelID>", longest-match glob allowed ("openai/gpt-5*")compaction.max_contextscalar (all models)- none →
hardCap
S2.4 Config schema
Added to the existing compaction struct (packages/cli/src/config/config.ts:239-258):
max_context: Schema.optional(
Schema.Union(TokenBudget, Schema.Record(Schema.String, TokenBudget)),
).annotate({
description:
'Compact earlier than the model window. Number of tokens or a string ("300K", "1M", "50%"). ' +
'Either one value for all models, or a map keyed by "<providerID>/<modelID>" (globs allowed). ' +
"Always clamped to the model's real window; never raises it.",
})TokenBudget = number | string. String parsing reuses the existing grammar of prune.parseThreshold (packages/cli/src/session/prune.ts:65-80: "40%", "100K", "1.5M", plain digits) — extract it into a shared helper rather than writing a second parser. % is relative to hardCap.
Validation at write time (config load warns, slash command refuses): value must leave at least reserved + outputReserve + 1 tokens, else it is ignored with a log.warn naming the model.
S2.5 Provider-layer fix for Codex (independent of the user-facing knob)
packages/cli/src/plugin/codex.ts becomes a clamp that also closes the limit.input hole and respects the sentinel:
const CODEX_GPT_CONTEXT_CAP = 372_000
if (modelID.startsWith("gpt-") && limit.context > 0) {
limit.context = Math.min(limit.context, CODEX_GPT_CONTEXT_CAP)
if (limit.input) limit.input = Math.min(limit.input, CODEX_GPT_CONTEXT_CAP)
}Clamping limit.input is what actually moves the trigger; clamping limit.context is what makes the display honest. limit.input is only touched when the catalog already publishes one — introducing it would switch usable() to the input branch and silently drop the output reserve (that would move gpt-4o from 91,616 to 111,616). gpt-image-* / gpt-3.5-turbo / gpt-4o keep their real windows.
The cap value, resolved during implementation (2026-07-27):
- 372,000 = capacity. OpenAI's Codex model registry declares
context_window = max_context_window = 372000for the gpt-5.6 variants (openai/codex#31860 quotes the served catalog, which also applieseffective_context_window_percent: 95and a 90% auto-compact default on top), and a direct Codex request with 350,317 input tokens completes (can1357/oh-my-pi#5705). So 272K is not a hard rejection boundary. - 272,000 = billing. Prompts above 272K input are priced at 2x input / 1.5x output for the whole request, and Codex's bundled metadata was lowered from 372000 to 272000 (openai/codex#33972) to keep default sessions inside the cheaper tier — after openai/codex#32486 pointed out the 353.4K effective window ran ~81K past it.
Those are the two quantities S1.1 separates, so the provider layer takes 372K and the 272K line is documented as a compaction.max_context recipe rather than baked into the cap. Users on plans with a smaller served catalog window (openai/codex#33069 reports 272K for Pro Lite) can lower it the same way; the clamp form can only reduce, so a stale literal never produces an over-long prompt.
S2.6 Display / "how do I see my context window"
Three surfaces compute % independently against raw limit.context today, and none of them matches the trigger. All three switch to Overflow.contextWindow():
| Surface | Today | After |
|---|---|---|
prompt footer packages/cli/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 |
% 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 |
The TUI may import Overflow.window directly — TUI modules already import from @/session/* (app.tsx:55, routes/session/index.tsx:100) — and it already holds sync.data.config plus sync.data.provider[i].models[id].limit. No new server route is needed for display.
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.
Route A — config only (`compaction.max_context`), no UI writer
- Add schema (S2.4) +
usable()(S2.2) + display (S2.6) + docs. Users hand-editmimocode.json. - Scope: global or per-model, per project or per user. Persistent. Read by every
usable()call site for free (cfgis already an argument at all 6 sites). - Cost: smallest. No SDK regeneration beyond the generated
Configtype, no migration, no new dialog. - Gap: does not satisfy the user's "slash option with preset tiers" ask; not discoverable.
Route B — Route A + `/context-limit` slash command writing global config **(recommended)**
- Picker dialog:
Model default · 200K · 300K · 500K · 1M · Custom…, applied to the currently selectedproviderID/modelID, written ascompaction.max_context["openai/gpt-5.6"]. - Mechanism already proven:
sdk.client.global.config.update({ config: patch })→sdk.client.instance.dispose()→sync.bootstrap(), exactly ascomponent/dialog-model.tsx:318-338anddialog-modalities.tsx:72-96do. Server routes exist (PATCH /global/config,server/routes/global.ts:171-195). - Constraint discovered during design: a config write forces
Instance.dispose(), whose finalizer cancels every runner (session/run-state.ts:36-51). The command must therefore be rejected while the session is busy (guard on session status) or deferred to idle. This is a hard constraint, not a preference. - Scope: per-model, persistent across sessions and projects. Matches "设置到模型上".
- Cost: Route A + one dialog + one command registration + a busy guard.
Route C — per-session override (session column)
- Nullable
max_contextcolumn onsession(session/session.sql.ts:14-48), migration foldermigration/<ts>_session_max_context/migration.sql(ALTER TABLE session ADD max_context integer;), touchfromRow/toRow/toPartialRow(session.ts:64-112,projectors.ts:37-63),Session.setMaxContextnext tosetTitle(session.ts:681-720), extend thesession.updatebody allow-list (server/routes/instance/session.ts:378-389), regenerate the SDK (./packages/sdk/js/script/build.ts). - Requires threading
sessionID(or the resolved budget) into the 6usable()call sites:processor.ts:638,prune.ts:274,prune.ts:371,compaction.ts:53,prompt.ts:1334,prompt.ts:1346,prompt.ts:3203. All of them havesessionIDin scope. - Writes are safe mid-turn (
patch()is aSyncEvent.run, no dispose) and propagate to the TUI automatically viasession.updated(sync/index.ts:215-249,server/projectors.ts:11-24,tui/context/sync.tsx:439-452). - Scope: per session, survives resume. This is what issue #1837's acceptance criteria literally ask for ("per-session and restored on resume", "does not rewrite global config unless save as default").
- Cost: highest — migration + SDK regen + signature churn across 7 call sites.
Route D — provider-layer fix only (S2.5)
- Fixes the Codex bug correctly. Ships no user-facing knob. Should be done regardless of A/B/C because it is a correctness regression fix, and it is independently testable.
Decision (2026-07-27, user)
Route D + B, with A as the substrate. Budget is keyed per model — compaction.max_context["/<modelID>"]; a scalar form stays accepted by the schema but /context-limit always writes the keyed form. Route C is deferred (see S4).
Recommendation
D + B, with A as the substrate: D closes the shipped regression, A gives the durable expressible policy that every call site already reads, B makes it discoverable with the preset tiers requested. Defer C until per-session divergence is actually demanded — the per-model config value already covers "I always want 300K on this model", and C's cost is dominated by plumbing rather than by design risk. If C is chosen later it layers on top of A without changing A's semantics (it is priority 1 in S2.3).
Rejected alternatives:
- Overload
provider.*.models.*.limit.contextas the user knob (what #1926 does at plugin level): destroys the distinction in S1.1, lies to the provider-cap consumers (acp/agent.ts:76,prompt.ts:1359), and cannot express "compact early" without also mis-reporting the model. - Overload
compaction.reserved: global-only, and collides with its safety-buffer role incompaction.tsandprune.ts. - TUI KV /
model.jsonas the store: the TUI runs in a separate realm from the session engine (Worker + HTTP-shaped transport,tui/thread.ts:270-335,tui/worker.ts:50-68), so server code cannot read it in memory, and it would not apply tomimo run/serve/ ACP clients. - Per-request
PromptInput.maxContext: not sticky across compaction continuations, retries, orLoopInputwakes (prompt.ts:4460), and needs threading intoProcessoranyway.
[S4] Out of scope
- Per-session budget override (Route C) — deferred by the 2026-07-27 decision; layers on top of S2.3 priority 1 when demanded.
- Multi-pass compaction when history already exceeds the target window (issue #1840). A budget makes that state rarer, it does not resolve it.
- Changing the
limit.inputoutput-reserve semantics (open PR #1265). - Lowering
DEFAULT_CONTEXT_WINDOW(provider.ts:35), repeatedly proposed and closed (#1863, #648, #626). - A live slider / drag UI. Preset tiers plus custom entry only.
- Per-agent or per-subagent budgets.
- Automatic budget inference from observed provider overflow errors.
Tasks
Route D (do first, independent):
- T1: Replace the assignment in
plugin/codex.tswith a clamp, includinglimit.inputand thelimit.context === 0guard — acceptance:bun test test/plugin/codex.test.tsassertsgpt-5.6-sol→{context: 372K, input: 372K},gpt-5.3-codex→{context: 372K, input: 272K},gpt-4o→{context: 128K}unchanged and noinputintroduced,gpt-image-1→{context: 0}unchanged,o3→{context: 200K}unchanged (covers: S2.5) - T2: Confirm the real Codex prompt cap and either keep the literal or make it plan-derived — acceptance: the chosen value is documented in a code comment with its source (covers: S2.5; depends: T1). Resolved to 372,000 with sources in the comment: OpenAI's Codex model registry declares
context_window = max_context_window = 372000for the gpt-5.6 variants (openai/codex#31860 quotes the served catalog), and a direct Codex request with 350,317 input tokens completes (can1357/oh-my-pi#5705). The 272,000 figure that Codex's bundled metadata was lowered to (openai/codex#33972) is the >272K 2x-input / 1.5x-output billing boundary, i.e. a spending policy — documented as acompaction.max_contextrecipe instead of baked into the cap.
Route A (substrate):
- T3: Extract the threshold grammar from
prune.tsintoToken.parseQuantityand unit-test"300K"/"1.5M"/"50%"/ plain number / invalid input — acceptance:bun testcovers all five,prune.tsbehaviour and error messages unchanged (covers: S2.4) - T4: Add
compaction.max_contextto the config schema with scalar + keyed-map forms and a validation warning — acceptance: a config with{"compaction":{"max_context":{"openai/gpt-5*":"300K"}}}type-checks and parses; a value below the reserves logs a warning and is ignored (covers: S2.3, S2.4; depends: T3). The warning fires on first use rather than at load, so it names the model it applies to. - T5: Make
Overflow.usable()budget-aware and addOverflow.contextWindow()per S2.2 — acceptance: unit tests show (a) no budget → identical numbers tomainfor alimit.inputmodel and a context-only model, (b) budget above the cap is clamped, (c) budget300Kon a 1.05M/922K model yieldsusable = 280KandisOverflowfires there (covers: S2.2; depends: T4) - T6: Document
compaction.max_contextwherevercompaction.*is documented — acceptance: the doc states the clamp rule and the"/<modelID>"key form (covers: S2.4; depends: T4)
Route B (UI writer):
- T7: Add
/context-limitcommand + preset picker dialog writingcompaction.max_context["/<model>"]viaglobal.config.update→instance.dispose()→sync.bootstrap()— acceptance: selecting300Kon a 1M model persists to the global config, the footer denominator updates without restart, andModel defaultresets the model (covers: S3 Route B; depends: T5). Reset writes0instead of deleting the key — a config merge cannot delete. - T8: Refuse the write while a session is busy, with an explanatory toast — acceptance: invoking the command mid-turn leaves the config untouched; the same action once idle writes (covers: S3 Route B; depends: T7)
Display (needed by any route):
- 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) - T10: Add a Context block to the
/statusdialog 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 correctsourcelabel (covers: S2.6; depends: T5) - T11: Surface the context window in the
modelsCLI command without--verbose— acceptance:mimocode models openaiprints each model's provider window and compact-at (covers: S2.6; depends: T5) - 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; reachingusable()still follows the existing rebuild path (covers: S1.3, S2.1; depends: T5) - 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) - 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)
- 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)
Compose Spec Cron Session Affinity (`docs/compose/spec/cron-session-affinity.md`)
feature: cron-session-affinity status: delivered updated: 2026-10-12 branch: fix/cron-session-affinity commits: 3c6e6bb0..4c3a7e46
Cron Session Affinity
Report
What was built — Scheduled cron / loop prompts now fire into the session that created the job instead of whichever session first started the process-wide CronBridge. onFire resolves the inject target via fireTargetSessionId(task, bridgeSession); createdBySessionId is stamped on armLoop and inherited across keepalive re-arms so a loop owned by session B keeps its stamp even when the bridge is mounted on A. get / delete / rename / list share one ownership rule: hide only tasks stamped to another session; legacy unmarked rows stay visible and fire into the current bridge session. Empty-string stamps count as unmarked everywhere.
Verification — bun test packages/cli/test/cron packages/cli/test/tool/cron.test.ts packages/cli/test/session/cron-bridge.integration.test.ts → 79 pass / 0 fail. bun run typecheck → PASS. Lint on touched files: 0 errors (warnings pre-existing). Review 1 of 3c6e6bb0..bfdb2b17 found CRITICAL (keepalive armLoop dropped the owner stamp) → fixed in 082b8124. Review 2 of the PR found CRITICAL on the post-fire path (one-shot loop row removed after fire, so inherit-from-prior found nothing) → fixed in 4c3a7e46 by persisting the owner on LoopState. Re-review of 4c3a7e46 → APPROVE, no remaining criticals.
Journey log —
- User guess "cron never marks the session" was only half-right:
createdBySessionIdexisted onschedulebut was ignored at fire time and never written byarmLoop. loopis not a standalone tool:/loopis a skill wrappingcron schedule;cronalso has aloopaction (armLoop). Both shared the wrong-session fire path.- Desktop EngineInstanceStore already accepts cron into non-active sessions by design (R2/R9); the bug was engine-side routing, not desktop mis-attribution. Sidebar lag for a never-listed session (30s poll) is a UI-discovery gap, out of scope.
- Keepalive affinity needed two layers: inherit-from-prior covers supersession of a live row;
LoopState.createdBySessionIdcovers the post-fire path where the one-shot row is already gone. Persist owner on the longer-lived state object. - e2e
fireToInjectmirrors productiononFirerather than invoking it (AppRuntime dynamic-import seam); helper-level tests cover the contract, not the live callback wiring.
[S1] Problem
Users (notably from mimo-desktop multi-session hosts) report that scheduledcron / loop prompts fire into the wrong session. Code confirmed this:
CronBridge.onFirealways injected intohandle.sessionID(the first session
that calledstart) and never readtask.createdBySessionId.CronBridgeis a single-slot singleton; a second session'sstartis a no-op,
so every fire in the process landed on the first session.scheduler.armLoopnever wrotecreatedBySessionId, so loop tasks had no
owner stamp at all (and keepalive re-arm dropped any stamp).get/delete/renameonly applied a session filter when the caller
explicitly passedsession_id; the tool schema claims "defaults to current
session" but did not enforce it.LoopStateis a process-global map keyed by prompt text only.
[S2] Design
Fire routing
When a task fires, inject into the task's owning session:
- Target session =
task.createdBySessionIdwhen present (truthy). - Legacy / unmarked tasks (no stamp, or empty-string stamp) fall back to the
current bridge session (previous behavior) so existing durable jobs keep firing.
injectScheduledPrompt already accepts an explicit sessionID; onFire passes
the target session rather than handle.sessionID. Sentinel expansion
(resolveAtFireTime) uses the bridge's workspaceRoot and the target
session id so the cache is keyed per receiving session.
Session stamping
scheduler.addstampscreatedBySessionIdwheninput.session_idis set.armLoopaccepts and stampssession_id. Owner resolution is truthiness
(""= unmarked): explicitsession_id, else a stamped prior same-prompt
loop task, elseLoopState.createdBySessionId.LoopStateoutlives the
one-shot task (which the tick removes after fire), so post-fire keepalive
re-arms still stamp the creating session.- The cron tool's
loopaction passessessionID(resolved likeschedule).
Ownership on read/update/delete
CronToolalways passessession_id: sessionID(resolved fromop.session_id || ctx.sessionID) toget/delete/rename, matchinglist/schedule.findByIdownership check: when a session filter is supplied, a task with a
differentcreatedBySessionIdis not visible. A task with nocreatedBySessionIdstays visible (legacy tolerance), matching the fire-time
fallback. When no filter is supplied (internal callers), behavior is
unchanged.listuses the same rule (hide only tasks stamped to another session) so
legacy unmarked rows remain discoverable and cancellable.- Empty-string stamps are treated as unmarked across fire/list/findById.
Loop state
armLoop stamps the task and stores the owner on LoopState.createdBySessionId
so affinity survives post-fire removal of the one-shot loop row. LoopState
remains keyed by prompt for this iteration (keepalive and max-age). Full
per-session LoopState isolation is out of scope; see S3.
Desktop (mimo-desktop) alignment
EngineInstanceStore is a passive mirror: apply() accepts message.* /session.status for any sessionID without requiring the session to be the
selected/active one. engineAwareActivity lights the sidebar spinner fromsessionStatus[type≠idle]. refreshActivity (2s) hydrates non-idle sessions;loadEngineSessions (30s / window focus) rebuilds the convo list from the
engine session list. So a fire into a cold (unselected) session is intentional
and correctly attributed after this fix — the creating conversation is the one
that goes busy. Remaining desktop-side lag (a session not yet in convos may
take up to 30s to appear in the sidebar) is a UI-discovery gap, not a
correctness bug, and is out of scope here.
Error / edge behavior
- Target session id present but the session is gone:
injectScheduledPrompt
already fails into the existing log path; no new recovery. - Same-tick double-fire latch and lock ownership stay unchanged.
- Non-owner processes still only fire session-store tasks; durable file tasks
remain lock-owner-only.
[S3] Out of Scope
- Multi-slot CronBridge / per-session keepalive sweeps.
- Per-session
isLoadinggating (busy of one session still delays the whole
tick). - Re-keying
LoopStateby (session, prompt). - Migrating or deleting legacy unmarked durable tasks.
- Desktop UI / Automations page changes; 30s sidebar discovery lag.
Tasks
- T1: Route
onFirethroughtask.createdBySessionIdwith fallback to bridge session — acceptance: a task stamped with session B injects into B while the bridge is mounted on A (covers: S2) - T2: Stamp
createdBySessionIdinarmLoopand passsession_idfrom the cron toolloopaction — acceptance: created loop task hascreatedBySessionIdequal to the calling session (covers: S2; depends: T1) - T3: Always pass resolved
sessionIDintoget/delete/renamein CronTool — acceptance: get/delete/rename from session A cannot act on a task stamped to session B (covers: S2; depends: T1) - T4: Regression tests for cross-session fire routing, loop stamping, and ownership filter — acceptance: tests fail before the fix and pass after (covers: S2; depends: T1, T2, T3)
Compose Spec Default Model Stable Fallback (`docs/compose/spec/default-model-stable-fallback.md`)
feature: default-model-stable-fallback status: delivered updated: 2026-09-01 branch: default-model-stable-fallback commits: fa69916897..afdd76ec62
Default Model Stable Fallback
Report
What was built — Provider.defaultModel() no longer ranks models with the TUI menu sort() priority list (gpt-5 / claude-sonnet-4 / gemini-3-pro, then latest). The chain is now: validated cfg.model (warn + fall through if missing) → existing recent → first allowed provider with a usable chat model (toolcall + text input + non-zero context), first by id ascending. Stale cfg.model values that are no longer in the live registry fall through instead of being returned blindly. Capability filter prevents last-resort from selecting models like live openai/chatgpt-image-latest (tool_call: false, context: 0).
Verification — bun test test/provider → pass / 0 fail (includes new cases: invalid cfg fallthrough, recent hit over id-asc first, valid cfg beats recent, invalid cfg + recent, non-chat last-resort skip, no priority-substring preference). bun run typecheck → clean.
Journey log — Desktop does not write TUI state/model.json recent, so empty recent is the common Desktop case, not a rare one; a “first provider model” fallback without a product-default path would re-open the same bug. First review flagged a missing recent-hit test and a weak last-resort test; both were fixed before finalize.
[S1] Problem
Provider.defaultModel() is the engine's last-resort model resolver for internal tasks (title generation, predict, agents, MCP sampling) when no session context is available.
Its final fallback previously did:
- pick the first allowed provider
- run
sort()over that provider's models (prioritysubstringsgpt-5/claude-sonnet-4/gemini-3-pro, thenlatest, then id desc)
That sort was written for TUI menu ranking, not as a product default. On MiMo Desktop:
cfg.modelis not injected- TUI-only
state/model.jsonrecentis almost always empty - the registry may contain router leftovers (e.g.
claude-sonnet-4-6-006)
Result: title generation and other cheap tasks can resolve to an unavailable or unsupported model while the user's conversation model works fine. Log evidence already exists in Desktop (title generation → 400 Unsupported model / ProviderModelNotFoundError).
cfg.model also returned without existence validation, so a stale configured default was forwarded until the request failed.
[S2] Design
Provider.defaultModel() is a stable, validated chain. No product-priority substring ranking.
1. cfg.model
- parse provider/model
- MUST exist in the live provider registry
- missing → warn + fall through (do not throw)
2. state/model.json recent[]
- first entry whose provider+model still exist
3. first allowed provider that has a usable chat model
- usable = toolcall && text input && limit.context > 0
- first such model by id ascending (deterministic)
- no priority list, no "latest" preference
4. no provider / no usable model → throw the existing clear errors
("no providers found" / "no models found")Contracts:
sort()stays for TUI menus /defaultModelIDs/ ACP listing. OnlydefaultModel()stops using it.defaultModel()return type is unchanged:Effect<{ providerID, modelID }>.- Call sites that catch resolution failure keep working; success no longer depends on priority substrings.
- Existence checks use the same in-memory
InstanceStateprovider map already used for recent.
Not changing in this feature:
- cheap-task preferred session model (separate fix, Plan B)
- Desktop injecting
model/model_groups.lite - writing
recentfrom Desktop
[S3] Out of Scope
- Changing
getSmallModel/genTitleto prefer the conversation model - Injecting Desktop
model_groups.lite - Rewriting TUI's own UI fallback in
packages/apporcli/cmd/tui/context/local.tsx - Deleting
Provider.sortordefaultModelIDs - Cost-aware last-resort pick (google-only setups can still land on an expensive usable model such as deep-research-*; filter only guarantees "usable", not "cheap")
Tasks
- T1: Rewrite
defaultModel()chain (validate cfg.model, keep recent, drop menu-priority sort; last-resort requires usable chat model) — acceptance: unit tests cover invalid cfg fallthrough, recent hit, cfg-beats-recent, non-chat skip, first-provider stable pick, and no priority substring preference (covers: S2) - T2: Adjust/extend provider tests — acceptance:
bun test packages/cli/test/providerpasses; new cases assert the chain order and thatgpt-5-like ids are not auto-preferred when earlier steps miss (covers: S2; depends: T1)
Compose Spec Default Port Ephemeral (`docs/compose/spec/default-port-ephemeral.md`)
feature: default-port-ephemeral status: delivered updated: 2026-09-12 branch: feat/default-port-ephemeral commits: ecbe6e59c7c9383f99767ca3f755ad032c4dc3cc..98ef287528e9c54fefe0055a90462338adc02f4b
Default Listen Port Ephemeral
Report
What was built — Server.listen({ port: 0 }) now binds an OS-assigned ephemeral port on both node and bun adapters; the conventional 4096 preference is removed so embedders (MiMo Desktop) and other local tools no longer collide by default. Fixed ports require an explicit port / --port / config.server.port. Runtime hardcodes of localhost:4096 were removed from the plugin client placeholder and CLI help; documentation examples may still show --port 4096 when a known URL is required.
Verification — packages/cli: bun test test/cli/cmd/server-port-ephemeral.test.ts test/cli/cmd/serve-advertise.test.ts PASS 8/8; bun test test/skill/mimocode-docs.test.ts test/plugin/mimo.test.ts test/plugin/codex.test.ts PASS 44/44; bun typecheck PASS. Independent review: no critical findings.
Journey log — Prefer start(opts.port) over a special-case port===0 → 4096 branch; yargs default 0 therefore means ephemeral unless config supplies a port. Downstream Desktop must keep consuming listen-returned Server.url / Listener.port (already dynamic). Generated SDK default baseUrl and unmanaged packages/app UI placeholders still mention 4096 (out of runtime listen path). Node adapter is code-symmetric with bun; unit suite exercises the bun path under Bun.
[S1] Problem
Server.listen({ port: 0 }) used to prefer a conventional serve port (4096), then fall back to start(0). That port collides with other local tools (MiMo Desktop embeds the engine in-process; OpenCode/CLI users also bind 4096). Port 0 is the OS standard “any free port” and should mean exactly that.
[S2] Design
- Adapter (node + bun):
port: 0→start(0)(OS-assigned ephemeral). No intermediate bind of a conventional port. Explicitport: Nbinds onlyN. - CLI: yargs default remains
0, so an unspecified--portnow means ephemeral. Fixed ports require an explicit flag orconfig.server.port. - Docs: network flags and skill/README examples that need a known port must pass it explicitly (e.g.
--port 4096). Example strings may keep 4096; runtime code must not hardcode it as the listen target. - In-process plugin client: dummy
baseUrl/serverUrlfallback useshttp://mimocode.internal(not a conventional listen port). Real traffic usesServer.urlafter listen or in-processapp.fetch. - Generated SDK default
baseUrl: http://localhost:4096is a client template, not a listen path; left as generated (docs/examples domain).
[S3] Out of Scope
- Desktop-side engine-pin bump (downstream).
- Changing
config.server.portschema (still optional and> 0). - Rewriting all multilingual web docs / unmanaged
packages/appUI placeholders beyond note-level consistency.
Tasks
- T1: node+bun adapter
0 → start(0)— acceptance: source has nostart(4096)preference (covers: S2) - T2: plugin client no localhost:4096 hardcode — acceptance: plugin/index.ts uses non-4096 placeholder (covers: S2)
- T3: CLI/network + server.listen contract comments — acceptance: describe
0as ephemeral (covers: S2) - T4: unit test port 0 does not prefer 4096 when free/bound — acceptance:
bun testgreen on new/updated suite (covers: S2) - T5: docs/skill keep explicit-port examples; no new “default is 4096” claims — acceptance: capability-api/guide/README still show explicit
--port(covers: S2)
Compose Spec Default Prompt P0 (`docs/compose/spec/default-prompt-p0.md`)
feature: default-prompt-p0 status: delivered updated: 2026-09-19 branch: fix/default-prompt-p0 commits: 50cd7139..ac7bbf66
Default System Prompt P0 Cleanup
Report
What was built — default.txt is a lean behavioral base prompt (System /
Doing tasks / Executing actions with care / Using your tools / Skills / Tone).
Architecture dumps (Agent system, native-agent catalog, permission pedagogy,
session lifecycle, plan-mode detail, MCP essay) are gone. Dispatch is task /actor / workflow only. Memory is not restated (owned bybuildMemoryInstructions). compose never appears in base sys. Subagent return
format is off the main memory path (spawn RETURN_FORMAT_INSTRUCTION +general.txt pointer); general keeps a nested-spawn ban and the
inspect/verify work-face.
general/explore carry work-face (case-sensitive snake_case tool ids,
parallel 1–3 / ≤8, trust-as-data) and parent-facing reporting. Model-facing
injectors across default, memory, recall hints, budgeted-read, truncate,
checkpoint render/reconcile, checkpoint-writer, dream/distill, write, and
workflow builtins use registered tool ids; bare glob/grep are qualified as
“the glob tool” / “the grep tool” when they could be read as shell.
Sibling provider prompts (glm.txt, deepseek.txt, minimax.txt, …) brand
residuals are deferred follow-up (S3).
Deletion → injection site (every removed block names who still carries it):
Deleted from default.txt |
Still injected / owned by |
|---|---|
### Memory + CC store / four types |
session/llm.ts buildMemoryInstructions → # Memory system (main/peer) |
## Subagent return format (also removed from memory block) |
actor/spawn.ts RETURN_FORMAT_INSTRUCTION for gate-eligible children (general via completionGate); general.txt points at required format |
Help/feedback (/help, issue URL) |
Not re-injected — TUI chrome only |
| Claude brand / anthropics / CLAUDE.md | N/A; durable instructions are AGENTS.md |
| compose / Agent-system / Session lifecycle / Plan-mode detail / MCP essay | Tool descriptions, agent.ts, prompt.ts plan reminder, plan-exit.txt, mimocode-docs |
| Skills brand-path dumps | Named only .mimocode/skill(s) + .agents/skills; other roots unnamed |
| Workflow numeric limits / “shared token budget” | workflow tool description / config |
Wrong tool ids (Agent tool, task_*, plan-exit, …) |
Registry snake_case ids |
What stayed (on purpose) — identity + security IMPORTANTs; System
(permissions, tool surface, system-reminder, DATA-as-data, memory-stale,
compress); Doing tasks + Executing (blast radius); Using tools (case-sensitive
snake_case, parallel 1–3 / ≤8, task/actor/workflow routing); Skills
rules + two real roots; Tone (progress rhythm, end-of-turn summary).
Verification — From packages/cli:
bun typecheck— PASSbun test test/agent/agent.test.ts— PASS (52)bun test test/session/llm-system-prompt.test.ts— PASSbun test test/session/prompt.test.ts test/session/budgeted-read.test.ts— PASS (65)bun test test/session/checkpoint-render-verify.test.ts— PASS- CI on
ba2312c1— lint / typecheck / unit 1–4 SUCCESS 0543ef5a(Turn 1readcasing +globtool qualifiers) — prompt-only; pre-push typecheck PASS- compose-next review: acceptance criteria met after casing + general inspect/verify follow-ups
Journey log
- Memory rewrite was wrong — already in
buildMemoryInstructions; delete the section. - Over-slashed then restored Agent system; final product call: architecture out of base sys (mimocode-docs / tool desc own it); tests lock the slim shape.
- Subagent return format belongs on spawn task injection, not the main memory block.
generalmust keep a nested-spawn ban and inspect/verify work-face:toolAllowlistis unset so it can inheritactor; without verify bullets the parent-report contract is unimplementable.- Tool-name casing across all injectors: English imperatives stay plain English (
Read all sources,Glob \pattern`,Read CHECKPOINT_PATH). When naming the registered tool, useUse `glob` with …/the `grep` tool/ call formglob("…")— never jam a backticked id in front of an argument (``globpath`` is unreadable). Prefer registered snake_case ids overGrep/Read tool` prose when the word means the tool.
[S1] Problem
default.txt mixed Claude Code brand residue, invented dispatch tools, a
wrong Memory block, compose advertising, and tool-limit/path dumps that
duplicate real injectors — while the architecture prose (Agent system, skills
rules, session, MCP, trust) is still needed. Memory/tool descriptions also
advertised capitalized tool names (Grep/Read/Edit) that violate the
case-sensitive registered-id rule.
[S2] Design
Base sys = behavior + trust + tool routing + skills roots + tone. Delete wrong,
branded, or already-injected content. Architecture lives in tool descriptions /
mimocode-docs / runtime injectors. compose never appears. Return format is
spawn-owned. general forbids nested spawn. Tool names in every model-facing
injector use exact registered ids when naming the tool (Use \glob` with …, the `grep` tool, glob("…")`); leave English imperatives as plain English.
Do not jam a backticked id in front of an argument.
See Report tables.
[S3] Out of Scope
- Sibling prompts (
compose.txt,anthropic.txt,glm.txt,deepseek.txt,minimax.txt, …) residuals — deferred follow-up. - Changing
buildMemoryInstructionsor skills catalog injection. - Further slimming of Agent-system pedagogy beyond the deletions above.
Tasks
- T1: Strip Claude brand + help/feedback + fix dispatch/tool ids
- T2: Delete Memory section (owned by
buildMemoryInstructions) - T3: Drop compose line, skill path dumps, workflow numeric limits (tool desc)
- T4: Slim base sys (no Agent-system dump); Skills + trust kept; subagent prompts carry work-face + parent reporting + nested-spawn ban
- T5: Regression test + verify
Compose Spec Desktop Child Process Env (`docs/compose/spec/desktop-child-process-env.md`)
feature: desktop-child-process-env status: delivered updated: 2026-08-17 branch: fix/desktop-runtime-boundaries commits: 17c9cdcb70b27116456f742f2b0d384b76f79108..0d3d0dcadf999cd05cb5ea0ae22e5e43ac1bd305
Desktop Child Process Environment
Report
What was built — MiMoCode now owns a process-level, replaceable child environment baseline. Embedding hosts can set it at startup through Server.listen({ childEnv }) or refresh it later through the Node entry's ChildProcessEnv.set(env). Hosts that never set a baseline retain existing behavior and read the current process.env for each spawn.
All inherited external process paths now resolve through the same boundary, including direct and Effect process wrappers, Bash, PTY, LSP, MCP, ripgrep and remaining native child_process calls. Inherited credentials are still scrubbed before explicit per-child environment overrides are applied.
Verification — bun typecheck passed. The affected regression suite passed 99 tests with 0 failures across child-process environment, credential environment, process, PTY, LSP, MCP, ripgrep and Bash coverage. git diff --check passed. Independent review passed spec compliance, correctness and codebase consistency after all critical findings were fixed.
Journey log —
- A frozen baseline was rejected because a future Desktop environment-sync action must replace the baseline for new child processes without changing session state.
- The no-host path reads current
process.envon every spawn; freezing that fallback broke existing CLI runtime environment updates. - Review found Effect commands with undefined env and native
child_processcalls could still inherit the engine process; both paths now explicitly resolve the shared baseline. - LSP environment deltas are resolved at each spawn so a baseline refresh affects later commands in the same workflow.
- The structural guard uses the TypeScript AST to validate every native process call; Process wrapper clients pass only env deltas so the final spawn boundary resolves the baseline exactly once.
[S1] Problem
MiMoCode runs in-process inside MiMo Desktop. Desktop adds engine control variables to the Electron process environment, and external tools currently derive their environment directly from that global state. Bash, PTY, LSP, MCP and shared process spawners can therefore observe Desktop-only values such as engine credentials, permission controls and NODE_ENV=production injected by electron-vite.
The existing Env.Service is mutable, instance-scoped configuration state, not a child-process boundary: several spawn paths run outside its Effect context and still read process.env directly. Session permission is unrelated and must remain persisted session state; this feature does not change permission semantics.
[S2] Design
The process/spawn infrastructure owns one replaceable child environment baseline. A new foundation module exposes two operations:
setChildProcessEnv(env: NodeJS.ProcessEnv): void
childProcessEnv(explicit?: NodeJS.ProcessEnv): NodeJS.ProcessEnvThe Node embedding entry exports the host-facing setter as ChildProcessEnv.set(env). The resolver remains internal to spawn infrastructure. This gives Desktop one stable startup and future refresh contract without exposing arbitrary environment mutation over HTTP.
setChildProcessEnv copies the supplied values and atomically replaces the process-wide baseline. Future child processes use the new snapshot immediately; already-running children keep the environment they were spawned with. The baseline is not tied to a project instance or listener and is not reset when a listener stops. If no host sets a baseline, childProcessEnv reads the current process.env on each call, preserving existing CLI/TUI/ACP/run behavior when those surfaces intentionally mutate their own process environment.
MiMoCode adds an optional childEnv?: NodeJS.ProcessEnv field to the public Server.listen module contract only as an embedding adapter. listen sets childEnv before creating the HTTP runtime or initializing any project instance. Desktop supplies the option; ordinary CLI/TUI/ACP/serve callers omit it. Headless mimo run, which does not call Server.listen, naturally uses the default current process.env. A future Desktop environment-sync action can call the same process foundation setter without changing tool or session contracts.
All external process paths use one helper, childProcessEnv(explicitEnv?), with this order:
configured childEnv (or current process.env when no host set one)
-> existing inherited-credential scrub
-> explicit tool/plugin/MCP/LSP env overrides
-> spawnThe foundation does not infer or reconstruct variables. The host-provided baseline is authoritative. Existing cleartext credential protection remains on the inherited half (MIMOCODE_AUTH_CONTENT and MIMOCODE_CONFIG_CONTENT); explicit per-child environment remains authoritative, so an MCP definition, LSP configuration, plugin hook, or future tool caller can intentionally override any value. NODE_ENV, user MIMO_* values, proxy variables and bundled runtime variables follow the baseline unchanged.
Every path that currently builds inherited child environment from process.env must call childProcessEnv: the direct process wrapper, Effect ChildProcessSpawner, Bash, PTY, LSP, MCP and ripgrep. Tool-local additions keep their existing order after the baseline, including plugin shell.env, Git identity floors, UTF-8 fixes and MCP/LSP explicit env.
MiMo Desktop obtains a complete environment snapshot from the same clean login-shell mechanism used to establish the user's terminal environment, adds bundled runtime variables, and passes the result as childEnv to Server.listen({ childEnv }). The snapshot is independent from Electron's mutated process.env, so Desktop/engine controls added later are absent by construction rather than removed heuristically. NODE_ENV is not synthesized or deleted: if the user's clean terminal environment contains it, it remains; if not, Desktop does not add it. Desktop continues to inject MIMOCODE_PERMISSION into the engine process; this feature does not move permissions into session storage or change cross-client behavior.
The engine module declaration in Desktop is expanded only for the childEnv option. The Desktop engine spec and bundled-runtimes spec remain the source of truth for the baseline construction and public MIMO_* runtime variables.
[S3] Out of Scope
- Changing
MIMOCODE_PERMISSION, session permission persistence, or permission precedence. - Adding an HTTP endpoint for arbitrary environment mutation.
- Guessing or overriding
NODE_ENV; child processes follow the clean terminal environment exactly, including its presence or absence ofNODE_ENV. - Adding model-visible per-call environment controls to the Bash schema.
- Reusing
Env.Servicefor child-process inheritance or making child environment project-instance mutable. - Constructing Desktop's clean-terminal environment, updating Desktop's virtual module declaration, adding Desktop E2E coverage, or bumping Desktop's engine pin. Those are downstream consumer work after this MiMoCode foundation lands.
Tasks
- T1: Add the process-owned replaceable child environment foundation and
Server.listen({ childEnv })adapter — acceptance: each set replaces the snapshot for future spawns, existing children are unaffected, listener stop does not reset, and no-set callers keep current process.env behavior (covers: S2) - T2: Route all inherited external process environments through
childProcessEnv(explicit)— acceptance: direct and Effect spawners plus Bash/PTY/LSP/MCP/ripgrep use the same baseline and explicit env still overrides (covers: S2; depends: T1) - T3: Add MiMoCode unit and structural regression tests — acceptance: baseline fidelity, credential scrub, replacement lifecycle and all spawn funnels are covered (covers: S2; depends: T2)
Compose Spec Disable Pascalcase And Flooding (`docs/compose/spec/disable-pascalcase-and-flooding.md`)
feature: disable-pascalcase-and-flooding status: delivered updated: 2026-09-22 branch: codex/disable-pascalcase-and-flooding commits: 1579e7d9..HEAD
Disable PascalCase Tool Projection and Toolcall Flooding
Report
What was built — Removed the MiMo v2.6 PascalCase tool-name projection at
the model boundary and the entire toolcall-flooding detector. Canonical lowercase
tool IDs are advertised and replayed again; prompt display names (Edit,Grep, Glob, …) remain unchanged. The FIFO safe-serial gate and fail-cascade
guard stay intact.
Verification — From packages/cli:
- PASS:
bun typecheck - PASS:
bun test test/session/tool-fail-cascade.test.ts test/session/invalid-tool-cascade.test.ts test/tool/gate.test.ts test/tool/fail-cascade.test.ts test/session/structured-output.test.ts --timeout 30000 - Independent review previously found no critical findings
Journey log
- Flooding and PascalCase are independent of the FIFO/fail-cascade gate; the
gate file stayed out of the removal diff. - Historical prefix snapshots may still carry
model_name;restoreTools
ignores leftover keys (no migration).
[S1] Problem
Two recent engine behaviors need a clean removal while keeping their surrounding
safety work:
- MiMo v2.6 message-side tool names are projected to PascalCase (
Read,Grep,Edit, …) at the model boundary (#2490). Prompt text already uses those
display names and must stay that way, but the request/history casing rewrite
should be removed cleanly so the harness again advertises canonical lowercase
tool IDs. - Tool-call flooding detection (#2463 / #2487) buffers and cancels oversized
batches. The whole flooding path should be removed, while the independent
safe-serial FIFO gate (#2456) and fail-cascade guard stay intact.
[S2] Design
Keep
- Prompt and tool-description display labels (
Edit,Grep,Glob,Read, …)
introduced for human-facing instructions remain unchanged. packages/cli/src/tool/gate.tsFIFO admission (read/grep/glob overlap;
every other top-level tool serial within an assistant step).- Fail cascade in the same gate:
FailCascadeError,FAIL_CASCADE_MESSAGE,MIMOCODE_DISABLE_FAIL_CASCADE, and all
fail-cascade / gate tests and behavior. - Provider-native buffering (for example the OpenAI-compatible complete-call
delay until EOF) is untouched.
Remove — PascalCase model-boundary projection
Delete the mimo-v2.6 casing rewrite end to end so schema and history names stay
canonical:
- Delete
packages/cli/src/tool/names.ts(usesPascalCaseTools,defaultToolName,toolSurface,NamedTool). - Drop
modelNamefrom tool definitions and theMIMOCODE_PASCAL_CASE_TOOLS
flag. - Stop projecting tools/messages in
session/llm.ts; restore/prefix rewrite
branches that only exist for projected names go away. - Drop
modelName/model_nameplumbing fromtool/tool.ts,tool/registry.ts,session/prompt.ts,session/llm-request-prefix.ts,session/prefix-snapshot.ts, andsession/session.sql.ts. - Delete PascalCase tests and flag tests.
Internal execution, permissions, events, and persisted tool IDs were already
canonical and need no behavioral change.
Remove — toolcall flooding
Delete the flooding detector completely:
- Delete
packages/cli/src/session/toolcall-flooding.ts
(middleware,guardToolCallStream,ToolCallFloodingError,TOOLCALL_FLOODING_*). - Remove middleware wiring and flooding name-restore from
session/llm.ts. - Remove flooding recovery (cancelled batch parts, recovery reminder,
releasedCallIDskip) and relatedretrySafespecial cases fromsession/processor.ts. - Delete
MIMOCODE_DISABLE_TOOLCALL_FLOODING_DETECT. - Delete flooding tests; strip flooding flag combinations from fail-cascade,
invalid-tool-cascade, structured-output, and tool-safety-flag tests.
Interaction contract
- Fail cascade and FIFO serial remain independent of both removals.
- A non-read/search tool failure still cancels the rest of the batch via the
gate; no flooding path reintroduces a generation barrier or early abort. - Invalid-tool handling and
ToolCompatname repair stay as they are. doom_loopkeeps its existing three-identical-calls ask.
[S3] Out of Scope
- Reverting prompt display names back to lowercase labels.
- Changing FIFO / fail-cascade semantics or flags.
- Open PR #2514 flood quota / generation barrier — not merged.
- GPT/Codex tool surfaces, MCP tool names, and the shared exec gateway.
- Database migrations for historical prefix snapshots that stored
model_name.
Tasks
- T1: Remove PascalCase projection — acceptance: MiMo v2.6 requests advertise
lowercase canonical tools and history replays the same names; noMIMOCODE_PASCAL_CASE_TOOLS/modelNameremains; prompt display names still
say Edit/Grep/Glob (covers: S2). - T2: Remove toolcall flooding — acceptance: no flooding module, middleware,
recovery reminder, or flag remains; tools execute without a flooding generation
barrier (covers: S2). - T3: Preserve safe serial and fail cascade — acceptance: gate FIFO and
fail-cascade tests still pass unchanged in behavior; flood-related test cases
are gone (covers: S2; depends: T1, T2). - T4: Verify — acceptance: package typecheck and focused suites pass
(covers: S2; depends: T3).
Compose Spec Exec Subtool Metadata (`docs/compose/spec/exec-subtool-metadata.md`)
feature: exec-subtool-metadata status: delivered updated: 2026-08-24 branch: codex/exec-subtool-metadata commits: 84f17b64233ae6bd4b65bb8ccd6325fb8a2cdf16..bc1abe91dc7334d709a7ab454269f58d50954b8f
Exec Sub-tool Metadata
Report
What was built — exec nested-tool progress now uses a 150 ms trailing-edge debounce while preserving the first admitted call's immediate running snapshot. Every outer terminal path cancels pending progress, terminalizes any nested calls still running, publishes the final complete sub_parts snapshot, and waits for the metadata write. Late nested completions cannot overwrite or republish stale terminal state. Existing result and attachment size limits remain unchanged.
Verification — bun test test/tool/tool-script.test.ts test/session/messages-default-main.test.ts (64 pass, 0 fail); bun typecheck (PASS); git diff --check (PASS). Independent review of the complete diff and affected-area fixes (PASS; no critical findings).
Journey log — The initial implementation was a fixed-window coalescer; review identified that true trailing-edge debounce requires resetting the timer on every update. Review also exposed detached nested promises that could leave persisted snapshots stuck in running; terminalization and a closed progress channel now make the terminal state deterministic. No new DB schema or size policy was introduced.
[S1] Problem
exec runs multiple tools inside a sandbox, but the nested tool metadata is currently discarded. The outer tool only keeps aggregate counts and a bounded trace, so a caller cannot reconstruct a nested tool result as if it had been invoked directly. In particular, actor.spawn publishes its { sessionId, actorId } through the nested context; exec swallows that callback, leaving TUI/Desktop without a reliable clickable subagent reference during execution.
The outer ToolPart is persisted by the existing part.data JSON column in SQLite. The loss happens before persistence, in tool-script.ts, so a new database table is not required for the core fix.
The Desktop MR !3727 (mimo-desktop merge 82c4ddf7a, implementation 63ae744e5) only standardized exec summaries and script/XML rendering. Its own design document records the nested-actor gap. Desktop currently receives raw engine messages from its message API; once nested metadata is present in the engine part, Desktop can project it into its live/history/shared schemas without a second source of truth.
[S2] Design
For every nested call, exec maintains one ordered, replayable record in outer state.metadata.sub_parts. The record is a ToolPart-compatible snapshot with only the outer identity fields omitted (id, sessionID, messageID are not real persisted parts):
type ExecSubPartSnapshot = {
seq: number
type: "tool"
callID: string
tool: string
state: {
status: "running" | "completed" | "error"
input: Record<string, unknown>
title?: string
output?: string
error?: string
metadata?: Record<string, unknown>
time: { start: number; end?: number }
attachments?: unknown[]
}
}The envelope is versioned as metadata.exec_schema: 1. viewExecSubtools(metadata) is a pure view function: it validates the version and entries, drops malformed records, preserves invocation order, and returns the nested tool view list. It performs no storage writes, no network calls, and never evaluates the original script. Desktop and TUI can use this list to render ordinary tool rows, with an additional outer exec fold.
The first admitted nested call publishes an initial snapshot immediately with status: "running"; later admissions and metadata callbacks update only their records through the coalesced progress path. A completed or failed call replaces its state with the same fields a direct tool part would expose. Calls not yet admitted do not appear. Terminal success, budget, timeout, cancellation, and error returns all republish the complete array, so SessionProcessor.completeToolCall replacing the part metadata cannot erase it.
Live progress publication is trailing-edge debounced (100–200 ms target window) so repeated nested metadata callbacks coalesce to the newest snapshot. The outer exec terminal path cancels any pending timer, publishes the latest complete snapshot immediately, waits for that write, and then returns terminal metadata. Nested completion events do not each force a full-array write; only the outer terminal flush is unconditional.
If the outer script terminates before an admitted nested promise settles, the terminal flush converts remaining running snapshots to terminal errors and closes the progress channel. Late host completions cannot publish stale updates into the already terminal outer part.
sub_parts is JSON-safe by contract because it is stored inside the existing ToolState.metadata record and therefore inside PartTable.data. No migration or new table is added. Existing consumers that only understand counts/recent remain compatible. Consumers that need full details can read the raw message part and pass state.metadata to viewExecSubtools; malformed or absent records degrade to the existing summary.
The TUI exec block exposes nested records in its expanded view. Any nested actor record with a valid target (sessionId + actorId from metadata, or an actor target in its input) is rendered as a clickable subagent entry, regardless of whether the action is spawn, run, wait, status, or send. Desktop can implement the same projection in live/history/share-history by forwarding exec_schema and sub_parts through its existing tool-step schema; actor notifications remain the authoritative terminal lifecycle event and must not be duplicated.
[S3] Out of Scope
- A separate SQLite table or query API for nested calls; the canonical storage remains
PartTable.data. - Reworking Desktop MR !3727 itself; this branch records the engine contract and the evidence that its requested full-information path is feasible.
- Persisting arbitrary binary attachments across the sandbox boundary; existing attachment behavior is unchanged.
- Changing the model-visible
execresult envelope or the existingcounts/recentsummary fields.
Tasks
- T1: Define and collect versioned ToolPart-compatible
sub_parts, including progressive callbacks and actor references — acceptance: every admitted nested call appears once with direct-call input/output/metadata/status/time fields, and a running snapshot is published through the coalesced progress path before it settles (covers: S2) - T2: Republish
sub_partson every terminal exec path and verify it survives outer tool-part completion/persistence — acceptance: terminal metadata cancels pending progress, flushes the latest snapshot immediately, retains all snapshots, and an SQLite-backed part round-trip returns the same envelope (covers: S2; depends: T1) - T3: Implement the pure
viewExecSubtoolsprojection and render valid nested actor records as clickable TUI subagent entries — acceptance: the view function returns only valid current snapshots in sequence order; actor rows with any supported action navigate to the actor route; non-actor/invalid rows remain safe summaries (covers: S2; depends: T1) - T4: Add regression coverage and document the Desktop MR !3727 data contract — acceptance: tool, view, persistence, and TUI tests cover partial live snapshots, complete fields, actor refs, terminal replacement, and fallback behavior (covers: S2)
Compose Spec Exec Tool View (`docs/compose/spec/exec-tool-view.md`)
feature: exec-tool-view status: delivered updated: 2026-07-27 branch: feat/exec-tool-view commits: 47c8425f..6479a56b
exec Tool View (bash-style collapse)
Report
What was built — exec (the parallel tool-call script tool) now renders like bash. Once input.code has streamed in, the part lives in a BlockTool for the rest of its life; collapsing caps the script and its output at a 10-row budget with a … marker instead of compressing everything into a one-line summary. Expanding shows both in full. The click handler and the expand/collapse hint appear only when something actually overflows, so a short script with short output is a static block — the same rule bash uses.
The budget counts rendered rows, not source lines. The first cut of this change counted source lines and bounded nothing: exec returns JSON, one line of it wraps to dozens of terminal rows, and the collapsed block still filled the screen. Estimation and clipping now live in packages/cli/src/cli/cmd/tui/util/collapse.ts and are shared with Bash, which had the same latent defect for long single-line output. Height is measured in display cells (Bun.stringWidth, plus one cell per tab), so CJK and emoji count double; a line straddling the budget is sliced on the cell budget without splitting a wide character, so a single 4000-char line still shows its head.
The pre-execution state keeps the single InlineTool pending line (~ Writing script...). Because that branch is only reachable while code is still empty, its failure color, spinner and summary children were unreachable and were removed; InlineTool's iconColor prop lost its last user and was deleted. exec output now passes through stripAnsi like bash's, since nested bash calls put raw escape sequences into <return_value> / <logs>.
On the backend, exec re-publishes the per-tool counts map in its terminal metadata. SessionProcessor.completeToolCall (packages/cli/src/session/processor.ts:359) replaces part metadata rather than merging it, so the live breakdown streamed through ctx.metadata used to vanish the moment a run finished and the summary degraded to 12 calls.
Verification
cd packages/cli && bun typecheck— PASS (clean), re-run after the display-cell rework.cd packages/cli && bun test test/cli/tui/collapse.test.ts— PASS, 16 pass / 0 fail (row estimation, clipping, CJK/emoji/tab widths, a budget invariant across grapheme classes, and the over-wide-cluster guard — verified to go red when the guard is removed).cd packages/cli && bun test test/tool/tool-script.test.ts— PASS, 42 pass / 0 fail.cd packages/cli && bun test test/tool test/cli/tui— PASS, 874 pass / 10 skip / 0 fail.- Independent subagent review of
47c8425f..b5dbe888: both acceptance criteria met, no critical findings. Two of its three minor findings were fixed in1e463724(dead pending-branch props, missingstripAnsi); the third (clip()being a plain function rather than a memo) was rejected — reads ofexpanded()inside JSX children are tracked by the render effect. A second review covered the row-budget and display-cell work and found one CRITICAL — a per-code-point width walk under-charged variation-selector emoji and clipped ~2x the budget — fixed by segmenting graphemes (36372846), plus a scrollbox-chrome under-reservation. Its re-review passed after fuzzing the budget invariant over regional indicators, skin tones, combining marks and Hangul jamo; two residual nits were fixed in6e23a566. A third review covered that residual fix and found the new guard test was not exercising the guard (its budget equalled the cluster width) — tightened in6479a56b. - Not covered: there is no render harness for the components in
routes/session/index.tsx, so wiring (which memo feeds which<text>) was reviewed by reading, not asserted. The row math itself is unit-tested.execis gated to GPT-toolset models (registry.ts:379-381), so no live TUI run was performed; both display defects were reported from user screenshots, not caught by a test.
Journey log
A source-line collapse budget is meaningless for tools whose output can be one enormous line.
execreturns JSON viaJSON.stringify(parsed, null, 2), but each string value inside it (a nestedbashstdout, agrepresult set) keeps its\nescaped, so 40 rows of text arrive as one source line. Budget rendered rows for anything block-shaped.Bun.stringWidthreturns 0 for a tab (and for a newline) — found viacomponent/prompt/offset.ts, which special-cases both to stay aligned with@opentui's editor offsets. Any width math over raw tool output has to handle tabs explicitly.Bun.stringWidthis also not additive over code points:"❤️"is 2 as a unit but 1 + 0 when summed per code point. The second review caught this as a real overshoot of the row budget; per-character width walks must segment by grapheme cluster.ctx.width(contentWidth,routes/session/index.tsx:244) subtracts the conversation box padding but not the transcript scrollbox's viewport padding or its always-visible scrollbar, so it over-states usable text columns by ~3 cells for any exact-width math.The spec's original §S2 rule — leave the two pre-execution error returns without
counts— did not survive contact with the type system:Tool.defineinfers metadata as the union ofexecute's return literals, andbun typecheckcoverstest/, so a non-uniform union makesresult.metadata.countsinaccessible in tests. All five terminal returns now carrycounts(empty map for the pre-execution pair, behaviorally identical). §S2 records the delivered rule.Hoisting
traceabove the code-size guard sotally()could be shared briefly left a duplicatedconst tracedeclaration; caught by grepping declarations, not by the first typecheck pass.Anything streamed via
ctx.metadatamust be re-emitted in every terminal return or it is lost on completion — a general trap for future live-progress tools.
[S1] Problem
The exec tool (parallel tool-call script, packages/cli/src/tool/tool-script.ts, tool id exec) renders in the TUI through ToolScript (packages/cli/src/cli/cmd/tui/routes/session/index.tsx:2292-2345) as a binary toggle:
- Collapsed (default): a single
InlineToolline —» exec 12 calls · read×7 grep×4. Neither the script nor its output is visible. - Expanded: a
BlockTooldumping the fullinput.codeand the fullprops.outputwith no truncation at all.
Every other block-shaped tool — bash above all (index.tsx:2922-2987) — treats collapse as overflow protection, not as compression to one line: the command is always visible, and the first 10 output lines leak through with a … marker. exec is the odd one out, so a batch of parallel tool calls is either invisible or floods the transcript.
A second, smaller defect: per-tool counts are streamed live through ctx.metadata (tool-script.ts:411-419) but the terminal metadata returned by execute is only { status, toolCalls } (tool-script.ts:549,568,575). SessionProcessor.completeToolCall replaces part metadata rather than merging it (packages/cli/src/session/processor.ts:359), so the moment a run finishes the breakdown disappears and the summary degrades to 12 calls.
[S2] Design
Collapsed/expanded behavior (TUI)
Restructure ToolScript to mirror Bash:
- Shape selection is a
<Switch>on whether script source has arrived. With noinput.codeyet, keep the existing singleInlineToolpending line (~ Writing script...). Onceinput.codeis a non-empty string, renderBlockToolfor the rest of the part's life, withspinner={isRunning()}on the title. BlockTooltitle stays# exec ·, wheresummarykeeps its current composition (N calls · read×7 grep×4(1!), prefixed with the failure status when the run did not complete).- Body, in order:
- script source — clipped to a 10-rendered-row budget when collapsed with a
…marker; full source when expanded; - output — the
props.outputstring with ANSI stripped (nestedbashcalls embed escape sequences), clipped to the same 10-row budget when collapsed, full when expanded. The XML envelope (<exec status=…>,<return_value>,<logs>, ``) is displayed verbatim, exactly asbashshows raw stdout. Colouredtheme.errorwhen the run failed. - hint line
Click to expand/Click to collapse, rendered only when at least one of the two blocks overflows.
- script source — clipped to a 10-rendered-row budget when collapsed with a
onClickis wired only when something overflows, so a short script with short output is a static, non-hoverable block (bash parity,index.tsx:2967).- The budget counts rendered rows, not source lines:
execreturns JSON, and one line of it wraps to dozens of terminal rows, so a source-line budget does not bound the collapsed height at all. Per-line height isceil(width(line) / columns)wherewidthisBun.stringWidthplus one cell per tab (Bun.stringWidthreports 0 for tabs, and the real tab stop in a static<text>is unverified) — so CJK and emoji count as two cells.columnscomes fromctx.widthminus the transcript scrollbox chrome and the block's border and padding. A line straddling the budget is sliced on the cell budget, dropping whole grapheme clusters, so a single huge line still shows its head instead of collapsing to a bare…. - Slicing walks grapheme clusters (
Intl.Segmenter), not code points:Bun.stringWidthis not additive over code points —"❤️"(U+2764 U+FE0F) measures 2 as a unit but 1 + 0 summed — so a per-code-point walk under-charges emoji-presentation sequences and overshoots the budget. - The prompt editor has its own width translation at
component/prompt/offset.ts(tab = 2, newline = 1) because it must match@opentui's editor offsets. That is a different coordinate system and is deliberately not shared withCollapse. - Known limitation (accepted, follow-up): the height estimate assumes character wrapping, but
<text>renders through@opentuiTextBufferRenderablewhosewrapModedefaults to"word". A row therefore breaks early at a space and the leftover spills into an extra row, so the collapsed block can exceed the 10-row budget and the final sliced row looks ragged rather than full. The budget is an approximate ceiling, not a hard bound. Two fixes exist and both were declined for now: simulating greedy word wrap inCollapse(duplicates renderer internals) and forcingwrapMode="char"on these bodies (loses word-boundary readability elsewhere). The collapsed view is readable as-is. - The row-budget helpers live in
packages/cli/src/cli/cmd/tui/util/collapse.ts(lines,columns,rows,clip) and are shared byBashandToolScript— bash has the same long-single-line defect.hasLongDisplayLine/Write's line count reuseCollapse.lines. - The pending branch is only reachable while
codeis empty, so it carries no failure color, spinner, or summary.InlineTool'siconColorprop has no other user and is removed.
Not in this change: syntax highlighting for the script body, envelope parsing, live output streaming.
Terminal metadata (backend)
exec keeps the per-tool breakdown after completion: the aggregation previously inlined in publishProgress is extracted into one host-side tally() helper (hoisted above the code-size guard together with trace), and the resulting counts map is included in all five terminal returns — code-too-large, transpile-error, failure, result-too-large, success. The two pre-execution returns emit an empty map, which is behaviorally identical to omitting the field (the TUI renders an empty counts as no breakdown) but keeps the inferred metadata union uniform so metadata.counts stays accessible to typed consumers, including tests.
running: true is not part of terminal metadata; the TUI derives running state from part.state.status.
[S3] Out of Scope
- Streaming logs / per-call trace lines into metadata for a live-scrolling collapsed view.
- A dedicated
execpanel with per-call expansion (WorkflowPanel-style). - Parsing the
<exec>envelope to renderreturn_value/traceas distinct sections. - TypeScript syntax highlighting of the script body.
- Any change to the
execenablement gate (registry.ts:379-381, GPT-toolset only). - Making the row budget a hard bound (see the word-wrap follow-up in S2): neither simulating greedy word wrap nor switching these bodies to
wrapMode="char"is done here.
Tasks
- T1: Retain per-tool
countsinexecterminal metadata — acceptance:bun test test/tool/tool-script.test.tspasses with a new assertion that a completed run's metadata carriescountswith per-tooln/errors, and that a failed inner call is reflected inerrors(covers: S2) - T2: Render
execas a bash-style collapsible block — acceptance:ToolScriptrendersBlockToolwheneverinput.codeis non-empty, showing a clipped script + clipped output +…markers while collapsed and full content when expanded, with the hint/click wiring gated on overflow;bun typecheckclean (covers: S2) - T3: Budget the collapsed height in rendered rows — acceptance:
bun test test/cli/tui/collapse.test.tspasses, covering wrapped-height counting, whole-line drops, mid-line slicing at the budget, and the column floor;BashandToolScriptboth consume the shared helper (covers: S2) - T4: Measure height in display cells — acceptance:
bun test test/cli/tui/collapse.test.tscovers CJK and emoji counting as two cells, a tab charged at least one cell, and a slice that stops before splitting a wide character (covers: S2)
Compose Spec Flaky Test Isolation (`docs/compose/spec/flaky-test-isolation.md`)
feature: flaky-test-isolation status: delivered updated: 2026-07-27 branch: fix/flaky-test-isolation commits: 028f3178..f4daa79e
Flaky Test Isolation
Report
What was built — Three suites that failed off-CI for environment reasons now assert the behavior
they claim to. assertSafeUrl takes the DNS resolver as an optional parameter, so the fail-closed and
rebinding assertions run against a stub instead of whichever resolver the developer's network hands
them; the rebinding branch gained its first test. checkpoint-rebuild-unify wipesActorRegistryTable before each test, because renderRebuildContext reads the process-wideActorRegistry.listActive() and earlier test files leave background actors behind in the singleton
SQLite client.
The workflow cancel-cascade test no longer times out. Its assertions always passed in ~280ms; the
budget was consumed by an unbounded teardown. Reclaiming the run's children notifies the parent
session's main inbox, which re-arms the parent's main runner against the auto-answering test LLM,
and SessionRunState's instance-state finalizer then awaits Runner.cancel from inside an
uninterruptible finalizer, where no timeout can fire. The test drains that runner itself, bounded, on
the interruptible side of scope close. The same drain let the previously-skipped orphan-on-cancel
regression test come back, restoring the only coverage of that bug; its recorded skip rationale
blamed a slow cancel, which measurement disproved.
Verification — bun test test/util/ssrf.test.ts passed 23 tests.bun test test/actor test/session/checkpoint-rebuild-unify.test.ts passed 130 tests / 2 skip, having
reproduced the failure on the same command before the fix.bun test test/workflow/runtime.test.ts passed three consecutive times (28 pass, 1 skip) and again
after the review follow-up. bun test (full suite) passed three times: 4360 pass / 0 fail twice
before the unskip and 4361 pass / 37 skip / 0 fail after, 4399 tests across 418 files.bun typecheck passed. oxlint on the four changed files reported 49 warnings / 0 errors against 48 /
0 on main; the single added warning is another instance of the await-thenable false positive that
already fires 20 times in that file.
Journey log
- The first fix attempt for the cancel test — swapping
llm.hangforhangUntil+ release, the
idiomruntime-worktree.test.tsuses — made it strictly worse: releasing the hang lets the child
resume, so it was still Running at teardown and the isolated run started hanging too. The
established idiom in a sibling file was the wrong tool here. - Measuring before theorising was what cracked it. Probes showed the assertions finishing in 280ms,
raising the budget to 180s still timing out (so: deadlock, not slowness), andprocess._getActiveHandles()empty (so: an Effect fiber, not a socket). - Naming the stuck disposer needed the stack captured at the
InstanceState.makecall site, outside
the returnedEffect.gen— inside it, every frame is Effect runtime internals. A first attempt
filtered stack lines on/src/, which matches every path under~/src, and silently kept nothing. - Effect finalizers run uninterruptibly, so an
Effect.timeoutplaced inside one never fires. That
is why the deadlock presented as infinite rather than as a bounded 6s stall. - Bisecting showed any predecessor test triggered the hang, which ruled out a specific
interaction and pointed at process-global state instead.
[S1] Problem
Three suites in packages/cli fail off-CI for reasons unrelated to the behavior they claim to
cover. Each failure is an environment or harness artifact, not a product defect, so each one trains
readers to ignore red output.
test/util/ssrf.test.tsasserts thatassertSafeUrlrejects an unresolvable hostname by asking
the real resolver for a.invalidname. Resolvers that answer for.invalid(ISP or corporate
DNS, mDNS) make the call succeed, so the assertion depends on which network the developer sits on.test/session/checkpoint-rebuild-unify.test.tsasserts thatinsertRebuildBoundaryreturnsfalsewhen there is nothing to push.renderRebuildContextreadsActorRegistry.listActive(), which is process-wide, and the SQLite client is a process-level
singleton, so background actors leftpending/runningby an earlier test file make the context
non-empty. It passes alone and fails in a whole-suite run.test/workflow/runtime.test.ts"cancel stops in-flight child agents and marks the run cancelled"
exhausts its 30s budget. The assertions complete in ~280ms; the remaining time is a teardown
deadlock, and it is not a slow path but an unbounded one — raising the budget to 180s still times
out.
[S2] Design
SSRF resolver injection. assertSafeUrl takes the resolver as an optional second parameter
defaulting to dns/promises.lookup, mirroring the fetchImpl parameter safeFetch already exposes
for the same reason. The DNS-dependent assertions inject a stub, so they pin the behavior that
matters — resolution failure means reject, and a hostname resolving into a blocked range means
reject — without consulting a real resolver. Production callers are unchanged.
Rebuild-context actor leakage. listActive() stays process-wide: peer actors legitimately live
in child sessions, so scoping the query to one session_id would drop live children from the "Active
actors" section. The test instead wipes ActorRegistryTable in beforeEach, the same isolationtest/session/checkpoint-rebuild-v3.test.ts already applies for the same reason, and consistent with
how that test already neutralizes the other inputs (deleting the memory dirs, capping recent_user
to 0).
Cancel-cascade teardown deadlock. Reclaiming the workflow's children makes each one notify the
parent session's main inbox, which re-arms the parent's main runner against the auto-answering
test LLM. SessionRunState's instance-state finalizer cancels every runner still in its map, andRunner.cancel awaits Deferred.await(run.done). Effect finalizers run uninterruptibly, so that
await cannot be bounded from inside and the instance disposer never returns. The test drains the
runner map itself — SessionRunState.cancel(parent.id), bounded and ignored — after its assertions
and before the fixture scope closes, where the cancel is still interruptible and the bound applies.
Out of the three, only the SSRF change touches src/, and it is additive.
Restored orphan-on-cancel coverage. The sibling test in the same describe, "cancel during an
in-flight fan-out reclaims every child (no orphan)", was skipped under the same symptom with the
diagnosis "cancel itself does not return before the test deadline". That diagnosis is wrong —cancel returns in ~300ms — and the real cause is the teardown deadlock above. With the same drain
applied it is unskipped, restoring the only coverage of the MR104 orphan-on-cancel regression.
[S3] Out of Scope
- Bounding
Instance.dispose()so a stuck instance-state finalizer cannot wedge teardown.Instance.disposeDirectoryalready bounds its path withDIRECTORY_DISPOSE_TIMEOUT; the directdispose()path does not. That is a real robustness gap, reachable in production whenever an
instance is disposed while a session run is live, but changing teardown semantics is a separate
change with its own risk surface. - Session-scoping the "Active actors" rebuild section.
- The other
llm.hangcall sites intest/workflow/runtime.test.ts. They pass today; converting
them is unverified churn.
Tasks
- T1: Inject the resolver into
assertSafeUrland rewrite the DNS assertions against a stub —
acceptance:bun test test/util/ssrf.test.tspasses with no network dependency (covers: S2) - T2: Wipe leaked
ActorRegistryTablerows incheckpoint-rebuild-unify— acceptance:bun test test/actor test/session/checkpoint-rebuild-unify.test.tspasses, having failed
before (covers: S2) - T3: Quiesce the parent session before teardown in the cancel-cascade test — acceptance:
bun test test/workflow/runtime.test.tspasses with zero failures (covers: S2) - T4: Confirm no further local-only failures across two full runs — acceptance: the union of two
bun testruns adds no failure attributable to this branch (covers: S1) - T5: Unskip the orphan-on-cancel test with the same drain — acceptance:
bun test test/workflow/runtime.test.tsgreen three consecutive times and a full run stays
green (covers: S2; depends: T3)
Compose Spec History Chunk Large Bodies (`docs/compose/spec/history-chunk-large-bodies.md`)
feature: history-index-tool-output-budget status: delivered updated: 2026-09-16 branch: feat/history-chunk-large-bodies commits: b4cc11cd652195af9a80297ed543218f3172e6c4..HEAD
History FTS Index Budget (tool-output policy)
Report
What was built — History FTS is truncated before write on the same path as tool call results: pure previewToolOutput in tool/truncate.ts is shared by Truncate.output and history extract / writer / import. Tool parts prefer the stored tool-result string (including Full output saved to: when tools truncated); other kinds compose then preview with the same 50KiB / 2000-line budget. Full text stays in PartTable / tool-output files for history get. Migration v6 removes legacy chunk rows and rebuilds one truncated index row per original part.
Verification — packages/cli: bun test test/history/ green after budget tests; tsgo --noEmit filtered to src/history/test/history clean.
Journey log
- Root cause is missing pre-write truncation, not chunking: tool results are capped; history indexed full part payloads (
patch~38MB,reasoning~600KB). - User required the tool-call-result path —
previewToolOutputextracted fromtruncate.ts; history writer/import call it before FTS insert. - Chunk deletion uses exact base id plus the binary prefix range
[id + '#', id + '$'), preserving literal_/%while using the primary-key index. - Post-LIMIT dedupe under-filled search — over-fetch
limit*24before dedupe.
[S1] Problem
Tool outputs already have a staged length policy before entering the model (truncate.output: ≤50KiB / 2000 lines; full text saved to tool-output files). History FTS extract had no equivalent budget and indexed unbounded part payloads. On a real ~7.3GB trajectory DB this produced multi-MB FTS documents and CPU-heavy snippet()+bm25() ranking.
[S2] Design
Contracts
- Single write path
upsertHistoryBody: cleanDataUrls then previewToolOutput before FTS insert (live writer, import, migration rebuild, test backfill) - Pure preview lives in
tool/preview.ts(no Log/Global);tool/truncate.tsre-exports and owns model-facingTruncate.output - Extract also strips data-URLs before preview so binary payloads never consume the tool-result budget
- Budget:
MAX_BYTES=50KiB/MAX_LINES=2000fromtool/preview.ts, head+tail when tail looks like errors; index bodies hard-clamp to this ceiling - Migration batch (
migration-batch.ts) is Log-free; schedulerstartIndexMigrationaccepts optionalmigratefor tests and clearsjobsafter 5 failed attempts - Migration v6 SQL marks prior
history_index_migrationversionsdoneso superseded state cannot linger atclean - Search keeps temporary
limit*24over-fetch (debthistory-search-overfetch-after-v6); tighten tolimitafter v6 — see Follow-ups - Search uses
CROSS JOINso SQLite evaluates MATCH once, then rowid lookup (Node SQLite plan regression covered in tests) - Tool body composition:
inputJSON previewed at ~25% budget; tool output previews keep theFull output saved to:path hint inside budget - Migration scheduler: limited error backoff (5 attempts) +
jobsslot clear on done/error/abort so restart works;stopIndexMigrationdeletes the slot previewToolOutputreservesMARKER_RESERVEbytes so omission markers stay withinMAX_BYTES; single-line giant outputs keep a head slice instead of only the marker- Tool parts: index stored tool-result string when present (incl.
Full output saved to:); still bound legacy payloads history getreads fullPartTabletext- No new index chunks are written. Indexed deletion and search normalization only support legacy chunks during background cleanup.
Background migration v6
- Always start
clean→repair clean: delete legacy chunk rows and orphan index rows; never reconstruct text by joining old chunks.repair: read original parts, apply the shared truncate policy, and upsert exactly one row under the original part id. The 50KiB preview must not be split again at 48,000 characters.- Recreate
history_part_adfor chunk ids - Separate the state insert, trigger drop, and trigger creation with Drizzle statement breakpoints so Node SQLite executes all three statements.
test/history/node-migration.test.tsverifies deletion removes chunk rows while preserving unrelated parts through the actual Node driver. - Startup waits one second before background work. Each transaction processes at most 32 rows and yields after an 8ms budget, checked between rows. Read one original body at a time; cursor and writes commit together, so yielding/restarting cannot skip unfinished rows.
- Rest after each batch for at least 100ms and at least 19 times its elapsed duration (including commit), targeting at most a 5% migration duty cycle per process. This is cooperative throttling, not a hard process CPU cap: an individual synchronous row or commit can exceed 8ms, and normal application work has a separate cost.
- The follow-up schema migration
20260916000000_history_single_row_indexreplaces the delete trigger with the indexed prefix range and starts v6 even on databases that already completed v5; those databases can still contain chunks. V6 resumes its own persisted cursor on subsequent launches. - Regression coverage: exact/chunk deletion preserves adjacent ids and literal wildcard characters; both deletion paths use an indexed query plan; both migration phases yield without losing cursor progress; the scheduler rests after work and cancels on database close.
Follow-ups
history-search-overfetch-after-v6— after v6 migration completes on a database (or once production fleet has finished v6), setservice.tssearchfetchLimitback tolimitand drop the legacy over-fetch comment.
[S3] Out of Scope
buildFtsQueryAND/OR semantics- Changing which part types are indexed
- Desktop
engine-pinbump / shipping - Online surgery of production DB outside the background migration
Tasks
- T1:
index-preview.ts+extract()tool-output budget (covers: S2) - T2:
chunk-write.tsshared upsert/delete; writer + import (covers: S2) - T3:
service.tssearch/get chunk-id normalize + over-fetch dedupe (covers: S2) - T4: Migration v6 + indexed trigger (covers: S2)
- T5: Tests: index-preview, extract budgets, chunk-write, backfill (covers: S2)
- T6: history unit suite + typecheck (covers: S2; depends: T1-T5)
Compose Spec Host Error Registry (`docs/compose/spec/host-error-registry.md`)
feature: host-error-registry status: in-progress updated: 2026-09-24 branch: feat/host-error-registry commits:
Host Error Registry
Report
[S1] Problem
Hosts need stable codes and explicit retry behavior for their own LLM API business failures, without teaching the engine product codes. Engine error names, broad HTTP statuses and message fingerprints cannot identify a particular host's API response safely.
[S2] Design
Trust and scope
The catalog is data only. The engine matches at the LLM API boundary using the providerID from the selected model's trusted call context. It never infers source from response JSON, URLs, message text or error metadata. Hosts must reserve their providerID and prevent BYOK overrides. No product provider IDs or business codes are built into the engine.
Only SDK API failures and structured provider error events are eligible. Tool errors, invalid output, aborts, programming errors and filesystem errors do not acquire host codes. Native transport failures without an API response remain on the engine's network path.
Catalog v2
This replaces unpublished v1; no birth-identity compatibility layer is retained.
type RetryClass = "terminal" | "persistent" | "bounded"
type JsonValue = string | number | boolean | null | readonly JsonValue[] | { readonly [key: string]: JsonValue }
type HostResponseMatch =
| { readonly kind: "field"; readonly path: string; readonly value: string | number | boolean | null }
| { readonly kind: "json"; readonly value: JsonValue }
| { readonly kind: "empty" }
interface HostErrorRule {
readonly match: {
readonly providerID: string
readonly statusCode?: number
readonly response: HostResponseMatch
}
readonly code: string
readonly retryClass: RetryClass
}
interface HostErrorCatalog {
readonly protocolVersion: 2
readonly rules: readonly HostErrorRule[]
}Example (synthetic values):
{
"protocolVersion": 2,
"rules": [
{
"match": {
"providerID": "test-host",
"response": { "kind": "field", "path": "/failure/reason", "value": "busy" }
},
"code": "host.example.busy",
"retryClass": "persistent"
},
{
"match": {
"providerID": "test-host",
"statusCode": 403,
"response": { "kind": "json", "value": { "error": "Example restricted account", "code": 403 } }
},
"code": "host.example.restricted",
"retryClass": "bounded"
},
{
"match": { "providerID": "test-host", "statusCode": 401, "response": { "kind": "empty" } },
"code": "host.example.unauthorized",
"retryClass": "terminal"
}
]
}All conditions within a rule are AND; the first matching rule wins. Field paths are RFC 6901 JSON Pointers, not dotted paths or URI fragments. The empty pointer "" selects the root; /token selects a member, / selects an empty key, and ~0/~1 decode to ~// without percent decoding. For example, /items/0/a~1b selects the a/b member of the first item. Arrays accept only canonical nonnegative indexes (0 or a nonzero digit followed by digits); leading zeros, signs, -, length and out-of-range indexes do not resolve. Those tokens remain valid object member names. Traversal reads only own properties and never boxes scalars or reads prototypes.
Field values use strict typed equality for strings, numbers, booleans and null; missing paths and malformed/non-JSON bodies never match null. JSON selectors accept any JSON root, including arrays, scalars and null, and compare the complete structure: object key order is ignored, additional keys and array ordering remain significant, and no type coercion occurs. Empty matches only missing/whitespace response bodies and require a statusCode; JSON null is not empty. No substring or regex matching is supported. An exact restricted-account 403 rule does not change unrelated 403 handling.
The schema rejects invalid versions, unknown keys, invalid classes, empty codes/provider IDs and malformed selectors as a whole, including nonempty pointers without a leading slash and any tilde escape other than ~0 or ~1. Rejected catalogs preserve the previous snapshot. The public loader also returns { ok: false, reason } for validation or cloning exceptions without replacing that snapshot. Valid catalogs replace it atomically; an empty array disables new bindings. Snapshots are deeply frozen.
Boundary and lifetime
bindHostError(error, { providerID }) first matches only inside the actual provider doStream() invocation and its returned raw provider stream (error parts and stream-read failures). Host transport wrappers, plugin preparation, SDK tool repair and the surrounding attempt never create new bindings. The raw stream wrapper preserves backpressure and propagates cancellation upstream. Matches and misses are cached by raw object identity and trusted providerID.
At the trusted doStream boundary, enable the SDK's existing includeRawChunks option. Some compatible adapters emit a raw frame followed immediately by an error part containing only the message string. Retain at most one raw error frame; restore structure only when the immediately following part is an error with the same string message. Both {error: {...}} and {type: "error", error: {...}} are supported. Matching uses the original complete raw JSON, not the normalization envelope. Intervening parts, normal raw frames, completion, cancellation and read errors clear the pending frame. Normal raw chunks never trigger catalog matching and internally requested raw parts are consumed before reaching the SDK fullStream/UI; an explicitly requested raw stream retains its requested visibility. No fetch or SSE parser is added.
Outside that boundary, inheritHostError follows only the SDK RetryError last-error chain to an existing binding, including cached misses, without consulting the catalog. Earlier retry-history entries cannot donate a binding to an unbound last error. Normalization also performs this inheritance before unwrapping content; the same failure retains its binding across catalog reloads. SDK-flattened stream errors use their retained original data frame for structured matching. A structured type: "error" protocol frame may carry arbitrary JSON payload fields; recognition does not require particular business field names. An outer type: "error" frame is authoritative even when its data member resembles another error frame; only flattened wrappers fall back to data.
Generic normalization and retry decisions do not match catalogs. They only propagate/read existing bindings. NamedError has no global enrichment hook. Direct construction does not stamp. Live request, processor and max-mode retry paths use fromLiveError: API host fields require an existing binding for the trusted providerID, and an absent binding or cached miss clears supplied stamps. Plugin preparation and whole-attempt failures cannot promote a plain APIError-shaped object or a constructed APIError into a host error. No catalog matching occurs at these outer boundaries. Live non-API errors always discard supplied host fields as well, preventing local output, abort or unknown errors from carrying a forged host identity to events.
fromError retains its trusted restoration/idempotence contract: schema/JSON/SQLite/event round-trips preserve API host fields without consulting the current catalog. Restoring serialized fields does not grant a live binding. Only an already-bound source transfers its binding to a normalized APIError, allowing repeated live normalization and SDK last-error wrapping to preserve the same decision across catalog reloads; a JSON clone is not live provenance. Real abort/context/load-key normalization remains native and does not gain a new host code.
Native retry facts follow only an SDK RetryError's lastError (or final array entry when lastError is absent) and actual cause links. Earlier attempts are not causes of the final failure. Normalization stores effective cause facts in metadata.causeChain, including a single remaining fact after removing an SDK retry wrapper; when the full diagnostic summary differs, it is retained separately as metadata.retryHistory. Retry classification never reads retryHistory, so an earlier timeout cannot turn a final TypeError into persistent recovery, nor can an earlier HTTP404 stop recovery of a final HTTP503. If normalization would otherwise lose a bare error's safety-terminal identity, its current operative summary is retained as causeChain even without a cause wrapper. Already-terminal normalized output is left unchanged so repeated restoration remains idempotent.
Behavior precedence
- User cancellation, actual context overflow, missing API keys and other true engine safety failures stop retry. Stream error code and type are checked independently for context overflow: a numeric business code cannot hide a context_length_exceeded/context_window_exceeded type. This applies to raw and SDK-flattened frames, with or without a catalog, and normalizes to an unstamped ContextOverflowError. HTTP provider messages explicitly saying that the prompt exceeds the model's context window also normalize to ContextOverflowError when the response has no structured overflow code; the same decision applies to the structured response error message when the SDK message is generic. Ordinary model-window configuration errors and output-limit errors remain API errors. Unsafe tool-side-effect replay remains forbidden.
- Explicit matched host behavior controls API errors. Terminal business failures cannot become network retries because their body/message mentions IO or because HTTP status is 5xx. Precisely matched host bounded errors can override broad HTTP400/403 heuristics.
- Unmatched native network/timeout errors retain mandatory persistent recovery (including HTTP408/504 legacy handling).
- Otherwise existing HTTP, quota and non-network heuristics apply. An unmatched HTTP401 is terminal even when a gateway labels its response
upstream_error: repeating an unchanged unauthorized model request cannot restore credentials. Native stream_read_error/upstream_error and rate-limit signals retain their precedence over the broad HTTP400/403/422 fallback; ordinary client failures without those signals remain terminal.
The stored host class is behavioral, independent from the diagnostic RetryKind. RetryDecision carries hostCode and hostRetryClass; retry status/events continue carrying hostCode. Terminal decisions emit no retry event. Malformed persisted stamps with hostCode but no valid class fail closed as terminal; class without code is ignored.
Budgets
max-candidate/max-judge retain their own isolation budgets. For normal request/live-step:
- Host persistent forces
mode: persistent, no maxRetries, and maxElapsedMs=0. Neither global/provider configuration nor caller budgets may downgrade this. Exponential backoff, delay caps, Retry-After and jitter remain effective. - Host bounded uses the phase request/stream budget, never the default-persistent server/rateLimit budget. Force bounded mode; missing/nonfinite count and nonpositive/nonfinite deadline fall back to existing phase defaults: request 4 retries / 30 seconds, stream 5 retries / 10 minutes. Explicit finite configured limits remain valid.
- Native network forces persistent, without attempts/deadlines, as before.
- The schedule enforces these semantics even when supplied a custom budget callback. Persistent retries remain cancellable and respect replay safety. Silent-overload limits cannot cap host persistent recovery.
Persisted event ownership
Effect InstanceRef is authoritative over ambient instance ALS when both exist. InstanceState.bind captures that Fiber context first, falling back to ALS only when the Fiber has no instance reference; callbacks outside either context remain unchanged. This is the same precedence used by InstanceState.context.
Database transaction callbacks and deferred post-commit effects must restore the captured owner before invoking synchronous instance consumers. Persisting a message or part under conflicting ALS must publish both its project Bus event and GlobalBus ordinary/sync envelopes to the Fiber owner's directory and project, never to the ambient instance. The database write succeeding alone is not sufficient verification. This fixes the callback bridge without changing SyncEvent payloads, retry behavior, or host matching boundaries.
Bootstrap and API
Keep loadHostErrorCatalog, loadHostErrorCatalogFile, hostErrorCatalog, and HOST_ERROR_CATALOG. Load before accepting traffic in CLI/shared-server paths; initialization runs once and cannot overwrite later explicit reloads. No new HTTP control plane is introduced.
[S3] Out of Scope
Product codes, UI copy, source inference, BYOK ownership enforcement, regex/plugin matching, default budget number changes, and multi-host catalog merging.
Tasks
- Specify the v2 schema, boundary and behavior contract.
- Replace birth identity matching with API-bound response matching and remove global constructor stamping.
- Wire all provider error events/thrown failures and preserve normalized API bindings.
- Enforce behavior budgets with safety precedence and native network recovery.
- Verify malformed input, provider isolation, exact-body matching, reload stability, non-API exclusion, persisted envelopes, bounded exhaustion, terminal no-retry, cancellation and recovery beyond one virtual hour.
- Run package typecheck and focused/expanded regression suites; document results.
- Reproduce persisted message event cross-directory routing under conflicting ALS/Fiber contexts, align callback binding precedence, and verify database persistence plus owner/wrong-owner/GlobalBus delivery.
Delivery verification
- Focused registry/retry/max-mode-input suite after boundary review: 120 pass, 0 fail (867 assertions).
- Provider/LLM/max-mode/prompt/compaction regression suite after compatible-adapter review: 140 pass, 0 fail (490 assertions). The two max-mode-input cases overlap; 258 distinct tests passed across both runs.
- Effect TestClock drives both request and stream host-persistent schedules through 70 failures and 71 virtual minutes to successful recovery despite bounded caller budgets and silent-overload predicates. The empty-catalog native network path also recovers after 70 failures. User interruption during a wait prevents further requests even after advancing two hours.
- Exact restricted-account HTTP403 exhausts request/stream count budgets despite a persistent custom budget. Host persistent retains max-candidate/max-judge isolation and cannot cross unsafe replay boundaries. Synthetic terminal business responses with HTTP400/408/500/503/504 and network-looking messages never retry.
- Real local HTTP/SDK integration preserves host codes on errors after output. The test first exposed SDK-flattened errors containing the original frame in data; matching now uses that structured frame. The existing HTTP408/429/503 recovery test still verifies correct-directory delivery, zero wrong-directory events and matching GlobalBus.directory.
- Boundary review reproduced a plugin preparation APICallError incorrectly acquiring a host code, then fixed it by moving first binding from whole-attempt/fullStream to actual doStream/raw provider stream. A real tool-repair callback throwing the same API-shaped error remains native; HTTP and post-output SSE bindings plus provider cancellation still pass. SDK RetryError inherits only an already-bound last error, preserving reload identity and provider isolation.
- Safety review reproduced a numeric business code masking context_length_exceeded in a stream error type. Independent code/type checks now keep raw/SDK-flattened frames and JSON round-trips terminal and unstamped, with and without a catalog.
- Real @ai-sdk/openai-compatible post-output SSE tests first reproduced loss of structured error fields into a string. Six red-to-green cases cover terminal/persistent/context behavior with and without top-level type. Persistent retries the actual request to recovery despite a zero-retry caller budget; terminal/context stop after one request. Complete-JSON and field selectors both work; raw chunks never appear in returned fullStream events and schema/JSON normalization retains the result.
- Production-routing follow-up: four regression cases failed on the old ALS-first binding. The real Session service test confirmed message/part rows persisted while the owner received neither event, the wrong instance received both, and all four ordinary/sync GlobalBus envelopes carried the wrong directory and project. Fiber-first binding makes the same test pass; additional cases cover deferred async callbacks, database use/transaction commit effects, Fiber-only, ALS-only and context-free binding.
- Post-routing regression run: 216 pass, 0 fail (1161 assertions) across instance-state, run-service, database, storage, sync, three Bus suites, registry, retry and two LLM suites. Package
bun typecheckandgit diff --checkpass. - Generic selector follow-up first reproduced 18 failures against the old path schema (6 tests still passed). Final registry/retry/LLM/LLM-retry/provider-error/message-v2 suites: 230 pass, 0 fail (2315 assertions), including 31 registry tests. Coverage includes root scalars/arrays/null, escaped and empty keys, canonical array indexes, own-property traversal, strict types, whole-catalog rejection, retained snapshots and arbitrary structured error payloads through bind/fromError.
- Complete-JSON matching also preserves literal prototype-named keys. A separate red test exposed record-schema stripping of
__proto__and rejection of an ownconstructor; strict JSON validation plus a detached clone now preserves those keys without reading inherited members during pointer resolution. Packagebun typecheckandgit diff --checkpass. - Malformed RetryError follow-up reproduced failures in both API response extraction and binding inheritance when lastError was absent and errors was missing or non-array. Both paths now guard the history with Array.isArray, matching message-v2 normalization; valid lastError remains preferred and a genuine array still falls back to its final entry. Two new path-specific regressions cover missing/null/scalar/object/array-like history without borrowing a forged binding. Registry plus message-v2: 76 pass, 0 fail (1496 assertions); the final six-suite run above also passes. Package
bun typecheckandgit diff --checkpass. - Review follow-up reproduced two failures before correction: nested error-shaped data displaced an outer raw error frame, and direct catalog loading leaked validation/clone exceptions. Field and complete-JSON regressions now preserve the outer frame; Proxy/DataCloneError and throwing-getter inputs return rejection while retaining the previous snapshot and rule behavior. Registry-only verification: 31 pass, 0 fail (786 assertions); final expanded verification is the six-suite result above.
- Native retry/source-trust follow-up added eight regressions, first failing in a seven-case run plus a separate non-API field-exclusion case. Real SDK HTTP408→TypeError history now stays bounded; earlier 404/401/network attempts cannot classify the final 503, while a final error's actual network cause survives JSON. Empty/unmatched catalogs retain HTTP400 stream/rate-limit recovery. Real chat.params plugins throwing either a plain APIError-shaped object or a constructed APIError with forged bounded stamps now stop after one attempt, not two. Strict live normalization rejects forged API and non-API fields but preserves existing bindings across reloads and repeated normalization; trusted schema/JSON restoration retains fields without acquiring a live binding. Final six-suite verification: 238 pass, 0 fail (2401 assertions); processor/max-mode verification: 30 pass, 2 existing skips, 0 fail. Package typecheck and git diff --check pass.
- Independent review reproduced loss of a single final safety fact after removing the SDK retry wrapper. Cause-bearing inputs now retain every nonempty operative summary, including one entry. A real generateText HTTP408→SubscriptionUsageLimitError regression verifies terminal decisions before normalization, after live normalization and after JSON restoration; equivalent FreeUsageLimitError/ProviderAuthError/ContextOverflowError cases and the earlier-history→503 controls pass. The new test failed before the one-line preservation fix. Registry/message-v2/retry verification: 185 pass, 0 fail (2250 assertions); package typecheck and git diff --check pass.
- Final review follow-up first reproduced two boundary failures: malformed output-length name-only objects bypassed schema validation and crashed strict live normalization, and bare safety-named Errors lost terminality without a cause wrapper. Removed the redundant output-length direct-return branch; valid instances and serialized forms still pass the existing schema path. Safety facts are supplemented only when normalization loses terminality, preserving raw/live/JSON decisions and repeated restoration without growing metadata. Final six-suite verification: 242 pass, 0 fail (2475 assertions); package typecheck and git diff --check pass.
Compose Spec Inbox (`docs/compose/spec/inbox.md`)
feature: inbox status: in-progress updated: 2026-09-26
Inbox 输入队列
每个 lane (sessionID, agentID) 的唯一持久化输入队列。所有外部输入(用户 prompt、actor send、子任务完成通知、workflow/spawn 投递)走同一套受理、认领、结算、停止逻辑。
目标
- 一个调度归属:inbox 是 lane 的唯一输入队列与受理/认领/结算所有者。Runner 只持有执行租约。
- 输入同构:任何来源的输入在受理时写成接收方切片的一条 user 消息加一行 inbox 记录,不按发送方或 type 分叉行为。
- 唤醒同构:任何受理都递增
input_revision,显式actor.wait与自动等待一律以 interrupted 结束。 - 读取聚合:每个步骤边界认领该 lane 全部 accepted 记录,多条输入同边界一起对模型可见。
- 可见性:只对模型隐藏仍在排队的输入(accepted 未认领,或被其他运行认领)。
- 停止语义:用户停止把 accepted/claimed 记录结算为 cancelled,消息保留且对模型可见,不自动执行。
- 回执契约稳定:HTTP
prompt_async202{receiptId}、GET /session/:sessionID/receipt/:receiptId、session.receipt.updated事件由 inbox 记录提供,PublicReceipt单源。
表结构
新迁移目录 packages/cli/migration/<ts>_inbox_queue/。已发布的 20260527000100_inbox 迁移不改。SQLite 不支持的列变更用表重建。
`inbox`(重建)
| 列名 | 类型 | 说明 |
|---|---|---|
id |
text PK | ULID |
receiver_session_id |
text NOT NULL | lane,FK session cascade |
receiver_actor_id |
text NOT NULL | lane,默认 main |
sender_session_id |
text | 发送方会话(agent 输入) |
sender_actor_id |
text | 发送方 actor |
type |
text NOT NULL default 'text' |
输入类型 |
content |
text NULL | 仅旧格式行;新行不存正文 |
input_message_id |
text | 受理时写入的 user 消息 ID |
state |
text NOT NULL default 'accepted' |
accepted | claimed | settled | cancelled | rejected |
epoch |
integer NOT NULL default 0 | 受理时的会话 epoch |
run_id |
integer | 认领运行 ID |
claim_frontier |
text | 认领前沿(MessageID 序) |
consumed |
integer NOT NULL default 0 | 是否已被模型读入 |
outcome |
text | success | assistant_error | interrupted | never_ran |
delivery_message_id |
text | 首次读入该输入的 assistant 消息 |
error |
text | 结算错误 |
idempotency_key |
text NOT NULL default '' |
会话内幂等;空串表示无键 |
created_at |
integer NOT NULL | |
time_updated |
integer NOT NULL |
索引:
inbox_receiver_idx (receiver_session_id, receiver_actor_id, id)inbox_state_idx (receiver_session_id, receiver_actor_id, state)inbox_created_idx (created_at)inbox_idem_idx唯一(receiver_session_id, idempotency_key) WHERE idempotency_key != ''
`inbox_lane_state`(新表)
| 列名 | 类型 | 说明 |
|---|---|---|
session_id |
text NOT NULL | FK session cascade |
agent_id |
text NOT NULL default 'main' |
|
consumed_frontier |
text | 已消费 MessageID 前沿 |
input_revision |
integer NOT NULL default 0 | 单调递增输入修订号 |
time_updated |
integer NOT NULL |
主键 (session_id, agent_id)。
`inbox_session_epoch`(新表)
| 列名 | 类型 | 说明 |
|---|---|---|
session_id |
text PK | FK session cascade |
epoch |
integer NOT NULL default 0 | 会话取消 epoch |
time_updated |
integer NOT NULL |
turn_legacy_bootstrap 不再需要:旧历史消息没有 inbox 记录,按可见性规则直接对模型可见。
新迁移做什么
packages/cli/migration/<ts>_inbox_queue/migration.sql(<ts> 晚于已发布的最新迁移 20261012000000_history_cjk_unigram):
- 表重建
inbox:建inbox_new(上表列)→ 把旧行拷入(content保留、input_message_id置 NULL、state='accepted'、time_updated=created_at)→ 丢旧表 → 改名 → 建四个索引。 - 建
inbox_lane_state、inbox_session_epoch。 - 不写清理
turn_receipt/turn_lane_state/turn_session_epoch/turn_legacy_bootstrap的语句(那四个表与两个迁移目录从未发布,当从不存在,直接删掉目录即可)。
旧行转成消息不在 SQL 里做:生成 MessageID 与确定 synthetic 消息的 agent/model 都依赖运行时,见「已发布数据」。
状态机
accepted → claimed → settled (outcome success | assistant_error)
→ cancelled (outcome interrupted | never_ran)
accepted → cancelled | rejected
claimed → cancelled (停止 / epoch;consumed 区分 interrupted 与 never_ran)settled+assistant_error:回合已跑,assistant 消息带错误。- 一个
runId可认领 N 条记录;认领与执行通过acquireUseRelease形成完整生命周期,退出时统一结算,覆盖已认领但尚未进入runLoop的取消。runLoop共用结算处理,成功、assistant 错误与执行中断均覆盖,每条 claimed 记录得到终态。 - 幂等:唯一性按会话(
receiver_session_id+idempotency_key)。并发 accept 同键一条插入胜出,另一条返回同一记录。
受理(accept)
一个事务内完成:
- 把输入写成接收方切片的一条 user 消息。agent 发来的标记
synthetic,消息 provenance 为{ source: "agent", senderSessionID?, senderActorID? }。synthetic 消息的agent/model在受理时按接收方确定(resolveDrainSeed语义:standing peer 跨切片搜索,无历史时使用注册 agent 与默认模型;workflow 父 agent 只搜本切片)。缺少接收方模型来源的协调切片使用systemagent/model 保存消息;laneKick仅在接收方切片能够解析真实模型时执行,防止子 actor 通知凭空产生真实回合。 - 写一行 inbox 记录:
state=accepted、epoch、input_message_id指向该消息、幂等键、type、发送方。 - 递增该 lane 的
input_revision。
事务提交后发布 session.receipt.updated。lane 空闲时经唯一调度路径(schedulerRef.kick)启动执行。laneKick 在执行租约内认领输入;子 actor 的续执行先取得 ActorExecution,再认领待处理记录,认领为空时结束,不发送额外终止通知。spawn/hook 初始执行沿用其外层 actor 生命周期,续执行由自己的 runTurn 负责终止通知。
用户 prompt 与 agent 输入共用 inbox.accept:sessions.persistQueuedUser 并入其中,不再单独存在。消息上的 queueAdmission 标记删除,排队状态只以 inbox 记录为准。
noReply 的 prompt 只写入历史、不请求执行,所以不经受理:直接写成没有 inbox 记录的普通 user 消息,按可见性规则对模型可见,不递增 input_revision,不启动执行,也不会被之后的运行认领。
跨会话投递与发送前接收方存在性检查(InboxReceiverNotFound)保留。inbox 不再存正文。send 的 wake / isWakeCancelled / wakeEpoch 参数由 AdmissionGuard(isCancelled / expectedEpoch)取代。会话停止时发出的子 actor 终止通知照常受理,随后由停止规则结算,没有单独的受理即作废路径。
唤醒
任何受理都递增 input_revision。显式 actor.wait 与自动等待(waitForSubagents)都经 observeInput(lane, afterRevision) 解除并返回 interrupted(有新输入到达),不取消子 actor。lane 空闲时受理即通过唯一调度路径启动执行。
读取
每个步骤边界认领该 lane 的 accepted 记录(claimNext),单次最多 100 条(MAX_CLAIM_PER_STEP),对应消息对模型可见并记录 delivery(delivery_message_id,事件在事务提交后发布)。写入 delivery_message_id 的同一事务把 consumed 置为 true:从这一刻起输入已被模型读入,之后停止结算为 interrupted。多条输入在同一边界一起读到。inbox.drain 及其手工回滚逻辑删除。extendClaim 在同一运行内认领边界到达的新输入。
可见性
只对模型隐藏仍在排队的输入:
- inbox 记录为
accepted(未认领)→ 隐藏。 - 被其他运行
claimed→ 隐藏。 cancelled、settled→ 可见。- 没有 inbox 记录的消息(历史、压缩 replay、导入等)→ 可见。
停止
用户停止时,该会话所有 accepted/claimed 记录结算为 cancelled:outcome 按是否已被模型读入取 interrupted(consumed=true)或 never_ran(consumed=false)。停止操作由 withStop 包围:期间抑制该会话的调度与认领,输入仍正常受理;停止操作收尾复用相同结算规则处理期间收到的输入,不再次递增 epoch。消息留在消息记录里、对模型可见,不自动执行。下次任何输入触发运行时模型会看到它们。用户 prompt 与 agent 消息同一规则。keep-suspended / drop 策略与 suspended 列删除。
abortSession 仍递增会话 epoch;epoch 低于当前值的 accepted/claimed 记录不可再被认领。
对外契约
| 端点 | 契约 |
|---|---|
POST /session/:id/prompt_async |
202 + { receiptId } |
POST /session/:id/message |
受理。busy 不 409。认领开始且客户端仍连接 → 200 stream;否则 202 + { receiptId } 并结束响应 |
GET /session/:id/receipt/:receiptId |
返回 PublicReceipt 投影 |
POST /session/:id/abort |
响应 { epoch } |
| 事件 | session.receipt.updated { sessionID, receiptId, agentID, state, outcome?, inputMessageID, deliveryMessageID?, epoch } |
PublicReceipt 字段:id、lane、state、type、senderSessionID?、senderActorID?、epoch、consumed、outcome?、inputMessageID、deliveryMessageID?、error?。内部记账(runId、claimFrontier、idempotencyKey、time)不进外部契约。
每条记录只承载一条输入,字段直接平铺在回执上,没有 intent 包装。resume 与 shell 是独占入口(C-03),不写 inbox 记录,因此不存在 resume、shell、wake 类型。
记录只引用两条消息:
inputMessageID:受理时写入的 user 消息。客户端用它把回执关联到消息。deliveryMessageID:首次读入该输入的 model-step assistant 消息,只写一次;引擎渲染上下文时用它确定输入的位置,客户端用它判断输入已被消费。写入时发布session.receipt.updated。
结算(ack)只写 state / outcome / error,不记录任何消息 ID。OpenAPI 响应 schema 与 HTTP body 共用 PublicReceipt zod 单源。
fork
fork 的目的是让新会话继承原会话的 prefix cache,所以新会话渲染出的上下文必须与原会话逐条一致。
复制消息时按原会话渲染给模型的顺序写入:先按「可见性」过滤,再按 delivery_message_id 把已消费的输入移到对应 delivery 消息之前,然后按这个顺序为每条消息分配递增的新 MessageID(消息 ID 是全局主键,必须换新),assistant 的 parentID 指向对应的新 ID。消息读取按创建时间排序、再按 ID 排序,所以复制时把每条消息的 time.created 取为它的原值与序列中前一条消息 time.created 的较大值,使创建时间沿渲染顺序不递减。仍在排队的输入(accepted 或 claimed 未消费)按受理顺序排在末尾。messageID 截断点按渲染顺序计算:只复制渲染序列中该消息之前的部分。
fork 不复制 inbox 记录,也不改写任何记录里的消息 ID。新会话里的消息全部是没有 inbox 记录的普通历史,按可见性规则对模型可见,存储顺序即渲染顺序。
已发布数据
用户机器上可能存在旧格式待处理 inbox 行(有 content、无 input_message_id)。inbox 服务启动时把它们转为新模型:用与受理相同的写消息逻辑把 content 渲染为一条 synthetic user 消息,写入 input_message_id 并清空 content,state 保持 accepted、input_revision 照增。这是唯一的转换实现;content 列只为这些旧行保留为可空,新行永远为空。正文为空白或渲染为空白的旧行直接结算为 rejected,同时清空 content,后续启动不再转换。
GC
原有 7 天按 created_at 清理改为:已结算记录(settled / cancelled / rejected)在 time_updated 之后 7 天清理。accepted / claimed 不按年龄清理。
每个调用方如何改
`packages/cli/src/actor/notification.ts`
makeTerminalNotifier改调inbox.accept(原inbox.send):content改为在受理时写入 user 消息,不再先落content再 drain。wake/isWakeCancelled/wakeEpoch参数换成AdmissionGuard(isCancelled/expectedEpoch)。wake: false(会话停止时的终止通知)改为照常受理:inbox 记录照写、input_revision照增,随后由停止规则结算。- 删掉
sent.wakeSuppressed分支判断。
`packages/cli/src/actor/spawn.ts`
cancel(..., { wake })的wake选项与notifyTerminal的wake: false传递链改为走AdmissionGuard。wakeEpoch捕获改为expectedEpoch语义(发送前捕获、受理时校验)。- 删掉对
turnQueueRef的直接依赖,改用inbox服务。
`packages/cli/src/workflow/runtime.ts`
inbox.send改inbox.accept;wake: false同上。- 子 agent 完成通知同样受理(写 user 消息 + inbox 记录 + 增
input_revision),因此会唤醒父 agent 的等待。resolveDrainSeed的「workflow 父 agent 只搜本切片」语义在受理时生效,不凭空产生真实回合。
`packages/cli/src/tool/actor.ts`
inboxServiceRef.current.send改accept;返回值给inputMessageID与inboxID。InboxReceiverNotFound错误分支保留。
`packages/cli/src/session/prompt.ts`
createUserMessage→sessions.persistQueuedUser与tq.admit合并为一次inbox.accept(同一事务写消息 + inbox 记录 + 增input_revision)。queueAdmission标记由 inbox 记录取代。- 删掉
inbox.drain全部调用(约 862、4610、4735、6228 行)及deferInbox分叉;步骤边界改为claimNext/extendClaim。 - 认领可见性过滤(约 4766–4785 行)改为按 inbox 记录状态判断:accepted 未认领或其他运行 claimed 的消息隐藏;其余可见。
recordDelivery写delivery_message_id,事件在事务提交后发布。- 运行结束结算(ack)不再传
messageId。 abortSession去掉queuedPolicy;cancel不再收AbortPolicy。waitForSubagents与显式actor.wait都用observeInput解除;任一输入唤醒。- 删掉
requireClaim/inboxWake/LoopInput相关字段与分叉(约 3536、6155、6227、7372、7546、7548 行)。
`packages/cli/src/session/session.ts`
fork按「fork」一节改写:按渲染顺序复制消息,不复制 inbox 记录。
服务端路由 `packages/cli/src/server/routes/instance/session.ts`
prompt_async202{receiptId}不变。GET /receipt/:receiptId用PublicReceipt单源。- abort 请求体去掉
queuedPolicy,响应{ epoch }不变。
同步 `packages/cli/src/turn-queue/sync.ts` → `packages/cli/src/inbox/sync.ts`
- 表名换成
inbox/inbox_lane_state/inbox_session_epoch。 - 去掉 bootstrap 分量。
materializeHistory继续为导入历史写 settled 记录。
其它
packages/cli/src/effect/app-runtime.ts、control-plane/workspace.ts、sync/restore.ts、server/routes/instance/sync.ts、cli/cmd/export.ts、cli/cmd/import.ts、session/{codex,claude,opencode}-import.ts:import 路径换成@/inbox,表名同步。- 重新生成 SDK(
./packages/sdk/script/build.ts)。
要删除的代码清单
| 位置 | 内容 |
|---|---|
packages/cli/migration/20260920000000_turn_queue/ |
整个目录(从未发布) |
packages/cli/migration/20260924000000_turn_legacy_bootstrap/ |
整个目录(从未发布) |
packages/cli/src/turn-queue/ |
整个模块(controller.ts、schema.ts、events.ts、scheduler.ts、sync.ts、turn-queue.sql.ts、turn-queue-ref.ts、index.ts) |
packages/cli/src/inbox/inbox.ts |
send 里的 promptRef.loop / inboxWake / requireClaim 分叉、wake 意图、inboxWatermark 合并、MAX_DRAIN_PER_TURN、drain 及其手工回滚、InboxArrived 发布(actor/events.ts 中的定义一并删,无订阅方) |
packages/cli/src/turn-queue/schema.ts |
WakeIntent、ResumeIntent、ShellIntent(后两者从未被受理)、isCoalescable、intentRank、AbortPolicy、suspended 字段 |
packages/cli/src/session/session.ts |
persistQueuedUser(并入 inbox.accept);fork 中复制 inbox 记录与 idMap 改写记录字段的整段 |
packages/cli/src/inbox/controller.ts |
ack 的 messageId 参数及其写入 delivery_message_id 的 coalesce |
| 回执投影与事件 | intent 包装、messageId 字段 |
packages/cli/src/session/message-v2.ts |
user 消息的 queueAdmission 字段 |
packages/cli/src/server/routes/instance/session.ts |
abort 的 queuedPolicy |
packages/cli/src/session/prompt.ts |
requireClaim / inboxWake / deferInbox / inbox.drain 调用 / queuedPolicy |
packages/cli/src/session/resume-test-hooks.ts |
inbox.drain 相关注释与钩子 |
测试点 (a)–(o)
| # | 场景 | 验收 |
|---|---|---|
| (a) | 显式 wait A 时 B 的完成通知唤醒 main | main 的 actor.wait 返回 interrupted;子 actor 不被终止 |
| (b) | 自动等待被任一输入解除 | waitForSubagents 在 prompt 或 agent 输入受理后结束并返回 interrupted |
| (c) | 同一 lane 多输入同边界读到 | prompt 与 agent 消息按受理顺序在同一步骤边界一起被认领,均对模型可见 |
| (d) | 停止后未读输入 | prompt 与通知为 never_ran;不自动执行;下一次运行时对模型可见 |
| (e) | 排队中输入不可见 | accepted 未认领的输入在认领前对模型隐藏 |
| (f) | 进程重启 | 待处理 inbox 记录不丢,boot 后仍可认领 |
| (g) | 旧格式 inbox 行 | 有 content 无 input_message_id 的行转为 synthetic user 消息并被引用 |
| (h) | 跨会话投递接收方不存在 | 报 InboxReceiverNotFound |
| (i) | workflow 父 agent 收到子 agent 通知 | 不凭空产生真实回合(resolveDrainSeed 切片范围回归) |
| (j) | 压缩溢出后续跑 | test/session/turn-queue-admission.test.ts:740 场景 |
| (k) | deliveryMessageID 与回执事件 |
test/server/session-prompt-busy.test.ts:356 场景 |
| (l) | 旧格式行正文或渲染为空 | 启动转换时结算为 rejected,不写空消息,重复启动无额外变更 |
| (m) | GC | 已结算记录在 time_updated 后 7 天清理;accepted/claimed 不清理 |
| (n) | fork 继承上下文 | 原会话含插话(输入受理早于 delivery)时,fork 后新会话渲染的消息序列与原会话逐条一致(角色、内容、顺序);新会话无 inbox 记录;按 messageID 截断时按渲染顺序截断 |
| (o) | noReply prompt |
不产生 inbox 记录;之后的 prompt 不会执行它,也不会因它多出一次模型请求;下一次运行时对模型可见 |
Compose Spec Log Safety (`docs/compose/spec/log-safety.md`)
feature: log-safety status: delivered updated: 2026-07-27 branch: fix/tui-log-stderr commits: 014a2577..aa28c273
Log Safety
Report
What was built — Logger instances own unique active files and serialize initialization, writes, rotation, and shutdown through one lifecycle queue. Cleanup preserves live files, recovers stale process and worker files, enforces archive count and size budgets, and stops file writes safely when finalization cannot complete. CLI hard exits use Log.exit() and normal TUI exits return through the top-level shutdown.
The logger no longer treats stderr as a fallback sink. Records reach stderr only under --print-logs; without a usable file sink they are dropped, and sink failures are swallowed rather than reported. The TUI worker flushes before its bounded teardown and closes its log as the last teardown step, so instance disposal and bus unsubscribe records land in the file instead of on the rendered screen.
Verification — MIMOCODE_PROCESS_ROLE=main bun test test/util/log.test.ts passed 18 tests / 53 expectations, and MIMOCODE_PROCESS_ROLE=worker bun test test/util/log.test.ts test/effect/app-runtime-logger.test.ts test/effect/runner-warn-log.test.ts passed 26 tests. bun typecheck passed. oxlint on the changed files reported zero warnings and zero errors.
Reproduction, before and after: running the same child-process script against the pre-fix log.ts leaked INFO … teardown after shutdown to stderr, while the fixed logger emitted nothing. End-to-end, a tmux-driven bun dev <dir> quit through the exit prompt command on main printed nine teardown records to stderr — the exact lines the user reported — with the worker log file ending at worker shutting down; on the delivered head stderr stayed empty and the worker log file contained the full teardown sequence through service=bus type=session.updated unsubscribing.
Journey log
- The first review exposed that a write queue alone is insufficient; lifecycle mutations must use the same queue.
- Capturing a stream before queued rotation can make shutdown close the wrong resource; shutdown now resolves the current stream only when its queued operation runs.
- Bun main and worker contexts share a PID, so stale worker recovery also needs the process role.
- Closing the worker log before its teardown traded one bug for another: the records were durable but were printed over the TUI. Flushing, then closing last, satisfies both; a worker killed mid-drain leaves an
.active.logthat the next initialization recovers. - Gating the failure report behind print mode made it unreachable — print mode never opens a file — which is why the reporting path was removed instead of gated. Attempting to test a branch is the cheapest way to discover it cannot happen.
bun devwrites its logs under the checkout's.dev-home(MIMOCODE_HOMEinscript/dev.ts), so redirecting only stderr to a file keeps the TUI on the real terminal while making a leak trivially greppable.
[S1] Problem
The CLI main context and TUI worker initialize logging independently but can target the same file. Rotation and cleanup can therefore rename, truncate, or delete a file another context still writes. Log writes also discard asynchronous failures and fatal exits do not flush.
[S2] File Ownership And Retention
Each logger initialization owns a unique active filename containing the timestamp, process role, PID, and a random instance identifier. Active files are distinguishable from completed and rotated files and are never selected by cleanup. Initialization, writes, rotation, and shutdown share one ordered lifecycle queue, so concurrent initialization closes the prior stream before replacing its state.
With rotation enabled, queued records that individually fit within the threshold rotate before an active file would exceed 50 MiB. Archived files are reduced to at most 10 and at most 200 MiB in total, and cleanup errors do not interrupt logging. A failed archive move uses a copy/remove fallback; if finalization still fails, the file sink closes instead of reopening the same oversized file with a reset counter. Explicitly disabling rotation continues to permit one active file to exceed 50 MiB.
[S3] Failure And Shutdown Behavior
Stream failures are consumed by the logger and must not create unhandled promise rejections, crash on a stream error event, or recursively call the logger. They are not reported to any stream: a file failure can only happen while logging to a file, which is exactly when stderr belongs to the rendered TUI screen, so the missing or short log file is the only signal. flush() waits for queued writes and leaves the file sink open. shutdown() runs after earlier queued rotations, closes the final stream, and marks its file completed. CLI hard exits use Log.exit(), and normal TUI exits return through the top-level shutdown.
[S4] Terminal Isolation
The logger writes to stderr only when the process was initialized with print (--print-logs), and then only the records themselves. Without a usable file sink — before initialization, after shutdown(), or when opening or finalizing the file failed — records are dropped. The TUI renders on the same terminal the process writes stderr to, so a leaked line corrupts the screen; a dropped record is preferable to a corrupted display.
The TUI worker therefore flushes, not closes, before its bounded teardown: queued records must be durable in case the host kills the worker mid-drain, while the teardown that follows (checkpoint drain, instance disposal, server stop) must still log to the file. The worker closes its log as the last teardown step. A worker killed before that leaves an .active.log that the next initialization recovers.
[S5] Testing Boundaries
Tests use real temporary files and streams. They cover unique ownership across repeated and concurrent initialization, same-PID worker recovery, ordered concurrent writes, size rotation, shutdown queued behind a rotation, retention by count and total bytes, active-file preservation, disabled rotation, write failure without unhandled rejection, and flush/shutdown persistence.
Terminal isolation is verified in a child process, because the sink state is module-level: records emitted before initialization and after shutdown() produce empty stdout and stderr while earlier records remain in the file, an unusable log directory produces no output at all, and print mode still writes records to stderr — without creating a file, and unaffected by an unusable log directory. An isolated dev CLI run verifies that a post-initialization command error exits nonzero with no active log and one completed log.
[S6] Out Of Scope
This change does not introduce per-record truncation, modify MCP/ACP/voice logging, add time-based retention, compress archives, change session/database retention, or remove the explicit rotation-disable option.
Tasks
- T1: Implement unique active-file ownership and a serialized lifecycle queue — acceptance: concurrent initialization cannot target, leak, or clean another live active file, and file/count/total limits hold (covers: S2)
- T2: Add failure-safe flush, shutdown, and command exit lifecycle — acceptance: write/finalization failures do not reject globally or resume unsafe file writes, and CLI/TUI/worker exit paths close pending logs before termination (covers: S3; depends: T1)
- T3: Add and run focused tests and package typecheck — acceptance: all S5 cases pass from packages/cli and typecheck succeeds (covers: S5; depends: T1, T2)
- T4: Confine log records to the file sink unless print mode is active — acceptance: a record emitted after
shutdown()or beforeinit()produces no stderr output, while--print-logsstill prints (covers: S4) - T5: Keep the worker's file sink open across teardown — acceptance: a TUI quit writes the worker teardown records (instance disposal, bus unsubscribe) to the log file and nothing to stderr (covers: S4; depends: T4)
Compose Spec Loop Streak Recovery (`docs/compose/spec/loop-streak-recovery.md`)
feature: loop-streak-recovery status: delivered updated: 2026-08-28 branch: feat/loop-streak-recovery commits: f02ee661a6..e5c9ba229b
Loop Streak Recovery
Report
What was built — Request-layer loop-streak crop behind experimental.loop_streak_recovery, aligned with turn recovery: no new user, lastUser/parentID unchanged, span persisted as an ignored+synthetic part on the existing parent user. Detection is thinking-primary (tools may drift). Spans re-apply on every later request until the feature is off. Cropped assistants stay in the DB; model-facing stream has no loop/recovery wording. max_span ceiling (default 64).
Verification — bun typecheck PASS; bun test test/session/loop-streak.test.ts PASS (24). Independent review of 9bab2c2a12 found 3 criticals + 3 mediums → fixed 5a597d938e → re-review PASS. Persisted-span follow-up after design critique that per-request re-detection breaks prefix stability. Follow-up review of f246a05e0: #1–#4 (re-apply suppressing text-loop, crop.kept, dead exports, key-range crop) confirmed and fixed in e5c9ba229b; #5 (span.toId === lastFinished.id "dead guard") rejected — empty-key lastFinished is not in entries, so the guard skips a new crop after the streak already broke. Second-pass review: #4's regression test used x_between, which sorts after a3 and never reached the streakKey clause — patched to an in-range id (a1narr) and mirrored for applyPersistedCrops.
Journey log — Suffix delete does not break Anthropic 20-block lookback by itself; real risks are deleting the anchor, a long recovery body, or middle-of-prefix edits. Streak key must be thinking-primary. Per-request re-detection is wrong: if the tail changes, the view flips, LCP truncates, and poison returns — detect once, persist the span as an ignored part on the parent user, re-apply every request. Never invent a Continue. user; transform.ts owns trailing-assistant Continue.
[S1] Problem
Agent turns enter repetitive streaks: the same (or near-identical) thinking is replayed across consecutive assistant messages while tools only drift slightly. Current detectors often miss this shape:
text-loopkeys on visible text + tool inputs; pure thinking+tool loops with changing narration never trip it, and its recovery only appends a reminder.stepSignature/ repeated-step nudge keys only on tool name+input and also only injects a reminder.- Recovery never removes the poisoned history, so the next turn still sees the failed plan as few-shot context (observed in session
ses_-ffe5fb84445ccffeZsPH2DWlj, where "I got stuck in a loop…" and identical 4935-char thinking replayed for many consecutive assistants).
Local DB evidence (message/part tables):
| Signal | Streaks | Max consecutive |
|---|---|---|
| Identical reasoning head | 621 | 661 |
| Identical tool signature | 3469 | 628 |
| Thinking streak ≥ 20 msgs | 12 | — |
| Tool streak ≥ 20 msgs | 15 | — |
So "delete more than 20 Anthropic content blocks" is a normal case, not an edge case. That does not by itself break Anthropic lookback: lookback walks remaining blocks from the new breakpoint, and a suffix delete keeps the pre-loop write anchor within 1–2 blocks. The real cache risks are deleting the anchor, inserting a long recovery body, or middle-of-prefix edits.
[S2] Design
Contracts
- Physical layer is audit-only. Loop recovery never
removeMessages. DB keeps the full trajectory for replay, undo, and forensics. - Request-layer crop, no new user. Align with turn recovery (
resume): do not create a Continue./recovery user. Reuse the existing parent user (lastUserunchanged →parentIDmatches resume). Persist the span as anignored+synthetictext part on that user. Trailing-assistant repair stays intransform.ts. - Thinking is always in scope. Deletion unit is a whole assistant message (reasoning + text + tools + embedded results). No part-level surgery in v1.
- Anchor is mandatory. The message immediately before the streak start is never removed (user or non-matching assistant).
- No snapshot rollback. Dirty workspace after an edit streak is treated like an external file change; the model re-reads and re-plans.
- Effectiveness > cache. Breaking the loop wins over a cache miss. Prefer prefix-safe crop; never keep poison thinking just to save cache.
Streak signature
Per finished assistant message, build:
reasonHash = sha256(normalize(join(all reasoning part texts)))
toolSig = stableStringify(tools in part-id order)
key = "reason:" + reasonHash when reasoning is present
key = "tool:" + toolSig when only tools are present
key = "" when neither is presentThinking is the loop source: crop eligibility keys on thinking alone so
slightly drifted tools still form one streak (MR-3931 shape). Tool-signature
keys are the fallback for thinking-less steps. Normalize reasoning likenormalizeForLoopDetection before hash. Do not rewrite stored parts;
hash is offline-only.
Detection
Detect once per streak, not on every request. Re-detecting would flip the
request view, break the cache prefix, and re-introduce poison thinking after a
successful crop.
At the start of each loop iteration:
- Re-apply every persisted span (
extractAllCrops/applyPersistedCrops) from user-part metadata (kind: "loop_streak_crop"). Spans stay active for the life of the feature — including after the user sends a new message — so poison thinking cannot re-enter the request view. - Then, only when
lastUser.id < lastFinished.id(continuing from a model step), detect a new streak on the already-cropped view:- Build keys for finished assistants.
- Trigger when the last
triggerCount(default 3) finished assistants share onekey. - Walk backward while
keymatches; predecessor is the anchor (always kept). - Require
span.toId === lastFinished.id. - On hit: crop the request view, persist an
ignored+synthetictext part on the existinglastUserwhosemetadata.originis{ kind: "loop_streak_crop", fromId, toId, key, truncated }. Do not create a new user; do not reassignlastUser.
- Clearing is explicit only: turn the feature off (next request returns to the full view). There is no auto-clear on user speech.
Persisting the span as an ignored part on the parent user gives: stable
prefix across later requests, restart survival, no new user/parentID, and no
model-facing loop wording. Cropped assistants stay in the DB.
Request crop
kept = messages with id < span.from OR id > span.to
+ force-include anchor (id < span.from, already kept)
span = ignored synthetic part on the existing parent userLater requests re-apply the same fromId..toId filter. No extra user is
inserted. If the cropped view ends on an assistant, transform.ts trailing-
assistant repair supplies Continue. on the wire only.
Invariants (assert in tests, log in prod):
- Kept prefix JSON equals original messages with span filtered out (no reorder, no field rewrite).
- Parts inside kept messages stay in
part.idorder. - Tool_use/tool_result pairing is preserved because whole assistants are removed.
- Conversation after crop + recovery ends with a user message (Bedrock prefill safety).
Safety ceiling
| Knob | Default | Behavior |
|---|---|---|
max_span |
64 | If streak length > ceiling, crop only the trailing ceiling messages of the streak (most recent poison). Older same-key messages stay; recovery note states remaining similar count. |
trigger_count |
3 | Same as current text-loop trigger. |
enabled |
false (experimental) | Behind experimental.loop_streak_recovery. |
Ceiling exists only to stop a detector bug from nuking a multi-hour 600-message run. It is not a cache bound.
Cache audit (not a gate)
On every crop, emit slog:
session_id, spanFrom, spanTo, anchorId,
nMessages, nParts, omittedBlocks, keptBlocks,
remainingSimilar, truncatedByCeiling,
cacheRisk: omittedBlocks > 20omittedBlocks ≈ per cropped message: #reasoning + #text + 2×#tool.
Rough is fine; purpose is observability of "we routinely crop >20 blocks".
Do not skip crop when cacheRisk is true. Log only.
Provider notes
| Provider | Crop impact |
|---|---|
| Anthropic | Suffix delete keeps pre-loop write; lookback hits anchor. Immediate next request maximizes hit. Thinking signatures disappear with the deleted assistants (desired). |
| OpenAI | LCP ends at anchor; usually hit, best-effort. |
DeepSeek interleaved.field |
Whole-assistant delete is the only safe mode; join cannot align thinking to tools. v1 already whole-assistant only. |
| Bedrock | No new user is inserted; if the cropped view ends on an assistant, transform.ts trailing-assistant repair supplies Continue. on the wire. |
| MiMo self-hosted | Behavior win only; no cache contract. |
Interaction with existing detectors
- text-loop / text-ngram: keep as fallback when streak key does not fire (e.g. pure text loops with no tools/reasoning). If streak crop already ran for this turn, skip injecting another recovery user (
loopStreakCropped). - repeated-step nudge: keep; it fires on identical tools even when thinking differs. Streak crop keys on thinking when present, so both can independently surface related loops.
- try-best / doom_loop: unchanged (pause / permission). Independent of crop.
Recovery note content
None in the model-facing stream. Span persistence uses an ignored +synthetic part (text body irrelevant; not sent). No loop/recovery wording.
Counts/audit stay in slog. Wire-level trailing assistant uses transform.tsContinue. only.
[S3] Out of Scope
- Physical deletion / compaction of loop spans.
- Snapshot or filesystem rollback of edit streaks.
- Part-level tail surgery inside one assistant (interleaved R-T-R).
- Changing Anthropic breakpoint placement in
transform.ts. - Unifying try-best pause UX with streak crop.
- Cross-message "similar but not identical" clustering (fuzzy streak beyond exact key match).
Tasks
- T1: Streak signature module — acceptance: pure functions compute
reasonHash/toolSig/keyfrom parts; unit tests cover empty reasoning, empty tools, order stability, normalize. (covers: S2) - T2: Streak detector — acceptance: given a sequence of assistant keys, reports trigger, span
[from,to], anchor predecessor; respects trigger_count and ceiling trailing-crop. (covers: S2; depends: T1) - T3: Request cropper — acceptance: filters a message list by span without reorder/rewrite; creates no new user; invariants hold on fixture with parallel tools. (covers: S2; depends: T2)
- T4: prompt.ts integration — acceptance: after a finished step, experimental flag on, a 3× identical-thinking streak crops the next request and logs audit fields; flag off path unchanged. (covers: S2; depends: T3)
- T5: Regression tests — acceptance: MR-3931-shaped fixture (identical thinking, drifting tools) is cropped; pure tool-loop and pure text-loop still behave as before when keys differ; no crop when span would include the anchor. (covers: S2; depends: T4)
- T6: Align with turn recovery — acceptance: crop creates no new user; span persists as ignored synthetic part on the existing lastUser; lastUser/parentID unchanged; tests cover ignored-part extract + no-new-user. (covers: S2)
Compose Spec Mcp Readiness Hint (`docs/compose/spec/mcp-readiness-hint.md`)
feature: mcp-readiness-hint status: delivered updated: 2026-10-20 branch: feat/mcp-readiness-hint commits: 113e3192..5d6de390
MCP Readiness Hint
Report
What was built — Deferred MCP discovery reports configured servers, current connection status, and request-visible tool names/descriptions. Reminders compare against the latest reported server state, so unchanged failures stay quiet while recovery, disable/removal, and changed tool sets are reported. All reminders share one budget; diagnostics use existing preview and escaping helpers. Prompts contain concise status, tools, and search/use pointers.
No auxiliary mode state is written into user history. Existing real reminders and search/use calls provide deferral evidence; silent auto-mode sessions may re-evaluate when tools arrive. This accepts a cache change instead of altering original user messages for bookkeeping. MCP connection, permission, dispatch, registration, and storage implementations remain unchanged. One actor guard test now explicitly provides the Inbox and verifies actual delivery, removing a dependency on prior test initialization.
Verification — Run from packages/cli unless stated otherwise, for final code head 5d6de390:
- PASS —
bun test test/session/prompt-effect.test.ts --timeout 30000: 135 passed, 3 skipped, 0 failed. Includes the full resume and MCP suites. The final assertion-only cleanup was rechecked with-t 'MCP auto mode can': 1 passed. - PASS —
bun test test/server/session-recovery.test.ts test/session/title-first-turn.test.ts test/session/tool-gate-cancel.test.ts test/server/session-select.test.ts test/tool/actor.test.ts test/tool/actor-send.test.ts test/tool/mcp-tool-search.test.ts --timeout 30000: 200 passed, 0 failed. - PASS —
bun run typecheck: exit 0 on the final code head. - PASS — repository-root oxlint on the two changed MCP source files and the prompt-effect, mcp-tool-search, and actor tests: 0 errors and no new diagnostics. PRE-EXISTING LINT-141: 134 existing MCP-file warnings plus 7 existing actor-test warnings, compared by file, rule, message, and severity.
- PASS — repository root:
git diff --check. - PASS — independent MCP review and final scoped review of
e5f2a933..5d6de390: spec compliance, correctness, and codebase consistency approved; no critical or blocking findings. The final documentation-only commit is excluded from the recorded range. Green remote CI is required before merge.
Journey log
- Readiness answers whether a server is connected; the authorized catalog answers which tools this request can use.
- Comparing with every historical state suppressed recovery. Compare with the latest state while ignoring volatile error details.
- Budget the complete set of reminders together, including status text and retractions, rather than allocating a full budget to every server.
- An auxiliary hidden mode marker caused ten CI failures by modifying original user messages during resume and related flows. It was removed entirely; no replacement storage mechanism was introduced. Auto-mode re-evaluation without actual reminder/call evidence is accepted.
- Another CI failure came from an actor test assuming Inbox was uninitialized. Explicit fixture setup and real delivery assertions fixed the test without changing actor behavior. Keep prompts and mechanisms simple; user configuration repair and cross-server ownership ambiguity stay out of scope.
[S1] Problem
The model cannot tell MCP is not ready from the tool does not exist. Announcements were catalog-delta-driven, so “which MCPs exist / are connecting” was missing or delayed, and empty search reused one ambiguous sentence.
Readiness reminders must also stay accurate without accumulating repeated failures. A conversation-wide set of previously seen states suppresses recovery after connected → failed → connected. Ignoring disabled or removed servers leaves stale availability claims in history. Advertising a server's raw tool list can recommend tools that request permissions hide, while allocating a full context budget to every server multiplies the total reminder size.
[S2] Design
Channels and timing
| Channel | When | Content |
|---|---|---|
| Entry reminder | user-turn boundary, non-disabled configured servers, deferred search exposed | configured server names, possible pending work, notification timing, search/use hint |
| Server outcome reminder | user-turn boundary, changed status or request-visible tool names | connected: names + description previews; unavailable: a short state and relevant action |
mcp_tool_search |
on use | full schemas on hit; readiness + action hint on empty/miss |
mcp_tool_use |
on use | result; unknown/unavailable name points back to search |
MCP.readiness() is optional on the MCP service interface and never awaits connection settled. It reports configured server names, connection status, errors where applicable, and connected tools. resolveTools refreshes the model-facing snapshot on each model step; a search uses that step's snapshot. Connection changes during model generation are picked up at the next resolution, not pushed into an in-flight request.
Reminders are persisted synthetic text parts on the last user message through ensurePersistedUserSynthetic. They are appended only before that user's first assistant step. Already-sent message content is not rewritten by visible reminders. Deferred search/use definitions stay stable while deferral remains selected; an empty, silent auto-mode session can re-evaluate the mode when tools arrive. This preserves the existing prefix-cache contract. These reminders apply where deferred mcp_tool_search is exposed; the GPT/exec and direct-tool paths retain their existing behavior.
Latest-state comparison and bounded repetition
Replay mcpReminder metadata in conversation order into the latest entry and the latest state per server. Compare the current semantic fingerprint with that server's latest reported state, rather than a set of every state ever seen. A fingerprint includes server identity, status, and sorted request-visible tool names. Hash the structured values to avoid delimiter collisions. Error text and tool ordering do not trigger a new reminder.
| Observed transition | Required behavior |
|---|---|
failed → failed, including different error details |
no additional failure reminder |
connected → failed → connected |
report the failure and subsequent recovery |
tools {a,b} → {a} → {a,b} |
retract b, then announce the restored set |
| connected → pending / disabled / removed | report the changed state once and invalidate earlier tool availability |
| initially pending → disabled / removed before any outcome | report the change once; the entry retains the server's presence |
| initially disabled, or no configured servers and no previous announcements | no ambient reminder |
Previously announced servers absent from the current configuration receive a removed reminder. Repeated absence stays quiet. On disconnection, the new reminder briefly states that previously listed tools are unavailable; on a shrinking visible tool set, it names removed tools when the budget allows and otherwise uses a compact superseding statement. Actual MCP reminders and search/use calls provide the existing evidence for sticky deferral, including legacy mcpDeferredTools announcements. If none exists, auto may re-evaluate when tools arrive; a cache change in that case is accepted. Do not persist an auxiliary mode marker or modify an original user message merely to retain a cache decision. Compaction can discard that evidence and cause a fresh announcement, because it already rewrites the prefix.
Connection state and request-visible capabilities
Connection state comes from MCP readiness and is never inferred from an empty search catalog. Before presenting tools to the model, intersect each connected server's tool names with the same catalog used by search and dispatch. That catalog applies request disables, permissions, agent allowlists, actor whitelists, executable checks, and local-name collision handling. List each surviving name once, use the catalog's description, and compute the displayed count from this filtered list. This does not change MCP registration or resolve ambiguous ownership between colliding server namespaces.
Consequently, a server can be connected with no tools available to this request. Do not claim the server itself exposes no tools. The reminder must never recommend a name that the current search/dispatch view excludes. The model receives names and short descriptions for discovery; complete parameter schemas remain on-demand through search.
One reminder budget per boundary
Entry plus all changed-server reminders share min(floor(usable_input_tokens × 0.1), 20,000) estimated tokens. The existing unknown-context fallback remains 20,000; an exhausted known window has zero budget. Deduct each emitted part, including a separator allowance, then divide the remaining budget among remaining changed servers.
Count the entire text, including wrappers, status, instructions, diagnostics, and retractions. Connected tools fall back from descriptions to names, then a truncated list with a search hint, then compact status. Diagnostic previews and large retraction lists are bounded. Search-miss diagnostics reuse the same normalization and harness-tag escaping as reminders. If even a compact reminder cannot fit, emit no part and do not mark that outcome as delivered. Search still covers the complete authorized catalog.
Keep the mechanism and prompts simple
Only report the server's current status, a short diagnostic when useful, and available tool names/descriptions with a search/use pointer. Avoid repeated instructions telling the model what to infer from a failure or how to work around the user's MCP setup. Ordinary ambiguity in invalid or conflicting MCP configuration is outside this amendment; preserve existing registration behavior.
Verification boundaries
Use the real prompt serialization path with controlled MCP service snapshots to inspect what reaches the test model endpoint. Cover state recurrence, changing error details, disable/removal before and after connection, restored tool sets, request/permission/agent filtering, and a shared multi-server budget. Unit tests cover rendering, tiny/zero budgets, long errors and retractions, fingerprints, and escaped readiness/search output. Prompt tests also cover auto-mode re-evaluation without auxiliary message parts, legacy announcement evidence, and duplicate names within a server. Resume, cold-tool initialization, scheduled prompts, and title tests must not acquire auxiliary MCP bookkeeping parts in their original user messages. The actor guard test must exercise actual Inbox delivery independently of prior test initialization. Existing MCP integration tests protect prefix stability, sticky deferral, dispatch, lifecycle, and result handling. These tests establish context correctness; they do not measure a real model's tool-selection success rate.
[S3] Out of Scope
- Changing
FIRST_TURN_WAITor connection lifecycle. - Mid-turn ambient push into history.
- Full JSON Schema validation of
mcp_tool_usearguments. - Consuming
MCP.ToolsChangedas a dirty signal. - Extending readiness reminders to GPT/
execor non-deferred tool exposure. - Empirical evaluation of real-model tool-selection success rates.
- Persisting auxiliary cache-mode state when there is no real MCP reminder or call.
- Repairing user MCP configuration, changing normalized-name registration ownership, or distinguishing all ambiguous cross-server name collisions.
Tasks
- T4: Compare the latest server state — acceptance: repeated failures and error-detail changes add no reminder; failure/recovery and tool-set recurrence each reach the model (covers: S2)
- T5: Report unavailable and removed servers — acceptance: pending, disabled, and removed transitions invalidate prior tool availability once; pending-server removal is covered; initially disabled servers remain quiet (covers: S2)
- T6: Align advertised tools with request authorization — acceptance: request disables, permissions, and agent allowlists hide the same tools from reminders and search, with matching visible counts (covers: S2)
- T7: Enforce a shared reminder budget — acceptance: multi-server serialized reminders remain within one budget; long diagnostics, retractions, and zero budgets are covered without marking omitted outcomes delivered (covers: S2)
- T9: Remove auxiliary mode persistence — acceptance: auto mode may re-evaluate without real announcement/call evidence; original user messages remain unchanged across resume, cold initialization, scheduled prompts, and title handling (covers: S2)
- T10: Keep diagnostics safe and prompts concise — acceptance: search-miss names/errors use existing escaping and preview bounds; reminders contain status, tools, and a brief search/use pointer (covers: S2)
- T11: Make the actor send guard fixture deterministic — acceptance: the test proves a subagent may send via exec using actual inbox behavior, independent of prior app-runtime initialization (covers: S2)
- T8: Verify and independently review the integrated feature — acceptance: focused MCP suites and typecheck pass, lint introduces no diagnostics, and spec compliance/correctness/codebase consistency have no unresolved critical findings (covers: S2; depends: T4, T5, T6, T7, T9, T10, T11)
Compose Spec Mcp Tool Search (`docs/compose/spec/mcp-tool-search.md`)
feature: mcp-tool-search status: completed updated: 2026-10-12 branch: feature/mcp-tool-search commits: c946f4c215aaf326036342a8c3dec6ad12d8772a..HEAD
MCP Tool Lazy Loading
[S1] Problem
Sending every complete MCP function definition to the model consumes context before the task needs any MCP capability. Hiding all metadata, however, leaves the model unable to discover capabilities it does not already know exist. OpenAI defer_loading is provider-specific and cannot generalize to Claude, Gemini, DeepSeek, MiMo, or compatibility gateways.
[S2] Deferral Mode
The design follows Claude Code's deferred tools + Tool Search. MIMOCODE_MCP_TOOL_SEARCH selects the mode:
true/1(default; also any unrecognized value): always defer.auto: defer MCP tools once their estimated definition size (name, description, transformed schema) exceeds 10% of the model context window; unknown windows assume 200,000 tokens.auto:N: same, with an N% threshold.false/0: never defer; every effective MCP definition is exposed directly.
Codex-mode models (GPT family except GPT-OSS, or a codex harness) always defer. The decision is recomputed on every model step from the request-authorized MCP set, so it tracks servers connecting, disconnecting, or being filtered.
[S3] Deferred Catalog And Search
When deferral applies, at least one effective MCP tool exists, and the model supports function calling, the request exposes one ordinary function named mcp_tool_search with a static description, so tool definitions stay byte-stable for the provider prefix cache.
The catalog (built from MCP.tools(), i.e. each server's tool listing) is announced to the model as a synthetic <system-reminder> text part appended to the last user message, never as a new user message, inside a tool result, or in the system prompt. The first announcement lists every deferred tool as - name: description preview; later ones carry only additions and removals relative to what earlier reminders announced. Each reminder part persists its delta in metadata.mcpDeferredTools, and the announced set is replayed from those parts, so compaction (which drops them) triggers a full re-announcement. Announcements are budgeted to 10% of the usable input window (capped at 20,000 tokens), falling back to names only and then to a truncated list; search always covers the complete catalog. System prompts declare that <system-reminder> content is harness-injected environment information.
The catalog contains only tools that survive request disables, permissions, agent allowlists, actor whitelists, executable checks, and local-name collision handling. External text — MCP names, descriptions, schemas, and MCP tool results — has <system-reminder>, <functions>, <function>, and <available-deferred-tools> tags escaped so it cannot forge harness content.
mcp_tool_search accepts query and optional max_results (default 5, max 20):
select:a,bloads the named tools exactly (case-insensitive).- A query equal to a tool name loads that tool.
- Otherwise a cached local BM25 index over names, descriptions, and recursive parameter names/descriptions ranks results;
+termrequirestermin the tool name.
The result returns the matched tools' complete JSON Schema definitions inside a <functions> block, so the model learns parameters from the result. The literal name tool_search is intentionally avoided because OpenAI Responses adapters reserve it for the native provider protocol.
[S4] Conversation-Scoped Loading
A successful search persists validated matched names in ordinary tool-result metadata. On every subsequent outer-loop step, MiMoCode scans completed searches across the conversation (newest first, bounded at 32 tools) and exposes those MCP definitions through the AI SDK activeTools subset. A loaded tool stays callable across user turns as long as it is still request-authorized; compaction drops older searches together with their messages. Model-visible output is never trusted as activation state.
[S5] Execution Safety
All MCP executors remain in the full local tool map so existing permission checks, actor whitelists, plugin hooks, metrics, result normalization, truncation, attachments, cancellation, and MCP client dispatch are preserved. Before any side effect, the MCP wrapper rejects a call to an unauthorized tool, and — while deferral applies — a call to a tool not yet loaded, instructing the model to use mcp_tool_search. Search matches become callable only on the next outer-loop step. Local tools win on callable-name collisions.
[S6] Provider And ToolScript Behavior
The mechanism uses an ordinary function tool and does not depend on OpenAI provider tools or defer_loading. Models without function calling receive neither MCP discovery nor MCP definitions. In Codex mode the outer surface stays exec; exec scripts reach every request-authorized MCP tool through ALL_TOOLS without a search round-trip, and mcp_tool_search is excluded from scripts.
[S7] Testing Boundaries
Unit coverage: mode parsing, auto threshold, names-only catalog and truncation, select: / exact-name / +required queries, BM25 ranking and cache invalidation, <functions> rendering, limits, and Codex mode resolution. Integration coverage: GPT requests expose only exec; by default non-GPT requests hide MCP schemas, load searched tools on the next step, and keep them loaded in the next user turn; false exposes MCP tools directly; unloaded calls fail recoverably; non-tool-call models omit both discovery and definitions; MCP success/error normalization is unchanged.
[S8] Out Of Scope
Semantic embeddings, MCP server summaries, MCP connection lifecycle, MCP naming, and provider-native tool_reference blocks.
Tasks
- T1: Replace provider-native Tool Search with ordinary
mcp_tool_searchand cached local BM25 discovery. - T2: Separate registered executors from model-visible
activeToolsand activate only searched matches. - T3: Preserve MCP execution safety while removing ToolScript catalog leakage and inactive-call bypasses.
- T4: Align with Claude Code: on by default with an opt-in
autothreshold, names-only deferred list,select:/+requiredqueries, schema-bearing results, conversation-scoped loading.
Compose Spec Mcp Tool Use (`docs/compose/spec/mcp-tool-use.md`)
feature: mcp-tool-use status: delivered updated: 2026-10-20 branch: feat/mcp-tool-use commits: 43c9daa20..bfdfd77ee
MCP Tool Use
Report
What was built — In deferred MCP mode, searched tools no longer join activeTools. A fixed nativemcp_tool_use tool ({ name, arguments }) is exposed next to mcp_tool_search and dispatches into the
existing MCP execution pipeline, so provider-facing tool definitions stay byte-stable across searches and
the prompt-cache prefix survives. Permission, plugin hooks and metrics (including the processor error
metric) use the real MCP tool name; the tool part and gate key are mcp_tool_use.
Arguments are pre-checked (object + own top-level required properties) after tool.execute.before;
failures return a recoverable error with the tool's <functions> schema. Unknown names point atmcp_tool_search. Direct calls to deferred tools are rejected after the actor-whitelist check, with amcp_tool_use hint (or an exec hint on the GPT toolset). The TUI renders dispatched calls as the
underlying tool. Harness prompts (anthropic, gemini, beast) and search/reminder wording were updated.
Verification
bun run typecheck(packages/cli): PASS.bun teston mcp-tool-search, prompt-effect, llm, tool-fail-cascade, tool-gate-cancel, tool-script: 266 pass / 3 skip / 0 fail after review fixes; deferred subset re-run after the last fix: PASS.- Full
bun test(packages/cli): 6092 pass / 66 fail under full-suite load; failing files (project, vcs, project-id, memory/paths, tool-gate-cancel, tool-fail-cascade, actor) pass in isolation on branch and main — PRE-EXISTING load flakes. - Independent review: no critical findings; important/minor items fixed and re-reviewed.
Journey log
- Codex/GPT models defer MCP tools but reach them only via
exec; older tests that searched then called a deferred tool directly with a GPT model exercised an unreachable path and were moved to the non-GPT model. - The processor's
executeToolchecks the persisted part's tool name, so dispatched calls must batch undermcp_tool_use, not the MCP key. - No JSON Schema validator is a direct dependency and the lockfile must not change, so argument checking stays structural and full validation is left to the MCP server.
[S1] Problem
With MCP Tool Search (deferred mode), mcp_tool_search returns the schema of matched tools and
the next request adds those tools to activeTools (session/prompt.ts, loadedMcpTools, capped byMCP_TOOL_SEARCH_MAX_LOADED). The provider-facing tool definitions therefore change after every
search. Tool definitions sit at the front of the prompt-cache prefix, so each search invalidates the
cached prefix for the rest of the conversation.
[S2] Design
Keep the provider-facing tool list byte-stable for the whole session in deferred mode by routing every
deferred MCP call through one fixed dispatcher tool.
mcp_tool_usetool. A native tool call (not text/XML) with parameters{ name: string, arguments?: object }. It is exposed exactly whenmcp_tool_searchis exposed
(deferred mode, non-GPT toolset, model supports tool calls) and placed inactiveToolsnext to it.
It is built inSessionPrompt(not the registry) so it can reuse the request-scoped MCP executor.- No more loading. In deferred mode, searched tools are no longer added to
activeTools;loadedMcpToolsandMCP_TOOL_SEARCH_MAX_LOADEDare removed.mcp_tool_searchkeeps returning the<functions>schema block (appended to history, so it never touches the cached prefix) and keeps itsmatchedToolsmetadata. - Dispatch.
mcp_tool_useresolvesnameagainst the request's searchable MCP catalog and runs the
same MCP execution pipeline as a direct call (availability, actor whitelist,tool.execute.before/after
plugin hooks with the real MCP tool name, permission ask keyed by the real tool name, truncation,
metrics with the real tool name, progress metadata). The processor batch/gate key ismcp_tool_use,
matching the persisted tool part. Its result is the underlying tool's result. - No search prerequisite. Any request-authorized deferred MCP tool can be called without a prior
search (search is a context optimization, not an authorization boundary, as withexec). - Errors (recoverable, loop continues).
- Unknown or unauthorized
name→ error naming the tool and telling the model to usemcp_tool_search. argumentsnot an object, or missing a top-levelrequiredproperty of the tool's input schema →
error that includes the tool's full schema in a<functions>block so the model can retry.
(Full JSON Schema validation is left to the MCP server; no new validator dependency.)
- Unknown or unauthorized
- Direct calls in deferred mode. A direct call to a deferred MCP tool name is rejected with an error
telling the model to call it throughmcp_tool_use. - Guidance text. The
mcp_tool_searchdescription, the deferred-tools<system-reminder>, and the
search result tell the model to call tools viamcp_tool_usewith{ name, arguments }. - Provider filter.
llm.tsresolveToolsexemptsmcp_tool_usefrom permission-disabled filtering
when it is active, as it does formcp_tool_search(real-tool permission is enforced at dispatch). - TUI. A
mcp_tool_usepart renders as the underlying tool: name frominput.name, arguments frominput.arguments. - Non-deferred mode (
MIMOCODE_MCP_TOOL_SEARCH=false, orautobelow threshold) and the GPT/execpath
are unchanged. - Stability across MCP churn (amendment).
- An empty catalog counts as deferred: unless mode is
never,mcp_tool_search/mcp_tool_useare
exposed even before any MCP server connects or is configured, so the first server to arrive never
changes the tool list. - Deferral is sticky per conversation: once history shows a deferred-tools announcement or a search/use
call,autostays deferred even if the catalog drops under its threshold or empties. Inauto, an
empty first catalog persists a "no deferred MCP tools yet" announcement as that evidence. - Deferred-tools announcements are appended only while the last user message is the tail of the
conversation; changes that happen mid-turn are announced on the next turn, so earlier messages are
never rewritten.mcp_tool_searchalways searches the live catalog meanwhile.
- An empty catalog counts as deferred: unless mode is
[S3] Out of Scope
- XML/text-embedded tool calls.
- Full JSON Schema validation of
arguments. - Changing when deferral is decided (
shouldDeferMcpTools) or the reminder delta mechanism. - Web/desktop app renderers.
Tasks
- T1: Add
MCP_TOOL_USE_ID, its parameters/description, and an argument pre-check helper totool/mcp-tool-search.ts; update search description/reminder/result wording; removeMCP_TOOL_SEARCH_MAX_LOADED— acceptance: unit tests for the pre-check (non-object, missing required, valid) and wording pass (covers: S2) - T2: Build the
mcp_tool_usedispatcher insession/prompt.ts, drop search-based loading, reject direct deferred calls, exempt it inllm.ts— acceptance: session tests show the tool list is identical before and after a search, amcp_tool_usecall completes with the MCP result, unknown name / missing required args produce recoverable errors, direct deferred calls are rejected (covers: S2; depends: T1) - T3: Render
mcp_tool_useparts in the TUI as the underlying tool — acceptance: typecheck passes and the part shows the MCP tool name and its arguments (covers: S2; depends: T1) - T4: Update harness prompt text mentioning deferred MCP tools — acceptance: prompts reference
mcp_tool_usewhere loading via search was implied (covers: S2)
Compose Spec Memory Prompt Decouple (`docs/compose/spec/memory-prompt-decouple.md`)
feature: memory-prompt-decouple status: delivered updated: 2026-09-14 branch: feat/memory-prompt-decouple commits: e485a2a..9780b2e
Memory Prompt Decouple From Checkpoint
Report
What was built — # Memory system is now always injected for main/peer
actors. MIMOCODE_DISABLE_CHECKPOINT only filters checkpoint-write subsections
(checkpoint.md / task progress paths, writer-as-curator, Active recall,
Four-vs-Two wording). Project MEMORY.md, global MEMORY.md, notes.md scratchpad,
subagent return format, and search-first rules stay on so embedders that close
checkpoint (e.g. Desktop) still teach the model where memory lives.
Verification —
bun run typecheckfrompackages/cli— PASSbun test test/session/llm-system-prompt.test.ts test/tool/checkpoint-tool-description.test.ts test/flag/disable-checkpoint-flag.test.ts test/session/prune.test.ts— PASS (30 pass / 0 fail; llm-system-prompt 6/6)
Journey log
- Desktop sets
MIMOCODE_DISABLE_CHECKPOINT=1for checkpoint bugs; engine previously dropped the whole memory system prompt with that flag — wrong coupling. - Existing test
MIMOCODE_DISABLE_CHECKPOINT=true — memory instructions are not appendedencoded the old coupling; inverted to core-on + ckpt-extras-off. - Flag-on test must not assert absence of the string
checkpoint-writer— baseagent.promptmentions that agent name; assert memory-system-specific copy instead. memory.disable_writeremains a separate write kill-switch; this change does not couple prompts to it.- After engine-pin bump Desktop gets memory paths while keeping checkpoint off; do not also copy
# Memory systemintodesktop-base.
[S1] Problem
# Memory system is injected by buildSystemArray only whenservesCheckpoint && !Flag.MIMOCODE_DISABLE_CHECKPOINT
(packages/cli/src/session/llm.ts). Desktop embeds the engine withMIMOCODE_DISABLE_CHECKPOINT=1 because checkpoint had production bugs, so the
entire memory write/read contract disappears from the model: no MEMORY.md /
notes.md / global paths, no when-to-write rules, no search-first reflex.
Memory itself is a file system + FTS + memory tool. Checkpoint is one
writer of those files. Coupling the prompt to the checkpoint flag is wrong:
closing checkpoint for writer/rebuild bugs must not erase memory ownership
paths.
[S2] Design
Contract: for main/peer actors (servesCheckpoint), # Memory system is
always injected. MIMOCODE_DISABLE_CHECKPOINT only toggles the checkpoint
write subsections inside that block, not the block itself.
| Content | Always | Gated by MIMOCODE_DISABLE_CHECKPOINT |
|---|---|---|
# Memory system heading + file-type list |
yes | |
Project MEMORY.md path + when agent may Edit |
yes | |
Global MEMORY.md path |
yes | |
Session notes.md scratchpad path + format |
yes | |
| Subagent return format | yes | |
| What NOT to do: no ad-hoc memory files; search before asking user | yes | |
Session checkpoint.md / tasks//progress.md paths |
yes | |
| "writer is sole curator; agent does not maintain mid-task" | yes | |
| MEMORY.md edit is exception vs norm wording | yes | |
| "Don't Edit checkpoint.md" | yes | |
| Active recall protocol (rebuild dumps in context) | yes | |
| File-type count wording "Four" vs "Two" | derived from flag |
Implementation choke points:
session/llm.tsbuildSystemArray— injectionifisservesCheckpoint
only;buildMemoryInstructions(projectID, memoryRoot)internalcheckpointEnabledsplits ckpt extras.buildMemoryInstructions/ injection-site comments state always-on for
main/peer; flag only narrows content.tool/checkpoint-description.ts— unchanged. Basememory.txtstays;
onlymemory.checkpoint.txtextra stays gated.- Session recall reminder (
session/prompt.tshasMemoryOrTasks) — already
independent of the checkpoint flag; no change. memory.disable_writeremains the write switch (separate axis); this
feature does not couple prompts to it.
Tests (engine): llm-system-prompt.test.ts flag-on case asserts core
memory present (# Memory system, MEMORY.md paths, Notes scratchpad, Subagent
return format, When to Edit, search-first) and ckpt extras absent (Active
recall, sole curator, Session checkpoint, session checkpoint.md path). Default
on path still expects Active recall + writer ownership. system-spawned actors
still receive no memory block.
[S3] Out of Scope
- Re-enabling desktop checkpoint / fixing historical checkpoint bugs.
- Changing
memory.disable_writesemantics or memory-path-guard. - Injecting memory system into system-spawned writers (they must keep using
checkpoint task paths). - Desktop prompt assets (
desktop-base) — engine supplies the block after pin
bump; desktop should not duplicate it.
Native memory paths
Memory reads and FTS reconciliation support Windows drive, UNC and mixed-separator
paths as well as POSIX paths. memory/paths.ts normalizes separators only in the
layout-matching representation shared by parsePath and parseCcPath; native
filesystem paths remain the FTS identity and search result path. Nested keys use/ for type detection. POSIX filenames containing backslashes keep their literal
characters. This leaves checkpoint gating and memory write permissions unchanged.
Desktop regression coverage is tracked by engine-runtime TP-R6-06: path parsing,
native buildPath round-trips, filesystem reconciliation/search, fingerprint hits,
updates/deletes, and the embedded Node memory tool.
Tasks
- T1:
llm.tsalways inject# Memory systemwhenservesCheckpoint; flag only filters ckpt extras — acceptance: withMIMOCODE_DISABLE_CHECKPOINT=true, main-agent system contains# Memory system+ project MEMORY.md + notes.md and omits checkpoint-writer ownership / Active recall (covers: S2) - T2: Update
buildMemoryInstructionscomments to match always-on contract — acceptance: comments no longer claim the whole block is skipped when checkpoint is off (covers: S2; depends: T1) - T3: Invert/extend
llm-system-prompt.test.tsfor flag-on vs flag-off content split — acceptance: tests cover always-on core + ckpt-gated extras + system-spawned exclusion; suite green (covers: S2; depends: T1) - T4:
bun typecheck+bun test packages/cli/test/session/llm-system-prompt.test.tsfrom worktree — acceptance: typecheck clean; target tests pass (covers: S2; depends: T3)
Compose Spec Move Drive Mimo To Repository Skill (`docs/compose/spec/move-drive-mimo-to-repository-skill.md`)
feature: move-drive-mimo-to-repository-skill status: delivered updated: 2026-08-23 branch: codex/move-drive-mimo commits: b7ff175e..dca3d16f
Move Drive MiMo to a Repository Skill
Report
What was built — Moved the complete drive-mimo skill from the built-in bundle to the repository-level .mimocode/skills/drive-mimo/ path. Removed its built-in README entries and bundled-only TUI localization metadata while preserving the skill behavior and helper script. Updated the helper invocation note to match the repository-managed executable bit.
Verification — bun ci PASS; bun test test/skill/skill.test.ts test/skill/bundle-discovery.test.ts test/skill/builtin.test.ts test/skill/skill-description.test.ts PASS (34 tests); bun typecheck PASS; scope and git diff --check checks PASS. Independent review PASS for spec compliance, correctness, and codebase consistency.
Journey log — The initial review found a translated README table entry missed by the first cleanup; it was removed and covered by re-review. A repository discovery regression test was added to assert bundled remains unset for project skills.
[S1] Problem
drive-mimo is packaged and presented as a MiMoCode built-in skill even though it is repository-specific tooling for exercising MiMoCode. This makes it available outside the repository and gives it bundled-only TUI metadata.
[S2] Design
Move the complete drive-mimo skill directory to the repository root at .mimocode/skills/drive-mimo/, where the project skill discovery path loads it as a non-bundled repository skill. Remove the built-in bundle copy and bundled-only README and localization entries. Preserve the skill name, frontmatter, behavior, helper script, and normal slash invocation behavior; update documentation where the repository-managed file mode differs from the extracted bundle.
[S3] Out of Scope
Do not change the skill behavior, helper behavior, skill discovery implementation, or other built-in skills. Documentation corrections required by the new repository-managed layout are in scope.
Tasks
- T1: Relocate the skill files to
.mimocode/skills/drive-mimo/— acceptance: the repository contains the complete skill and no built-in bundle copy (covers: S2) - T2: Remove bundled-only documentation and localization references — acceptance: README and TUI locale catalogs no longer identify
drive-mimoas bundled (covers: S2) - T3: Verify repository discovery and absence from the built-in bundle — acceptance: focused tests/checks show the skill is discovered from the project path with
bundledunset and absent from the extracted built-in bundle (covers: S2)
Compose Spec Npm Fds Launcher (`docs/compose/spec/npm-fds-launcher.md`)
feature: npm-fds-launcher status: delivered updated: 2026-10-13 branch: feat/npm-fds-launcher commits: 8e1df4ab..8884c6fd
npm FDS Launcher
Report
What was built — @mimo-ai/cli is now a thin launcher instead of a hub of 12 optionalDependencies platform packages. mimo.js detects one target (os/arch/AVX2/musl), uses MIMOCODE_BIN_PATH or package-local vendor/mimo[.exe], otherwise downloads exactly one version-pinned FDS archive (releases/vX.Y.Z/<target>.{tar.gz|zip}) into vendor/, and forwards argv with inherited stdio. postinstall prefetches best-effort and never fails install. FDS failure exits 1 with the real error and a recommended curl/irm native install. npm uninstall deletes the binary with the package directory. Wrapper packaging drops optionalDependencies. READMEs mark npm as limited support: README.md / README.zh.md only recommend curl/irm; README_npm.md recommends the native installer over npm install @mimo-ai/cli and still names the package for users who need it.
Upgrade is package replace, not tree migration: no leftover-node_modules fallback. If the first run cannot reach FDS, it says so and points at curl/irm.
Verification — bun run typecheck — PASS. bun test ./test/installation/launcher.test.ts ./test/installation/launcher-bin.test.ts ./test/installation/postinstall.test.ts in packages/cli — PASS, 29 tests (12-target matrix, URL pin + MIMO_FDS_BASE, vendor hit offline, FDS fetch via stub, sha256 match/mismatch/skip-when-absent, argv/exit codes, postinstall best-effort, FDS-down error + curl/irm hint). Local E2E: packed @mimo-ai/[email protected] tgz (7.3 kB) → npm install on macOS arm64 → postinstall pulled releases/v0.1.15/mimocode-darwin-arm64.zip from production FDS into vendor/mimo → mimo.js --version printed 0.1.15; second run reused vendor. Production FDS probe: missing objects and unpublished *.sha256 return 404. Independent review: go after spec/checksum/comment fixes (a90a1057) and typecheck fix (8884c6fd).
Journey log
- Multi-download is not postinstall picking wrong — npm already fetched every
os/cpumatch; baseline/musl siblings share those fields. - Do not put the launcher binary under
Global.Path.cache(wiped onCACHE_VERSION). - Upgrade = uninstall old package + install new; do not paper over orphans with legacy
node_moduleslookup. Bun.spawnSyncdeadlocks tests that alsoBun.servein-process — use async spawn around local stubs.
[S1] Problem
npm install @mimo-ai/cli pulled several platform packages (~130 MB each) on Linux x64 because optionalDependencies matching cannot distinguish baseline vs AVX2 or musl vs glibc (os/cpu identical; libc was unset). Users saw multiple huge downloads while only one binary was used.
[S2] Delivered design
Package
- Name
@mimo-ai/cli,bin: { "mimo": "./mimo.js" }, version = release version,"type": "module",engines.node >=20.10.0. - No
optionalDependencies. No npm platform packages — binaries ship as FDS/GitHub archives only. - Shipped files:
mimo.js,launcher.js,postinstall.mjs.
Runtime resolution (`mimo.js`)
MIMOCODE_BIN_PATH→ spawn that./vendor/mimoorvendor/mimo.exe→ spawn that.- Else download from FDS into
vendor/, then spawn. - On failure: exit 1 with the error and recommended
curl -fsSL https://mimo.xiaomi.com/install | bash(Windows:irm https://mimo.xiaomi.com/install.ps1 | iex).
Detection (one target, all 12 published names):
darwin|linux|windows×x64|arm64- linux musl via
/etc/alpine-releaseorldd --version - x64 baseline when AVX2 is absent (
/proc/cpuinfo,sysctl hw.optional.avx2_0,IsProcessorFeaturePresent(40)) - name
mimocode-${os}-${arch}[-baseline][-musl]
Fetch
- URL:
MIMO_FDS_BASE(defaulthttps://mimocode.cnbj1.mi-fds.com/mimocode/mimocode) +/releases/v${packageVersion}/${target}.tar.gz|.zip— pinned to the package version, neverreleases/latest. - Stream to temp, optional
.sha256check when published (404 skips), extract the singlemimo[.exe], chmod 755,*.partial+ rename. postinstall.mjscalls the same helper; failures only warn. First run still downloads when needed.
Upgrade
npm/pnpm/bun i -g @mimo-ai/[email protected] (or built-in upgrade) replaces the package; the new launcher fetches vX.Y.Z. Curl channel and installation/index.ts methods are unchanged. Publish order (FDS before npm cli) already guarantees the archive exists.
Uninstall
Binary lives inside the package directory → npm uninstall / pnpm remove / bun remove / rm -rf node_modules all remove it. No uninstall lifecycle hook.
On Windows a running PE is locked: mimo.js copies vendor/mimo.exe to %TEMP%/mimocode-bin/<version>/ once and executes that copy, so vendor/ itself stays deletable while mimo runs (upgrade/uninstall from a live TUI). The temp copy is a run artifact, not the install.
[S3] Compatibility
| Surface | Behavior |
|---|---|
mimo on PATH |
bin.mimo unchanged |
| argv / stdio / exit code | forwarded |
MIMOCODE_BIN_PATH |
wins, no network |
curl install / install.ps1 |
untouched |
| built-in upgrade | npm/pnpm/bun/curl methods work |
Direct @mimo-ai/mimocode-* |
not published to npm anymore; FDS/GitHub archives only |
MIMO_FDS_BASE |
same override as the curl installer |
[S4] Rejected (not in this change)
- Shared XDG/vendor cache and
MIMOCODE_VENDOR_DIR— orphans on uninstall. - Leftover
node_modules/@mimo-ai/mimocode-*fallback — upgrade is package replace. libc-only metadata fix or shipping a single fat npm package.releases/latestpin, curl|bash from the launcher, embedding the binary in the npm tarball.
[S5] Risks (accepted / mitigated)
| Risk | Mitigation in tree |
|---|---|
| First-run needs FDS | postinstall prefetch; honest error + curl/irm hint |
| FDS trust | version pin; optional sha256 when *.sha256 is published |
| Wrong cache path | vendor/ inside the package only |
--ignore-scripts |
first run downloads |
| Windows AV on extracted PE | extract as mimo.exe |
Follow-up (not blocking this change): release-stage upload of *.sha256 (T3).
[S6] Out of Scope
- curl installer / GitHub Release layout, brew/scoop/choco, reducing the 12-target matrix, auto-updating the vendor binary without a package-manager bump.
Tasks
- T1: Land
libcon platform packages — abandoned (launcher supersedes; wrapper has no optionalDependencies) - T2: Version-pinned FDS launcher in
@mimo-ai/cli(mimo.js+launcher.js) —binismimo; package-localvendor/;MIMOCODE_BIN_PATHfirst; FDS failure exits 1 with curl/irm hint (covers: S2) - T3: Publish per-archive SHA-256 from the FDS release stage — launcher already verifies when
.sha256is published (follow-up) - T4:
script/release.tspacks only the launcher (mimo.js/launcher.js/postinstall.mjs); npm no longer publishes platform packages;trustregisters sdk/plugin/cli only (covers: S2) - T5: Tests for argv/exit codes,
MIMOCODE_BIN_PATH, vendor hit, FDS fetch, sha256 match/mismatch/skip-when-absent, postinstall best-effort, FDS-down hint (covers: S2, S5) - T6: README.md / README.zh.md drop npm install; README_npm.md recommends curl/irm over
npm install @mimo-ai/cliand still names the package (covers: S2, S3)
Compose Spec Opencode Go Session Header (`docs/compose/spec/opencode-go-session-header.md`)
feature: opencode-go-session-header status: delivered updated: 2026-10-12 branch: fix/opencode-session-header commits: c7dbd85d..bc0cfd9a
OpenCode Go Session Header
Report
What was built — OpenCode Go inference through session/llm.ts stream() now sends x-opencode-session: <sessionID> (same stable per-conversation id as x-session-affinity), including ephemeral title/summary. Non-Go providers are untouched. Target detection is providerID starting with opencode-go or Provider.effectiveBaseURL containing opencode.ai/zen/go; effectiveBaseURL is the single source of request-URL precedence (provider.options.baseURL ?? model.api.url) and resolveSDK reuses it.
Verification — bun test session/llm* (llm, llm-retry, llm-system-prompt, llm-request-prefix) → 50 pass / 2 skip / 0 fail. bun run typecheck → PASS. Removing the header or dropping providerOptions from detection fails the new tests. Review 1 found CRITICAL (provider-level options.baseURL missed) → fixed by effectiveBaseURL + getProvider().options. Review 2: all ACs met, no CRITICAL.
Journey log —
- First draft sent the header on every provider; user required Go-only so non-Go stays byte-identical.
- Field-list detection (
model.api.url/model.options/provider.options) was brittle — replaced byeffectiveBaseURLmatchingresolveSDKprecedence. - Ephemeral title still has a real
sessionID; OpenCode Go 400s title/summary without the header, so the header is not gated on!ephemeral(unlike affinity). - Non-
stream()inference paths (predict,goal,agent.generate,llm-server,mcp/sampling) are intentionally unwired — operator configuration if pointed at Go. - Integration tests key off
providerID="opencode-go"; they do not proveitem.optionsis threaded (unit matrix covers the helper contract).
[S1] Problem
OpenCode Go (the subscription behind https://opencode.ai/zen/go/v1) has hard-rejected every inference request that lacks a stable per-conversation x-opencode-session header since 2026-09-05 (HTTP 400 MissingSessionID). MiMoCode is a fork of OpenCode and is listed on https://opencode.ai/docs/go/ as a known problematic client because of XiaomiMiMo/MiMo-Code#2317.
User-visible impact:
- Every
opencode-go/*model call through CLI and MiMo Desktop fails withRequest is missing x-opencode-session and cannot be routed efficiently. - Title generation / summary / compaction also fail, so the failure is not limited to the main chat turn.
- Users have been working around this with
chat.headershooks / plugins that re-sendinput.sessionIDunder the second header name.
Code today (packages/cli/src/session/llm.ts) already computes the correct value and sends it as x-session-affinity on non-ephemeral calls. The same value is never sent as x-opencode-session. User-Agent is already product-specific (mimocode/<version>), which matches the Go docs requirement.
Prior PRs (#2327 closed, #2494 open-but-conflicting) target the retired packages/opencode/ tree and only cover non-ephemeral calls. Community reports (Letta's fix, title-gen failures) show ephemeral/auxiliary requests must also carry the header.
[S2] Design
Header contract (OpenCode Go only)
Only requests that target OpenCode Go gain the extra header. Every other provider's outbound headers stay byte-identical to today.
isOpenCodeGoTarget(model, providerOptions?) is true when either:
model.providerIDstarts withopencode-go(coversopencode-go,opencode-go-chat), oreffectiveBaseURL(model, providerOptions)containsopencode.ai/zen/go.
effectiveBaseURL lives in provider/provider.ts and is the same precedence resolveSDK uses to pick the request URL: non-empty provider.options.baseURL wins, else model.api.url. Do not list more config fields in the Go helper — if URL resolution changes, fix effectiveBaseURL. (options.endpoint is already folded into options.baseURL at config load.)
On matching requests from session/llm.ts stream() that have a sessionID:
- Send
x-opencode-session: <sessionID>— includingephemeral: truecalls (title / summary / compaction). - Keep
x-session-affinityandx-parent-session-idgated on!input.ephemeralexactly as today (all providers). - Do not invent a new identifier. Reuse the existing stable per-conversation
sessionID. - Do not send the header when
sessionIDis empty/absent. User-Agent: mimocode/<version>stays unchanged.
Scope of the header
Not sent on non-Go providers. OpenCode Zen (opencode without a zen/go URL) is out of scope.
Call sites: session/llm.ts stream() only (covers main chat + ephemeral title via prompt.ts). Other AI SDK surfaces (predict, goal.evaluate, agent.generate, llm-server, mcp/sampling) are intentionally not wired — if those are pointed at Go without a session header, that is operator configuration, not this fix.
Transport coverage
The header is set in the single stream() headers object for matching models, so it applies to every AI SDK path that stream() uses (OpenAI-compatible /chat/completions, Anthropic /messages, Responses).
Explicit non-goals
- Restoring
x-opencode-client/x-opencode-project(optional in the Go docs). - Provider-ID filtering of "opencode" (#114) or Responses-only model errors (#2030).
- Changing how
sessionIDis generated. - Adding the header to non-Go providers.
- Covering every non-
stream()inference call site in the repo.
[S3] Out of Scope
- Docs rebranding of leftover OpenCode names.
- Windows hooks loader path bug (
pathToFileURL). - OpenCode Go model catalog / pricing / auth UI.
Tasks
- T1: Send
x-opencode-sessionon OpenCode Go requests only (including ephemeral) — acceptance: unit test forisOpenCodeGoTarget; integration shows Go non-ephemeral carriesx-opencode-session== sessionID next tox-session-affinity; Go ephemeral carriesx-opencode-sessionand omitsx-session-affinity; non-Go carries neither change (x-opencode-sessionnull, affinity unchanged). (covers: S2) - T2: Regression test + typecheck/lint — acceptance: new tests fail if the header is removed or scoped wrong;
bun run typecheckand targetedbun testforsession/llm*pass. (covers: S2; depends: T1)
Compose Spec Permission Skip And Timeout Decouple (`docs/compose/spec/permission-skip-and-timeout-decouple.md`)
feature: permission-skip-and-timeout-decouple status: delivered updated: 2026-04-02 branch: permission-skip-and-timeout-decouple commits: 9d54ad3c..HEAD
Permission Skip & Timeout Decouple
Report
What was built — Split the monolithic skipAll boolean into two orthogonal runtime flags: skipAll (auto-allow normal permission asks) and permissionAskTimeoutMs (timeout for all human-confirmation asks). Added /permission-timeout TUI slash command with a modal selector (Never/30s/1min/2min/5min/10min), server API endpoints, and full i18n coverage across 7 languages.
Verification — All 147 permission tests pass (bun test test/permission/ from packages/cli). 5 new tests cover: env var compat, independent timeout (skip-all off), timeout on normal asks, null timeout (no timeout), skip-all+timeout combination.
Journey log — SDK regeneration (./packages/sdk/js/script/build.ts) is required when adding new server API endpoints; stale toast messages referencing old coupled behavior were found during review and fixed.
[S1] Problem
The skipAll runtime toggle bundles two orthogonal concerns: (1) auto-allowing all normal permission asks, and (2) timing out forced-ask permissions (e.g. bash_delete). The timeout logic is hardcoded inside the skipAll branch of ask() (permission/index.ts:489-510), with duration controlled by the MIMOCODE_SKIP_ALL_FORCED_ASK_TIMEOUT_MS environment variable (default 60s).
This means:
- Users cannot enable ask-timeout without also skipping all normal permissions.
- Timeout duration is only configurable via environment variable — no TUI entry point.
- Two orthogonal concepts are coupled in a single boolean, increasing cognitive and maintenance cost.
[S2] Design
2.1 Two independent toggles
Split into two instance-scoped runtime state fields:
| Toggle | Type | Semantics |
|---|---|---|
skipAll |
boolean |
Auto-allow all normal permission asks. Explicit deny rules still win. Forced-ask permissions still require human confirmation. Behavior unchanged. |
permissionAskTimeoutMs |
number | null |
Timeout in milliseconds for permission asks requiring human confirmation. null = no timeout (wait indefinitely). Positive integer = auto-reject after that duration. |
The two are orthogonal:
skipAll=true, timeout=null— skip normal asks, forced-ask waits indefinitely (skip-all with timeout disabled).skipAll=false, timeout=60000— normal asks require confirmation, all asks auto-reject after 60s (new capability).skipAll=true, timeout=60000— original behavior (both enabled).skipAll=false, timeout=null— most conservative, all asks wait indefinitely (default).
2.2 Timeout scope
permissionAskTimeoutMs applies to all permission asks that reach the human-confirmation path (both normal and forced-ask), regardless of skipAll state. Timeout behavior:
- Wait
permissionAskTimeoutMsmilliseconds. - On expiry, auto-reply
reject(publishEvent.Replied), fail the Deferred withCorrectedErrorcarrying actionable feedback. - Clean up pending state.
2.3 Environment variable backward compat
MIMOCODE_SKIP_ALL_FORCED_ASK_TIMEOUT_MS maps to the initial permissionAskTimeoutMs value when set to a positive integer. When unset or 0, permissionAskTimeoutMs initializes to null.
2.4 Service interface change
// New
readonly permissionAskTimeout: () => Effect.Effect<number | null>
readonly setPermissionAskTimeout: (ms: number | null) => Effect.Effect<void>skipAll / setSkipAll unchanged.
2.5 ask() logic change
Replace the skipAll+forced timeout branch at permission/index.ts:489-510 with generic timeout logic:
// When permissionAskTimeoutMs != null, wrap the entire await
// with Effect.timeoutOrElse.
// On timeout: publish reject + fail with CorrectedError.The timeout no longer depends on s.skipAll; it depends solely on s.permissionAskTimeoutMs. Forced-ask and normal asks share the same timeout path.
2.6 TUI interaction
New /permission-timeout slash command opens a modal selector:
| Option | Value |
|---|---|
| Never | null |
| 30 seconds | 30000 |
| 1 minute | 60000 |
| 2 minutes | 120000 |
| 5 minutes | 300000 |
| 10 minutes | 600000 |
Selection calls setPermissionAskTimeout and shows a toast. The existing /skip-permissions command is unchanged (controls only skipAll).
2.7 Server API change
New HTTP endpoints:
GET /instance/permission/ask-timeout— returns currentpermissionAskTimeoutMsPOST /instance/permission/ask-timeout— body:{ "ms": number | null }, setspermissionAskTimeoutMs
Existing setSkipAll / skipAll endpoints unchanged.
2.8 i18n
New keys across all 7 languages (en/zh/zht/ru/ja/fr/es):
tui.command.permission_timeout.titletui.permission_timeout.title/hint/option.*/toast_*
[S3] Out of Scope
autoApproveDeletemechanism (already an independent toggle).--dangerously-skip-permissionsCLI flag behavior (injects allow-all via config ruleset, bypasses skipAll runtime toggle).FORCED_ASKpermission set definition.- Parent-grant inheritance.
- Persisting
permissionAskTimeoutMsto the database (runtime-only, same as skipAll).
Tasks
- T1: Service layer split — add
permissionAskTimeout/setPermissionAskTimeoutto Interface and State; generalizeask()timeout fromskipAll && forcedtopermissionAskTimeoutMs != null. acceptance: unit tests cover 4 combinations (skipAll on/off × timeout on/off) (covers: S2.1, S2.2, S2.4, S2.5) - T2: Env var compat —
MIMOCODE_SKIP_ALL_FORCED_ASK_TIMEOUT_MSmaps topermissionAskTimeoutMsinitial value. acceptance: new Instance'spermissionAskTimeout()returns the env-derived value (covers: S2.3) - T3: Server API — add GET/POST
/instance/permission/ask-timeoutendpoints. acceptance: HTTP calls can read and write the timeout setting (covers: S2.7) - T4: TUI command — add
/permission-timeoutslash command with modal selector (Never/30s/1min/2min/5min/10min). acceptance: executing the command toggles the timeout and shows a toast (covers: S2.6) - T5: i18n — add translations for all 7 languages (en/zh/zht/ru/ja/fr/es). acceptance: labels display correctly when switching language (covers: S2.8)
- T6: Test updates — update
skip-all-timeout.test.tsfor new behavior; add independent timeout tests (timeout works with skip-all off). acceptance: all permission tests pass (covers: S2.1, S2.2)
Compose Spec Plan Enter Removal (`docs/compose/spec/plan-enter-removal.md`)
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 TUIplan_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/cli: 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 basece124cbd too, verified by stashing). git diff --check PASS. Rootbun 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
- First attempt gated
plan_enterbehind 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. - 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. mimocode-docsrouting keys off the frontmatterdescription(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.
- aliases + description,
rg -rnsilently means--replace n, printingnwhere matches
were. Two searches during this change reported false clean states. Userg -n.- No locale key-parity test exists in this package, and
test/cli/tui/i18nis
not a real path — a verification band naming it exercises nothing. Question
i18n is fail-soft anyway (routes/session/question.tsx:24falls 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/cli/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/cli/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, itshardPermission 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 agent cycle | cli/cmd/tui/component/prompt/index.tsx |
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 bothbuild 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 thehardPermissionwrite-block does not engage.prompt/default.txtis
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— dropplan-enterfrom 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 (Tabor 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 remainsplan_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 inmimocode-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.mdfrontmatterdescription— 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 thatplan_exitis the
agent's one move from inside plan.mimocode-docs/reference/commands.md:130— under Keybindings, the concrete
answer:Tabor the agent dialog to enter;Taborplan_exitto 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/cli/src/tool/plan-enter.txtPlanEnterToolinpackages/cli/src/tool/plan.ts(keepgetLastModel
andPlanExitTool)packages/cli/src/tool/registry.ts— theplan_enterimport, itsTool.initentry, andtool.planenterinbuiltinpackages/cli/src/tool/tool-script-ref.ts:29—"plan_enter"exclusionpackages/cli/src/agent/agent.ts— theplan_enterrules at:113
(defaults deny),:141(build allow),:181(plan allow)packages/cli/src/cli/cmd/run.ts:356-360— theplan_enterdeny rulepackages/cli/src/cli/cmd/tui/routes/session/plan-switch.ts:7— theplan_enter → "plan"mapping- the
tui.question.plan_enter.*block (6 keys plus its comment header) from all
seven locale files underpackages/cli/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:24falls back to the
DB-stored question text when atui.question.<key>.*lookup misses.
Modify:
packages/cli/src/session/prompt/default.txt— lines 87 and 134 per S2.packages/cli/src/skill/builtin/.bundle/mimocode-docs/SKILL.mdandreference/commands.md— per S2 Documentation surfaces.
Tests:
test/tool/plan.test.ts— drop theplan_enter"No" case;plan_exitcases
unchanged.test/cli/tui/plan-switch.test.ts— drop theplan_enter → "plan"cases and
keep one inverted assertion: a completedplan_enterpart must now map toundefined. Resumed sessions still hold historicalplan_enterparts in the
DB, and replaying them must not switch the mode.test/agent/agent.test.ts:160,177— assert onplan_exitonly.test/permission/disabled.test.ts:53-65— these exercisePermission.disabled
semantics using tool names as data; rename the subjects toplan_exit/questionso no test references a deleted tool.test/tool/tool-script.test.ts:557— dropplan_enterfrom the exclusion-set
assertion.- New:
test/tool/plan-enter-absent.test.ts—registry.ids()does not containplan_enter, and does containplan_exit. This is the regression guard
against a re-add.
Verification, from packages/cli: bun test test/tool test/cli/tui test/skill test/agent test/permission, bun typecheck, git diff --check, andbunx prettier --check on the touched files. Two baseline caveats: rootbun run lint reports ~4040 pre-existing warnings (0 errors), andsrc/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'shardPermission, the plan workflow reminder in session/prompt.ts, thebuild/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:
- Permission handled by another mechanism — the only part of plan mode
carrying durable value is thehardPermissionwrite-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.evaluateisfindLastover the flattened rulesets with no
specificity scoring (permission/evaluate.ts:9-15), and--dangerously-skip-permissionsmerges{"*": "allow"}into the lastuser
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 viahardPermissionbeing re-appended after the user merge; a preset needs
an equivalent last-layer story. - 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-nextalready 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
planagent, itshardPermission,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_exitsurvives in
it, and that reference stays valid). - Hardening
bashagainst 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. PerAGENTS.mdthe TUI is the supported surface; syncing that
corpus is its own change.
Tasks
- T1: delete
PlanEnterTool,plan-enter.txt, and its registry / tool-script-ref wiring — acceptance:registry.ids()omitsplan_enterand still containsplan_exit;bun typecheckclean (covers: S2, S3) - T2: remove the three
plan_enterpermission rules inagent/agent.tsand the deny rule incli/cmd/run.ts— acceptance: noplan_enterstring remains insrc/agentorsrc/cli/cmd/run.ts; build and plan agents both expose exactlyplan_exit(covers: S2, S3; depends: T1) - T3: remove the
plan_enterbranch inplan-switch.tsand thetui.question.plan_enter.*block from all seven locales — acceptance:rg "plan_enter" src/cli/cmd/tuireturns nothing; no locale file is left with a dangling comment header or a double blank line;plan_exitswitch mapping still returns"build"(covers: S3; depends: T1) - T4: correct
prompt/default.txt— acceptance: noplan-enterin the tool list; item 5 of "Plan mode in detail" states the user switches modes, forbids unprompted suggestions to switch, and keepsplan_exitas the model's request path; the "Enter plan mode for non-trivial implementation work" paragraph is gone with no behavioural replacement (covers: S2) - T5: make
mimocode-docsanswer "how do I enter/leave plan mode" — acceptance: the frontmatter description carries mode/keybinding vocabulary so the question routes to the skill;SKILL.mdandreference/commands.mdboth state that entering is a user gesture (Tab/ agent dialog), that no tool enters plan, thatplan_exitis the agent's only move, and that the agent will not raise plan mode unasked (covers: S2, S3) - 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 inplan-switch.test.tsproves a replayedplan_enterpart no longer switches modes; the new test fails ifplan_enteris re-registered (covers: S3; depends: T1, T2, T3) - T7: verification band — acceptance: the S3 test bands,
bun typecheck, andgit diff --checkall pass frompackages/cli(covers: S3; depends: T1-T6)
Compose Spec Plan No Continue (`docs/compose/spec/plan-no-continue.md`)
feature: plan-no-continue status: delivered updated: 2026-07-23 branch: plan-no-continue commits: 29a107adfe76cfb67f73862115f56eebb007f569..5006141ccd8dc0088a8a791295db2160891fc971
Plan mode: non-Yes paths must guide the model to continue planning
Report
What was built — plan_exit/plan_enter answered "No" no longer throw QuestionRejectedError (which hard-stopped the turn and left an intent-free "dismissed" error); both now resolve normally with switched: false and explicit stay-in-mode guidance — plan_exit's output instructs the model to use the question tool to ask what to refine and forbids implementing. Custom-feedback answers additionally carry a "mode did NOT change, do not implement" reminder. Esc (dismiss) still rejects and stops the turn.
Every plan→plan turn now injects a one-line synthetic system-reminder (plan mode active, only the plan file is writable, end the turn with question or plan_exit) — deliberately short per user direction to save tokens; the full workflow reminder remains entry-transition-only. A dedup guard keeps it to exactly one reminder per user message across multi-step turns (see S2), and upgrade impact on resumed sessions is a one-time incremental prefix-cache miss only (see S2).
Verification — from packages/cli: bun typecheck PASS; bun test test/tool/plan.test.ts PASS (4 new tests: No/feedback/Esc on plan_exit, No on plan_enter); bun test test/session/plan-reminder-dedup.test.ts PASS (multi-step entry + continuation turns each carry exactly one reminder); bun test test/tool/question.test.ts test/agent/agent.test.ts test/permission/disabled.test.ts test/session/prompt.test.ts PASS (71); bun test test/tool/tool-script.test.ts PASS (47). Independent reviewer: spec compliance MET, no correctness bugs, style consistent.
Journey log
- Tool pipeline appends
truncated: falseto metadata — assert withtoMatchObject, nottoEqual. Question.Service.rejecttakes a barerequestID, unlikereplywhich takes an object; passing an object silently no-ops (logged warning) and hangs the awaiting fiber until test timeout.- Esc semantics survive the fix for free: the
RejectedErrorfor dismissal originates insidequestion.ask's deferred, not from the removedanswer === "No"re-throw. - The continuation reminder was first written as 3 lines; user flagged token cost on chatty planning sessions — compressed to one line.
insertRemindersruns per-step, not per-turn, and persists parts — any unconditional injection there duplicates on multi-step turns. The base entry branch only avoided this by accident (its condition flips after step 1). First fix matched on reminder text ("Plan mode is"); replaced with the simpler positional guardmessages.at(-1) === userMessage(fresh user turn = step 1), which encodes the intent directly.
[S1] Problem
When the model calls plan_exit (or plan_enter) and the user does not pick "Yes", the model either goes silent or misbehaves (issue #1812):
- "No" —
plan.tsthrowsQuestion.RejectedError, whose message is the intent-free "The user dismissed this question".processor.ts:377then setsctx.blocked, hard-stopping the turn. The model never responds to the user's explicit "stay in plan mode and refine" decision, and next turn only sees a failed tool call with no guidance. - Custom feedback — the tool returns
User chose not to switch yet and provided feedback: ...with no instruction that plan mode is still active. Models routinely misread the feedback as approval and start implementing while in plan mode (reading files, attempting edits via bash — only theedittool is hard-blocked). - Subsequent plan turns — the full plan-mode system-reminder is injected only on the transition into plan mode (
prompt.ts:810returns early when the previous assistant message was already plan). Long planning conversations lose the constraint entirely.
[S2] Design
plan.ts — replace RejectedError on "No" with a guiding tool result
Applies to both PlanExitTool and PlanEnterTool (symmetric fix, user-confirmed).
plan_exit, answer"No": do NOT throw. Return a normal result:title: "Staying in plan mode"output: states that the user chose to stay in plan mode and continue refining; instructs the model it must NOT implement, and to use thequestiontool to ask the user which aspects of the plan to refine or change.metadata:{ switched: false, feedback: "" }
plan_enter, answer"No": same shape; output states the user chose to stay in the current mode and the model should continue the current task without switching.- Custom-feedback branch (answer is neither "Yes" nor "No"), both tools: keep returning the feedback, and append an explicit reminder that the mode did not change — for
plan_exit: plan mode is still active, do not implement; address the feedback by refining the plan file, then callplan_exitagain when ready. - Esc (dismiss): unchanged. The
RejectedErrorfor Esc originates insidequestion.askitself (deferred failure), not from the removedanswer === "No"re-throw, so turn-stop behavior for Esc is preserved automatically. - Loop consequence: with no error thrown on "No",
processor.ts:377no longer fires; the turn continues and the model can immediately ask what to refine.
prompt.ts — short continuation reminder on plan→plan turns
In SessionPrompt where input.agent.name === "plan" and the previous assistant message is already plan (currently an early return at prompt.ts:810), inject a compact synthetic <system-reminder> on the user message instead of returning bare. Content (short, ~3 lines, to avoid token waste — user-directed):
- Plan mode is still active: read-only, the only writable file is the plan file at
${plan}(or "create it at ${plan}" when absent). - Refine the plan per the user's message.
- End the turn by either asking a question (
questiontool) or callingplan_exit.
The full plan-mode workflow reminder stays exclusive to the entry transition; this new one covers every subsequent plan turn.
Dedup guard (required for correctness): insertReminders runs on EVERY loop step and persists parts via updatePart. The base code's entry branch was implicitly self-guarding — at step 2 the last assistant message is already plan, flipping the condition. The new plan→plan branch has no such flip: its condition stays true all turn, so step 2+ would stack duplicate reminders into the DB (and shift the prompt prefix every step). The branch therefore injects only on a fresh user turn — when the user message is the last message in the context (step 1); at step 2+ the turn's own assistant message follows it. This directly encodes the intended semantic ("user typed a new plan-mode message") with no content matching. Regression test: test/session/plan-reminder-dedup.test.ts (multi-step entry turn + multi-step continuation turn each end with exactly one reminder).
Upgrade compatibility / prefix cache: no schema change; historic messages are never rewritten. For an in-flight plan session resumed after upgrade, the next plan→plan user message gains one extra synthetic part — a one-time incremental prefix-cache miss from that message onward (equivalent to any new user input), not a full-history invalidation. Subsequent turns append the reminder only on the newest message, so the historic prefix stays byte-stable and cacheable.
Out-of-scope UI note
TUI strike-through rendering (completed && metadata.switched === false) is left as-is; it applies uniformly to No/feedback results and is cosmetic.
[S3] Out of Scope
- Removing or redesigning plan mode itself (possible future direction; not this change).
- Esc/dismiss semantics and
continue_loop_on_denybehavior. - Hardening bash against writes in plan mode (existing "trust the model" stance).
- TUI rendering changes, i18n question text changes.
Tasks
- T1: Rework "No" and feedback branches in
PlanExitToolandPlanEnterTool(packages/cli/src/tool/plan.ts) — acceptance: replying "No" to either tool resolves successfully withswitched: falseand output containing continue-planning guidance (plan_exit output instructs asking the user what to refine via the question tool); feedback replies include a "mode unchanged, do not implement" reminder; rejecting (Esc) still fails withQuestionRejectedError; covered by new unit tests underpackages/cli/test/tool/(covers: S2) - T2: Inject short plan-continuation reminder for plan→plan turns in
packages/cli/src/session/prompt.ts— acceptance: when agent isplanand previous assistant message agent isplan, the outgoing user message gains one synthetic system-reminder naming the plan file path and the question/plan_exit turn-ending rule; entry transition still gets the full workflow reminder only; verified by unit test or, if the prompt pipeline is impractical to harness, by targeted inspection plus typecheck (covers: S2) - T3: Verify — acceptance:
bun typecheckpasses inpackages/cli; new and existing related tests (test/tool/question.test.ts, new plan tool tests,test/agent/agent.test.ts) pass from the package dir (covers: S2; depends: T1, T2)
Compose Spec Plugin Host Sdk (`docs/compose/spec/plugin-host-sdk.md`)
feature: plugin-host-sdk status: delivered updated: 2026-10-12 branch: feat/plugin-host-sdk commits: 4a5e2140..de9d65c5
Plugin Host SDK
Report
What was built — Config and TUI config loading no longer npm-install @mimo-ai/plugin into config directories, and plugin loading no longer waits on those installs (PluginSdkNpmVersion, waitForDependencies and the loader's wait retry are gone). On Bun, PluginHost.provideSdk() (packages/cli/src/plugin/host.ts) registers @mimo-ai/plugin, /tool and /tui as host-provided virtual modules at the start of PluginLoader.loadExternal and before file hooks load; the host copy wins over any plugin-local node_modules, and the file-hook Bun.build marks the SDK external so hooks share it too.
When the runtime cannot serve the SDK (the Node build used by Desktop) or MIMOCODE_ONLY_CONFIG_DIR_PLUGINS is set, only plugins whose source lies inside MIMOCODE_CONFIG_DIR load, and file hooks are discovered only there. Desktop's own plugin keeps loading; user plugins from other config directories do not load at all.
Verification — From packages/cli: bun typecheck PASS; root bun typecheck PASS; bun test test/plugin test/config/plugin-dependencies.test.ts test/config/config.test.ts test/cli/tui --timeout 30000 PASS (557 pass, 0 fail); bun test test/provider/refresh.test.ts PASS (6/6). test/plugin/host-sdk.test.ts was red-checked against disabled provideSdk, a forced-off policy, and a missing Bun.build external. oxlint on changed files: 0 errors. Compiled binary (bun script/build.ts --single): a project plugin importing tool with no node_modules registered its tool; with the flag set it did not load. Independent review found one critical (cross-drive containment failing open on Windows), fixed in de9d65c5 and re-reviewed clean.
Journey log —
Bun.pluginbuild.modulefor a bare specifier wins overnode_modulesboth underbun runand inbun build --compilebinaries; no opentui-style specifier rewriting is needed.Bun.buildignores runtimeBun.pluginregistrations; mark the SDKexternaland let the runtime import resolve it.Filesystem.containsfails open across Windows drives (path.relativereturns an absolute path); its other call sites still have this.- Embedders shim part of
Bunon Node (Desktop defines onlyBun.file/Bun.write), sotypeof Bunis not a runtime test; probeBun.plugin.test/plugin/host-node.test.tsbundleshost.tsfor Node and runs it with and without such a shim. - Follow-ups: drop the always-false loader
retryargument; host-SDK tests needoutsideGit: true, since tmpdirs underpackages/cliresolve the workspacenode_modules.
[S1] Problem
On every startup the engine and the TUI run an npm install of @mimo-ai/plugin into every config directory (~/.config/mimocode, each .mimocode up the tree, ~/.mimocode, MIMOCODE_CONFIG_DIR). Plugin loading waits for all of those installs with no timeout whenever any plugin exists. The install touches the network and writes package.json, package-lock.json and node_modules into user directories without asking. It can pin a version that does not exist on npm, and it gives each directory its own copy of the SDK and its own zod. This supersedes the version-resolution patch in plugin-sdk-npm-version.md.
The Node build (used only by MiMo Desktop) has the same coupling. Desktop pre-writes npm short-circuit files so the wait returns immediately. Directory plugins written by users are only half supported there: they load, but a plugin that imports the SDK fails to resolve. On top of that, user plugins can collide with Desktop's own tools.
[S2] Design
[S2.1] No implicit install
Config and TUI config loading never install packages into config directories, and plugin loading never waits on dependency installs. Existing package.json and node_modules in those directories are left untouched. Explicitly configured npm plugins, providers and LSP servers keep their own install paths. PluginLoader.loadExternal drops its wait option and the file-plugin retry it drove, since no caller prepares dependencies any more.
When an external plugin fails to import because a module is missing, the load error tells the author to bundle runtime dependencies or install them in the plugin's own directory.
[S2.2] Host-provided SDK on Bun
packages/cli/src/plugin/host.ts exports PluginHost.provideSdk(). On the Bun runtime it registers a Bun.plugin that serves @mimo-ai/plugin, @mimo-ai/plugin/tool and @mimo-ai/plugin/tui from the host's bundled SDK as loader: "object" modules. The call is idempotent. It is a no-op when Bun.plugin is not a function.
The host module wins over any node_modules/@mimo-ai/plugin next to the plugin, so plugins share the host's tool() and zod. Plugin authors install the package only for types.
provideSdk() runs before any external plugin import: at the start of PluginLoader.loadExternal (the single entry for server plugins, TUI plugins, TUI file plugins and plugins added from the TUI) and before file hooks load. File hooks are bundled with Bun.build, which does not see runtime plugins, so that bundle marks @mimo-ai/plugin and @mimo-ai/plugin/* as external. The runtime import then resolves to the host module.
[S2.3] Config-dir-only plugin policy
PluginHost.onlyConfigDir() is true when the runtime cannot serve the SDK (Bun.plugin is not a function, which covers plain Node and embedders such as MiMo Desktop that define a partial Bun global on Node) or when the new flag MIMOCODE_ONLY_CONFIG_DIR_PLUGINS is truthy. Under this policy the engine loads only plugins provided by MIMOCODE_CONFIG_DIR:
PluginHost.filterOrigins(origins)keeps a plugin origin only when itssourcepath lies insideFlag.MIMOCODE_CONFIG_DIR, after resolving symlinks on both sides; a path on another Windows drive is outside. Non-path sources (MIMOCODE_CONFIG_CONTENT, remote config URLs) are dropped.PluginLoader.loadExternalapplies it, so it covers every server and TUI plugin load, including TUI file plugins (their source is<dir>/tui.json).PluginHost.filterDirectories(dirs)keeps a config directory only when it lies insideFlag.MIMOCODE_CONFIG_DIR. It applies to file-hook discovery.- When the policy is active and
MIMOCODE_CONFIG_DIRis unset, no external plugins, file hooks or TUI file plugins load. - Each filter logs the number of skipped items.
When the policy is inactive, both helpers return their input unchanged. MIMOCODE_PURE still takes precedence and skips all external plugins.
On the Node build, Desktop's own plugin is declared in MIMOCODE_CONFIG_DIR/mimocode.json and keeps loading. User plugins from global or project config are not loaded at all, rather than loading in a half-working state. Desktop needs no change; its npm short-circuit files become unused.
[S2.4] Testing
- A plugin in a temp directory with no
node_modulesimportstoolfrom@mimo-ai/pluginand registers a tool. - With a fake
node_modules/@mimo-ai/pluginnext to the plugin, the plugin receives the host module. - A file hook with a value import from
@mimo-ai/pluginloads with nonode_modules. - With
MIMOCODE_ONLY_CONFIG_DIR_PLUGINS=1, a plugin declared inMIMOCODE_CONFIG_DIRloads and a project-local plugin does not. With the flag set andMIMOCODE_CONFIG_DIRunset, no external plugin loads. - Config loading leaves no
package-lock.jsonornode_modules/@mimo-ai/pluginbehind (existingplugin-dependenciestests).
[S3] Out of Scope
- Serving the SDK on the Node runtime (Node module hooks).
- Removing Desktop's npm short-circuit files; Desktop does that after bumping its engine pin.
- Changing npm CLI distribution or the npm plugin install path for explicitly configured plugins.
- Making file hooks work on the Node build; they already require
Bun.build. - Removing the now always-false
retryargument threaded through loader reports and TUI plugin state.
Tasks
- T1: Remove startup SDK installs, dependency waits and
PluginSdkNpmVersion; add the missing-module hint — acceptance:plugin-dependenciestests pass and no config-dir npm install remains inconfig.tsortui.ts(covers: S2.1) - T2: Add
PluginHost.provideSdk()and call it before server and TUI external plugin loads; mark the SDK external in the file-hook bundle — acceptance: host-SDK tests in S2.4 pass (covers: S2.2, S2.4; depends: T1) - T3: Add
MIMOCODE_ONLY_CONFIG_DIR_PLUGINS,PluginHost.onlyConfigDir(),filterOrigins,filterDirectories, and apply them inPluginLoader.loadExternaland file-hook discovery — acceptance: policy tests in S2.4 pass (covers: S2.3, S2.4; depends: T2)
Compose Spec Plugin Sdk Npm Version (`docs/compose/spec/plugin-sdk-npm-version.md`)
feature: plugin-sdk-npm-version status: delivered updated: 2026-09-14 branch: feat/plugin-sdk-npm-version commits: 4b1dfe2fa68bd6cf4d086244617ccac4146fc43a..aca33f1fd83637c83c26bc837d65b3068d697019
Plugin SDK npm Version Resolution
Report
What was built — Config/TUI background installs of @mimo-ai/plugin no longer pin the package to the engine install identity. A pure resolver pluginSdkNpmVersion(version, local) in installation/version.ts decides the npm version: local installs and non-semver identities (desktop-<hash>, other non-npm strings) omit the version so npm resolves latest; only a valid semver identity (release and prerelease forms) pins that exact string. Both install sites (config/config.ts, tui/config/tui.ts) consume the runtime binding PluginSdkNpmVersion. InstallationVersion / InstallationLocal remain the install identity for skill extraction, User-Agent, upgrade, and other consumers.
Verification — From packages/cli on the feature worktree: bun typecheck PASS (tsgo --no-emit clean); bun test test/installation/plugin-sdk-npm-version.test.ts PASS (3 pass / 0 fail); bun test test/installation/ PASS (22 pass / 0 fail). Grep confirms no remaining InstallationLocal ? undefined : InstallationVersion in package source. Independent review passed spec compliance, correctness, and codebase consistency with no critical findings.
Journey log —
- Desktop embeds intentionally burn
MIMOCODE_VERSION=desktop-for skill path isolation; that identity must stay, so the fix is in the engine consumer, not the desktop inject. InstallationLocalis channel-based (=== "local"), not version-based — desktop runschannel=latest+ non-semver version, so both flags matter for the pin decision.- npm install stringifies as
[name, version].filter(Boolean).join("@");undefinedcorrectly means unpinned, not@pkg@undefined. - Residual (explicit out of scope): a non-local valid but unpublished semver identity still fails resolution with warn-only; no registry probe/fallback in this change.
[S1] Problem
Config load installs @mimo-ai/plugin into each config directory so user plugins can import the SDK. The install request currently pins the package version to InstallationVersion whenever InstallationLocal is false.
InstallationVersion is an install identity, not an npm dist-tag. Official CLI releases use a published semver and match npm versions. Embedding hosts (MiMo Desktop) deliberately inject MIMOCODE_VERSION=desktop- with MIMOCODE_CHANNEL=latest so builtin skill / compose extraction paths advance with the pin. That identity never exists on npm, so config load requests @mimo-ai/plugin@desktop-<hash>, npm resolution fails, and the engine only logs background dependency install failed. User plugins that import @mimo-ai/plugin from a config directory then fail to resolve at runtime.
[S2] Design
Decouple install identity from npm package version resolution.
InstallationVersion / InstallationLocal remain unchanged: they still drive User-Agent, telemetry, skill extraction directories, and release upgrade checks.
A pure resolver and its runtime binding live in packages/cli/src/installation/version.ts:
pluginSdkNpmVersion(version: string, local: boolean): string | undefined
PluginSdkNpmVersion = pluginSdkNpmVersion(InstallationVersion, InstallationLocal)Contract for pluginSdkNpmVersion:
| Input | Result |
|---|---|
local === true |
undefined (npm resolves latest) |
non-local + valid semver version (e.g. 0.1.14, 0.1.3-preview.0) |
that version (pin to the release) |
non-local + non-semver identity (desktop-<hash>, any non-npm string) |
undefined (latest) |
Semver validity uses the same semver package already used by npm/plugin code (semver.valid). Preview prereleases are valid semver and stay pinned when they are the release identity. The helper checks format validity, not registry presence.
Config and TUI install sites both consume PluginSdkNpmVersion instead of InstallationLocal ? undefined : InstallationVersion:
packages/cli/src/config/config.tspackages/cli/src/cli/cmd/tui/config/tui.ts
Error behavior is unchanged: install failure still logs a warning and does not block config load. The change only makes the version request resolvable for non-semver identities.
[S3] Out of Scope
- Changing desktop
MIMOCODE_VERSION=desktop-<hash>/ skill extraction identity. - Publishing desktop-hash tags to npm.
- Surfacing background install failures to product UI.
- Probing npm before pin / fallback-on-404 for unpublished semver identities.
- Compatibility-check changes (
checkPluginCompatibilityalready skips non-semver host versions).
Tasks
- T1: Add
pluginSdkNpmVersionpure helper +PluginSdkNpmVersionbinding ininstallation/version.ts— acceptance: local → undefined; valid semver non-local → same string; non-semver non-local (desktop-hash) → undefined; covered by unit tests (covers: S2) - T2: Point
config.tsandtui.ts@mimo-ai/plugininstall sites atPluginSdkNpmVersion— acceptance: neither site passesInstallationLocal ? undefined : InstallationVersionanymore; no leftover unused imports (covers: S2; depends: T1) - T3: Run package typecheck + targeted unit tests — acceptance:
bun typecheckand the new plugin-sdk-npm-version tests pass frompackages/cli(covers: S2; depends: T2)
Compose Spec Provider Local Refresh (`docs/compose/spec/provider-local-refresh.md`)
feature: provider-local-refresh status: delivered updated: 2026-10-03 branch: codex/provider-local-refresh commits: 698f0f29..296b24ca
Provider Refresh Without Instance Disposal
Report
What was built — An explicit model refresh API prepares and publishes Config
model fields and Provider caches while preserving directory instances. Busy
instances return pending without scheduling work; failed preparation leaves
prior views active. HTTP operations and MCP sampling hold activity claims, and
callbacks belonging to closing instances are rejected.
MCP registration and authentication mechanisms are unchanged. MCP, skill, and
plugin configuration still requires an explicit restart. No Desktop changes or
database migrations are included.
Verification — Independent Compose Next review of698f0f29..296b24ca passed spec compliance, correctness, and codebase consistency,
with no unresolved findings.
Broader CLI regression selection: 933 passed, 4 skipped across 55 Config,
Provider, non-mocking MCP, Instance, and Effect test files. Separate MCP runs
forheaders,lifecycle,oauth-auto-connect,oauth-browser, andstdio-exit-observepassed 47 tests; six HTTP regression files passed 26.CI runs each provider refresh suite in a separate process because the API
requires process-wide idleness, while other suites exercise detached session
work. Exact isolated commands passed 6 core and 7 boundary tests; YAML parsing
and the focused CI review passed. No test or assertion was removed.The final teardown fix passed the following affected regression command from
packages/cli(110 passed, 0 failed):shbun test test/provider/{refresh,refresh-boundaries}.test.ts \ test/project/instance-dispose.test.ts \ test/mcp/{sampling,sampling-e2e}.test.ts \ test/server/{session-prompt-busy,session-recovery,project-init-git,openapi-refs,session-select,session-actions}.test.ts \ --timeout 120000Root
bun run typecheckpassed. Rootbun run lintreported 3,287 warnings
and 0 errors; this is not a warning-free lint result.SDK generation and
bun tsc -b packages/sdk/tsconfig.json --forcepassed.
Generation also surfaced unrelated existing SDK drift, which is excluded
from this change.bun script/build-node.tsfrompackages/cli, with fixture models and a
local version/channel, passed. A plain Node HTTP/SDK smoke confirmed the
OpenAPI operation, unauthenticated rejection, authenticated refresh, and the
unchanged existing Provider client.git diff --checkpassed.Regression probes reproduced OAuth renewal failure, consecutive-update
disposal, cold initialization, missing sampling activity, and teardown
cancellation before the corresponding corrections; retained cases now pass.
Journey log
- Keep refresh limited to model settings. Removing extra credential and MCP
registration mechanisms kept the change within the requested scope. - Count actual asynchronous operations, including MCP callbacks, rather than
only the request that creates their context. Closing-owner admission must
reject promptly so cancellation cannot wait for its own teardown. - Preserve cold state and reuse initialized plugin hooks; refresh must not
accidentally run full configuration or plugin initialization. - A flat SDK operation ID preserves the existing exported Provider client.
Force SDK compilation after generation when stale incremental metadata can
otherwise omit emitted files. - Global-idle tests need an isolated process. Existing resume fixtures leave
notification/wake activity alive beyond their assertions; a fresh process
avoids order dependence without weakening production admission or expanding
this feature into unrelated fixture lifecycle changes.
[S1] Problem and scope
Embedded clients need to apply model catalogs, provider settings, and credential
changes without destroying the directory instance that owns conversation state,
subscriptions, and pending interactions. Ordinary instance disposal currently
couples those unrelated lifetimes.
This change provides an engine API for local model refresh. It does not change
the Desktop configuration UI or automatically migrate CLI configuration actions
to that API. Existing conversation and explicit lifecycle operations must remain
compatible, including callers that never request a provider refresh.
The existing linked engine worktree is reused inside the already selected
Desktop worktree. The feature document uses the default Compose Next location.
The engine PR and its review material are in English; Desktop review and
publication are separate work.
[S2] Admission and publication
POST /global/provider/refresh returns { "state": "applied" } after a
successful refresh, or { "state": "pending" } when an instance has an active
request, execution reservation, update, or unresolved cleanup. A busy response
does not queue a background refresh: the caller must retry after work finishes.
It must not cancel work or dispose an instance. The v2 JavaScript SDK exposes the
same operation as client.global.refreshProviders() without renaming the
existing provider client.
The refresh prepares candidates for the existing directory contexts while
holding an admission barrier. New requests wait, and synchronous execution
claims cannot enter that barrier. Ordinary disposal and reload must not race a
model publication. Request, execution, and teardown accounting remains owned
by Instance rather than inferred from UI activity.
All candidates must be prepared before publishing any replacement. A failed
preparation retains the old usable model views, releases admission, and allows
a later retry. Refresh does not emit instance-disposal events or replace Bus,
SessionRunState, permission, question, MCP, or skill state.
Inbox wake ownership during ordinary disposal
Ordinary configuration disposal uses the same Instance execution ledger. An
inbox delivery that schedules a receiver loop holds an Instance claim before
forking that loop. The claim belongs to the current service instance and lasts
until the scheduled wake fiber actually exits; the receiver's existing Runner
and ActorExecution keep their own execution ownership. This closes the gap
between a child terminal notification and the receiver's asynchronous admission,
so the last child releasing its claim cannot dispose the scope containing the
already scheduled receiver work.
The wake remains interruptible and detached from the sender. A registered fiber
exit observer releases the claim even when the wake is interrupted before its
body starts. Claim acquisition, wake scheduling and observer registration form
one uninterruptible handoff; observer registration is synchronous and also
handles an already completed fiber. Explicit wake: false and a missing receiver-loop
binding persist the inbox row without taking a claim or scheduling work. No new
message, retry, timeout, actor activity flag or configuration state is created.
Concurrent deliveries retain independent scheduling claims and continue to use
the existing Runner's pending-work and inbox-drain semantics.
The regression boundary covers pending disposal during terminal delivery, real
receiver model output, multiple concurrent deliveries, session abort, and the
absence of automatic execution for quiet deliveries. The plain Node build and
HTTP integration exercise the same implementation as Bun tests.
Effect HTTP API handlers retain an instance claim for their actual operation,
including asynchronous work, and release it on completion, failure, or
interruption. This must not make existing lifecycle handlers reject their own
otherwise valid requests.
[S3] Configuration and provider state
The refreshed configuration surface is limited to provider,enabled_providers, disabled_providers, model, small_model, vision_model,
and model_groups. Source precedence follows ordinary configuration loading.
The preparation path does not install dependencies, rewrite configuration
files, scan commands/agents/plugins, or publish unrelated configuration changes.
Global source invalidation allows subsequently created instances to read fresh
configuration without invalidating the current instances. An instance whose
Config has not been initialized remains cold until ordinary use initializes it.
Provider candidates use the refreshed model fields and already initialized
plugin configuration/authentication hooks. Configured plugin factories are not
reloaded as part of a model refresh. Plugin-contributed models must survive
refresh even if that instance has not read its Provider state yet. Refresh does
not initialize cold plugin state.
A committed Provider view has fresh SDK and LanguageModel caches. Existing
supported adapters can pick up new options and model definitions; this feature
does not promise hot replacement of arbitrary SDK packages or plugin code.
In-flight execution must not mix models, options, and SDKs from different views.
[S4] Credentials and compatibility
Authentication retains its existing behavior, including OAuth token renewal.
A successful refresh builds the next Provider view from current configuration
and authentication sources. A pending or failed refresh leaves the previous
view in use; saving credentials alone does not mean refresh has succeeded.
Callers must check the result before presenting new settings as active.
Immediate invalidation of previously obtained SDK objects and credential
revocation enforcement are outside this API's scope. Callers that require an
immediate reset must use the existing explicit lifecycle operation. The refresh
endpoint uses the server's existing authorization boundary.
[S5] MCP activity and static configuration
Server-initiated MCP sampling counts as active execution for its owning
instance. Refresh returns pending until it finishes. Callbacks from a closing or
disposed instance are rejected and must not claim a replacement instance.
MCP registration, connection configuration, and OAuth semantics are unchanged.
This feature adds no duplicate-name registration support or MCP configuration
hot reload. Ordinary MCP, skill, and plugin configuration changes require an
explicit restart; model refresh must not imply that those changes were applied.
[S6] Boundaries and verification
There is no database/schema migration, per-session Instance conversion,
resource pool, idle eviction policy, or Desktop implementation in this PR.
Explicit disposal, shutdown, and isolated-worktree cleanup remain separate
lifecycle operations.
Verification covers real Instance/Config/Provider behavior; configuration and
provider regression tests; MCP sampling and existing lifecycle behavior;
request/cleanup admission; schema generation; typechecking; and the Node build.
External OAuth providers and third-party plugin side effects cannot be proven
by local fixtures. Review must distinguish tested mechanisms from those live
integration limits.
Tasks
- T1: Verify admission and publication — acceptance: busy requests and executions defer refresh; update, request, reload, and disposal lifetimes do not race or deadlock; failed preparation does not partially publish. (covers: S1, S2)
- T2: Verify configuration and provider isolation — acceptance: refreshed model data is executable, caches adopt it coherently, static configuration stays unchanged, and initialized plugins retain their contributions before and after first Provider use. (covers: S3; depends: T1)
- T3: Verify authentication compatibility — acceptance: successful refresh adopts current provider credentials, failed refresh retains the prior view, and normal OAuth renewal works for clients that never use refresh. (covers: S4; depends: T2)
- T4: Verify MCP activity admission — acceptance: active sampling defers refresh, completion releases admission, and stale callbacks cannot claim a replacement instance. (covers: S5)
- T5: Verify and independently review the full engine change — acceptance: relevant tests, typecheck, Node build, schema checks, and v2 SDK transport checks pass or have demonstrated baseline limitations; the reviewer gives separate spec-compliance, correctness, and codebase-consistency conclusions with no unresolved critical finding. (covers: S1, S2, S3, S4, S5, S6; depends: T1, T2, T3, T4)
Compose Spec Question Recovery Scope (`docs/compose/spec/question-recovery-scope.md`)
feature: question-recovery-scope status: delivered updated: 2026-09-21 branch: codex/question-recovery-scope commits: 4101b3d4..0271ca02
Question Recovery Scope
Report
What was built — Project bootstrap now settles abandoned actor metadata without reading message or part history. The synchronous directory-wide question JSON scan and its obsolete mutation tests are removed. Nothing schedules the same scan later, and no schema migration or physical archive is required.
Persisted questions remain historical until existing selected-session execution cleanup handles them. Fresh prompts repair idle main-slice question parts; pending requests are not reconstructed from storage. The regression suite verifies unchanged unrelated-session and active-subagent question parts, busy/retry protection, read-only recovery discovery, and startup actor settlement. Merely browsing an old transcript does not promise to repair every historical status.
Verification — Commands below ran from packages/cli unless noted.
bun test test/actor/abandon-question-bootstrap.test.ts --timeout 30000before the fix: expected failure, exposing the actual historical part SELECT queries.bun test test/actor/registry.test.ts test/actor/abandon-question-bootstrap.test.ts test/session/prompt-orphan-tool-parts.test.ts test/server/session-recovery.test.ts --timeout 30000: PASS, 64 tests, 190 assertions.bun test test/actor/abandon-question-bootstrap.test.ts --timeout 30000after the final fixture refinement: PASS, 1 test, 9 assertions. The refinement uses public session writes and simulates stale actor activity after those writes; production and other test files were unchanged.bun typecheckon the final code/test state: PASS.MODELS_DEV_API_JSON=./test/tool/fixtures/models-api.json bun run script/build-node.ts: PASS. No production changes followed this build.git diff --checkat repository root: PASS.- Independent review of the recorded range: PASS for spec compliance, correctness and codebase consistency; no remaining findings. The documentation-only scope clarification did not change production or tests.
Journey log
- Moving synchronous SQLite work to a detached Effect or timer would still block the caller's event loop; removing the redundant history scan eliminates that cost.
- Question waiters live in instance-local memory. Persisted running parts do not restore an answerable request after restart, so startup reclamation is unnecessary for input unblocking.
- Keep the small actor abandonment update: actor waiters depend on its persisted terminal status.
- Public part writes refresh actor activity. Crash fixtures must backdate the actor after seeding messages and parts.
- Review narrowed the isolation guarantee to question/tool parts; the separate preexisting assistant-message cleanup is outside this change.
[S1] Problem
Entering a project synchronously scans historical tool JSON to find abandoned questions. This work grows with the directory's entire transcript history even when no question needs repair. Embedded Node consumers execute the scan on their calling thread, making their UI unresponsive.
[S2] Design
Project bootstrap retains the existing indexed actor-registry abandonment update, including the other-process, age and status guards. It never reads or rewrites message or part history to reclaim questions, nor schedules that scan for later. Registry initialization and actor wait semantics remain unchanged.
Question requests are held in instance-local memory; persisted tool rows do not recreate a request after restart. Opening a project therefore leaves historical question parts unchanged and never re-prompts expired questions. Existing read-only recovery queries still offer the interrupted turn for the selected session.
Explicit user work uses the existing session-scoped lifecycle: a fresh prompt on an idle session repairs pending/running main-slice tool parts, including questions. That tool cleanup leaves question/tool parts in busy/retrying sessions and independently executing subagent slices unchanged. This is a tool-part guarantee, not a guarantee that all assistant-message metadata is untouched by the separate existing assistant cleanup. The existing ownership-scoped main-run finalizer continues to repair interrupted tools. Read-only recovery discovery does not mutate historical questions. Resuming a turn continues through the existing recovery/runner implementation; this change does not add a new recovery path or promise to settle every old subagent transcript row merely by browsing it.
The obsolete directory-wide question reclamation implementation and tests for its removed mutation contract are removed. Regression coverage exercises real project bootstrap with persisted historical rows, verifies actor abandonment still works, and exercises existing selected-session prompt/recovery behavior with cold history in another session. A database access guard makes any startup attempt to inspect part history fail deterministically, without timing thresholds or a large real database.
[S3] Out of Scope
Physical database archiving, new indexes/migrations, background transcript scans, desktop application changes, new question replay UI, and redesigning actor abandonment, assistant-message cleanup or cross-process ownership are outside this fix. Large individual-session hydration costs remain a separate concern.
Tasks
- T1: Prove bootstrap does not inspect or mutate question history while actor abandonment remains functional — acceptance: real bootstrap regression fails before the fix and passes afterward, including the history-read guard (covers: S2).
- T2: Remove directory-wide question reclamation and retain actor-only abandonment — acceptance: startup contains no transcript scan or deferred replacement, and registry guard tests pass (covers: S2; depends: T1).
- T3: Verify selected-session recovery and tool cleanup — acceptance: pending/running questions in an idle selected main slice are repaired on fresh prompt; unrelated-session question parts, active-subagent question parts and busy/retry question parts remain unchanged; recovery discovery remains read-only; package typecheck and Node build pass (covers: S2; depends: T2).
Compose Spec Read Finite Media Mimes (`docs/compose/spec/read-finite-media-mimes.md`)
feature: read-finite-media-mimes status: delivered updated: 2026-09-12 branch: fix/read-finite-media-mimes commits: 6fbb1732..4b4d5b90
read finite media MIME allowlist
Report
What was built — read no longer treats every audio/* or video/* MIME as attachable media. Attachment branches open only for a finite allowlist in util/media.ts: images image/jpeg|png|webp|gif, PDF application/pdf, audio audio/wav|x-wav|mp3|mpeg, video video/mp4. Binary media-like files outside that list (.webm, .aac, sniffed BMP) refuse with a convert hint naming the list; text-like media-like MIMEs fall through to the text reader (.ts/.mts fix for the video/mp2t mime-types collision).
Tool description is aligned in a second pass: static read.txt stays model-independent — image and PDF are attachments when the model includes those modalities — and names the finite list (jpeg/png/webp/gif, wav/mp3, mp4). Dynamic describeMedia(model) advertises only the modalities the model has, with those same finite format names.
Verification —
bun typecheckinpackages/cli— PASSbun test test/util/media.test.ts test/tool/read.test.ts— 61 pass, 0 fail- Reviewer pass 1 (allowlist): all 5 acceptance criteria met; no critical findings
- Reviewer pass 2 (description alignment): APPROVE; no critical findings
Journey log —
- Root cause:
mime-typesextension lookup, not content sniffing —.ts→video/mp2tis a known IANA collision with TypeScript. - Feature lineage: audio/video attach landed 2026-09-12 (
049fe862,cca753c0); image/PDF earlier; sniffing7fa302e3(2026-09-07). - User chose a stricter-than-MiMo allowlist (no BMP/FLAC/M4A/OGG/MOV/AVI/WMV) over a
modalitytool parameter. - BMP was previously attached for transform transcode; it is now refused under the finite image list.
- Prefix helpers (
isMedia,looksLikeMediaMime) remain for messaging only — they must never gate a successful attach. - Static desc must keep PDF caveated, not model-gated; dynamic desc owns capability-specific format lists. Change either string set and update the other + tests.
[S1] Problem
The read tool decided media modality with prefix checks on a MIME from sniffAttachmentMime(sample, AppFileSystem.mimeType(filepath)). mime-types maps .ts/.mts to video/mp2t, so TypeScript source entered the video branch and was refused. Prefix rules would attach any future audio/*/video/* lookup even when MiMo cannot take it.
Audio/video attachment was added on 2026-09-12 (049fe862, cca753c0). Image/PDF attachment predates that; content sniffing landed earlier (7fa302e3, 2026-09-07).
[S2] Design
read attaches media only when the resolved MIME is in a finite allowlist. Prefix matches are no longer sufficient to enter an attachment branch.
| Modality | MIME allowlist |
|---|---|
| image | image/jpeg, image/png, image/webp, image/gif |
application/pdf |
|
| audio | audio/wav, audio/x-wav, audio/mp3, audio/mpeg |
| video | video/mp4 |
Official MiMo also lists BMP/FLAC/M4A/OGG/MOV/AVI/WMV; read will not attach those. GIF stays (explicitly allowed).
Helpers: isReadImageMime, isReadPdfMime, isReadAudioMime, isReadVideoMime, isReadAttachmentMime.
read.ts branching after sniffAttachmentMime:
- MIME in a finite list → existing size / model-capability / adapter-declaration gates, then attach.
- MIME looks like media but is outside every finite list:
- sample is binary → refuse with a convert hint naming that modality’s finite list.
- sample is text-like → fall through to the text path (
.ts/.mtsfix).
- Otherwise → existing text / binary-fail path.
No modality tool parameter. Sniffing still overrides extension for known image/pdf/wav headers, but a sniffed MIME outside the finite list (e.g. BMP) is not attached.
Tool description alignment (second pass):
- Static
read.txtstays model-independent: one short sentence covers image and PDF — both are attachments “when the model includes those modalities” — and the same line names the finite attach list (jpeg/png/webp/gif,wav/mp3,mp4). All multimodal attach is model-capability gated, not PDF-only. - Dynamic
describeMedia(model)lists only the modalities the model accepts, each with the same finite format names (image (jpeg, png, webp, gif),audio (wav, mp3),video (mp4)). It does not invent PDF support; PDF remains the static caveat plus the runtime capability gate.
view_image, prompt-attachment routing, and MCP sampling keep their current prefix/capability logic (out of scope).
[S3] Out of Scope
- Adding a
modalityparameter toread. - Changing
AppFileSystem.mimeType/mime-typesitself. - Broadening or narrowing provider capability declarations in
capability-registry.ts. - prompt-side user attachments, MCP sampling, or
view_imageMIME policy. - Supporting MPEG-TS video (
.ts/.m2ts) even when binary — callers convert tovideo/mp4first.
Tasks
- T1: Export finite read-attachment MIME allowlists and membership helpers from
util/media.ts— acceptance: helpers return true only for listed MIMEs;video/mp2tis not a read image/audio/video/pdf mime (covers: S2) - T2: Gate
tool/read.tsmedia branches on the finite lists; binary media-like files outside the list still refuse with a convert hint naming the allowed list; text-like media-like MIMEs fall through to text — acceptance:.tsTypeScript source reads as text;.webmbinary still refuses with convert hint (covers: S2; depends: T1) - T3: Add/adjust regression tests in
test/util/media.test.tsandtest/tool/read.test.ts— acceptance: tests cover.ts/.mtsas text, finite-list attach still works for wav/mp4, out-of-list binary media still refuses (covers: S2; depends: T2)
Compose Spec Release Ci (`docs/compose/spec/release-ci.md`)
feature: release-ci status: delivered updated: 2026-10-12 branch: feat/release-ci commits: f98dcd1c..b8704179
Release CI
Report
Later:
npm-fds-launcherretired npm platform packages — npm now publishes SDK/plugin + the@mimo-ai/clilauncher only; platform binaries are FDS/GitHub archives.
What was built — Pushing a vX.Y.Z tag on a main commit runs .github/workflows/release.yml: verify (tag on main, four package versions equal the tag), the existing typecheck/lint/test workflows via workflow_call, a macOS build of 12 archives and 15 npm tarballs, then a publish job in environment release that ships npm SDK/plugin → GitHub Release (generated notes, archives only) → FDS archives then releases/latest → npm platform packages and @mimo-ai/cli, followed by a non-blocking mirror sync. npm publishes through trusted publishing (OIDC) with no stored token. Every publish stage skips work that already landed, so "Re-run failed jobs" resumes a release.
script/release.ts replaces the old release, version and publish scripts (root and per-package). packages/cli/script/build.ts only builds, with --archive for release archives; MIMOCODE_RELEASE is gone. The shared setup-bun action now pins Bun from packageManager and installs with bun ci. CONTRIBUTING documents the procedure and the npm trusted-publisher setup.
Verification
bun typecheck(root) — PASS.- actionlint 1.7.12 over all workflows — PASS (also after the review fix).
bun test script/release.test.ts— PASS, 7 tests / 23 expects (version format, verify against real git fixtures, real SDK/plugin packing).- oxlint on the new files — 0 errors; 3
await-thenablewarnings onawait expect(...).rejects, the pattern used elsewhere in the repo. - Full
MIMOCODE_VERSION=v0.1.15 bun script/release.ts buildin a throwaway clone with HEAD tagged — PASS: 12 archives and 15 tarballs, checkout clean afterwards; sdk/plugin/cli/platformpackage.jsonfields match the published 0.1.15 on npm; darwin-arm64 binary is ad-hoc signed like v0.1.15 and prints0.1.15. - Independent review — spec compliance met for T1–T5, no critical findings. Adopted:
releaseenvironment gate, CONTRIBUTING notes on manual start from the tag and one release at a time. Accepted as-is:npm viewnetwork errors fall through to a failing publish (retry resolves), an already-published GitHub Release is skipped without asset checks, the mirror-sync log prints thevprefix.
Not run: a real tag-triggered release, OIDC publication, FDS upload. These need the trusted publishers configured on npmjs.com first.
Journey log
bun pm packalready rewritesworkspace:andcatalog:specifiers; onlyexportsneeds moving fromsrctodist, so packing stays in place with a restore infinally.- v0.1.15 was built on macOS (ad-hoc, linker-signed darwin binaries, clean tarballs); building on
macos-latestkeeps that rather than trusting cross-signing from Linux. - Bun's
$.cwd()mutates the shared shell; tests neednew $.Shell().cwd(dir). - Publishing SDK/plugin before the binary removes the window where a downloadable binary resolves an unpublished plugin version, without touching runtime code.
[S1] Problem
Releases run from a maintainer machine through bun run release: script/version.ts drafts a GitHub Release, packages/cli/script/build.ts builds and uploads to GitHub and FDS, and script/publish.ts rewrites every package.json, runs a non-frozen install, regenerates the SDK, and publishes 15 npm packages with a long-lived npm token. The process depends on one machine's credentials and state, mixes building with publishing, and publishes @mimo-ai/plugin last, after the binary is already downloadable — a fresh binary resolving @mimo-ai/plugin@ can hit a version npm does not have yet.
The goal is a CI-only release path that lands before v0.1.16, so later work (retiring npm platform packages around v0.2) only edits CI lists.
[S2] Design
Trigger
- The maintainer merges a version-bump PR (cli/sdk/plugin/shared package.json all at the same
X.Y.Z, SDK regenerated manually as today), then pushes tagvX.Y.Zon that main commit. The tag push starts.github/workflows/release.yml. workflow_dispatchwith no inputs is kept for reruns when the original run is unavailable; it must be started from the tag ("Use workflow from" → the tag). Within a run, "Re-run failed jobs" is the normal recovery path and reuses the original build artifact.- One workflow-level concurrency group
release(no cancellation) serialises releases.
Jobs
flowchart LR
V[verify] --> T[typecheck / lint / test]
T --> B[build on macos-latest]
B --> P1[npm: sdk, plugin]
P1 --> P2[GitHub Release]
P2 --> P3[FDS archives, then latest]
P3 --> P4[npm: 12 platform packages, cli]
P4 --> P5[mirror sync, non-blocking]- verify (ubuntu):
bun script/release.ts verify. Fails unless the ref is a tag matchingvMAJOR.MINOR.PATCH(no prerelease/leading zeros), HEAD is the tag commit and is reachable fromorigin/main, andpackages/{cli,sdk,plugin,shared}/package.jsonversions all equal the tag version. - checks: the existing typecheck, lint and test workflows gain a
workflow_calltrigger and run against the tag commit. - build (
macos-latest, matching how past releases were produced: darwin binaries ad-hoc signed by the native toolchain):bun script/release.ts buildre-runs verify, builds all 12 targets viapackages/cli/script/build.ts --archive, compiles SDK/plugin from committed sources withtsc(never the SDK generator), packs 15 npm tarballs, writesdist/release/{archives,npm}/, then asserts the checkout has no tracked or untracked changes. The directory is uploaded as one Actions artifact. - publish (ubuntu, environment
release,contents: write,id-token: write): downloads the artifact and runs stages in order. Each stage is idempotent so "re-run failed jobs" resumes safely:npm libs—@mimo-ai/sdk, then@mimo-ai/plugin. Publishing these first removes the window where a downloadable binary references an unpublished plugin version.github— create releasevX.Y.Zwith--verify-tag --generate-notesand only the 12 archives. If a draft exists (earlier partial attempt) upload with--clobberand publish it; if a published release exists, skip.fds— upload the 12 archives toreleases/vX.Y.Z/, then writereleases/latestlast.npm cli— 12 platform packages, then the@mimo-ai/cliwrapper.script/sync-registry.tsmirror sync withcontinue-on-error.
npm publication
- Trusted Publishing (OIDC) only: no
NPM_TOKENsecret. Each of the 15 packages must have a trusted publisher configured on npmjs.com for repositoryXiaomiMiMo/MiMo-Code, workflowrelease.yml, environmentrelease;bun script/release.ts trustregisters it for every published package withnpm trust github(npm ≥ 11.19, run once by an owner in a 2FA login session — tokens cannot change package settings). npm CLI ≥ 11.5.1 is required; the job installsnpm@11and the script asserts the version. - Every tarball publishes with
--access public --tag latest. A package whose exact version already exists on the registry is skipped, which makes reruns safe. - Pack rules:
bun pm packresolvesworkspace:*/catalog:; the script temporarily rewrites SDK/pluginexportsfrom./src/*.tsto./dist/*.{js,d.ts}, copiesREADME_npm.mdandLICENSE, and restores the original files afterwards. After packing, the tarball'spackage.jsonmust have the expected name/version, noworkspace:/catalog:specifiers, and every export target present in the tarball.
Secrets
| Name | Kind | Purpose |
|---|---|---|
MIMO_FDS_AK, MIMO_FDS_SK |
repository secret | FDS upload + public-read ACL |
MIMO_FDS_ENDPOINT, MIMO_FDS_BUCKET, MIMO_FDS_PREFIX |
optional variables | Override FDS destination |
GH_TOKEN |
from github.token |
Release creation and upload |
| npm | OIDC | No secret; configured per pkg |
Script surface
script/release.ts <verify|build|github|fds|npm libs|npm cli>replacesscript/release.ts,script/version.ts,script/publish.tsandpackages/{cli,sdk,plugin}/script/publish.ts. Version comes fromMIMOCODE_VERSION.script/fds-upload.tsandscript/sync-registry.tsstay.packages/cli/script/build.tsno longer uploads anything;--archiveproduces the archives.MIMOCODE_RELEASEandScript.releaseare removed..github/actions/setup-bunpins Bun viabun-version-file: package.json(build.ts refuses any other Bun) and installs withbun ci.
Failure handling
A failed stage stops the job; later channels are not touched. Fix the cause (credentials, trusted-publisher config, network) and "Re-run failed jobs". If artifact content itself is wrong after any channel published, ship a new patch version instead of overwriting.
[S3] Out of Scope
- Changing npm package scope (platform packages, CLI wrapper behaviour) — PR B.
- Runtime plugin auto-install, debug-agent fixes, plugin examples, Bun type bumps from #2638.
- Automating version bumps, SDK generation, or tag creation.
- Separate npm READMEs for SDK/plugin.
Tasks
- T1: Rewrite
script/release.tswith verify/build/github/fds/npm stages and delete the replaced scripts — acceptance:bun script/release.ts verifyrejects a non-tag/mismatched version; old entry points are gone and nothing references them (covers: S2) - T2: Strip uploads from
packages/cli/script/build.ts, add--archive, dropMIMOCODE_RELEASE— acceptance: host--single --archivebuild produces the archive and touches no remote (covers: S2) - T3: Pack SDK/plugin from committed sources with a regression test — acceptance: test packs both into a temp dir; tarball exports resolve to files inside the tarball and no
workspace:/catalog:specifiers remain; checkout is clean afterwards (covers: S2; depends: T1) - T4: Add
release.yml,workflow_callon check workflows, pinned/frozen setup-bun — acceptance: actionlint passes; job graph and permissions match S2 (covers: S2; depends: T1) - T5: Update CONTRIBUTING with the release procedure and trusted-publisher setup — acceptance: doc describes bump PR → tag push → rerun path and lists secrets (covers: S2; depends: T4)
Compose Spec Release Publish Preflight (`docs/compose/spec/release-publish-preflight.md`)
feature: release-publish-preflight status: delivered updated: 2026-10-19 branch: fix/release-preflight-checks commits: 135cb231..0adeefc0
Release Publish Preflight
Report
What was built — The publish job now runs a blocking bun script/release.ts preflight after artifact download and before any npm/GitHub/FDS stage. Preflight asserts every @mimo-ai/{sdk,plugin,cli} tarball exists under dist/release/npm with matching embedded name/version, fails when MIMO_FDS_AK/MIMO_FDS_SK are missing, and uploads a small releases/v{version}/.preflight-ok canary through the real FDS signing path so unusable credentials stop the release before anything half-ships. China mirror sync now includes @mimo-ai/sdk and @mimo-ai/plugin, and the npm cli step label matches what it publishes (npm — @mimo-ai/cli launcher).
Verification — bun test script/release.test.ts --timeout 120000 PASS (11 tests / 30 expects). tsgo --noEmit -p tsconfig.scripts.json PASS. bunx oxlint on changed scripts: 0 errors (9 warnings match existing expect().rejects / sync-registry assertion style). Independent review: all five acceptance criteria met; no critical findings.
Journey log — Preflight was scoped to tarball completeness + FDS canary only; trusted-publisher / allow-publish checks stay in maintainer trust (CI has no npm owner session). Residual accepted risk: FDS can still die mid-upload after a green canary; recover with “Re-run failed jobs”. Canary is intentionally public-read like every other FDS object and is not referenced by releases/latest.
[S1] Problem
The publish job runs npm libs → github → fds → npm cli with no gate in front of it. FDS credentials and the built npm tarballs are only exercised after some channels have already shipped. A missing MIMO_FDS_AK/MIMO_FDS_SK, an unreachable FDS endpoint, or a corrupt/incomplete dist/release/npm therefore leaves a half-published release (SDK/plugin and/or GitHub Release already out, installers still on the old pin). China mirror sync also omits @mimo-ai/sdk and @mimo-ai/plugin, so new library versions never reach npmmirror while @mimo-ai/cli does.
[S2] Design
Run a blocking preflight as the first step of the publish job, after artifact download and before any npm/GitHub/FDS publish stage.
- Tarballs present and coherent — for each package in
npmGroups.libs + npmGroups.cli(@mimo-ai/sdk,@mimo-ai/plugin,@mimo-ai/cli), requiredist/release/npm/<tarball>to exist and its embeddedpackage/package.jsonname/versionto match the release version. Fail with the full missing/mismatched list. This reuses the same name/version rulepack()enforces at build time. - FDS usable — fail fast unless
MIMO_FDS_AKandMIMO_FDS_SKare set. Then prove signing + network + bucket with one small canary upload (releases/v{version}/.preflight-ok, body = version text). Upload uses the existingputObjectpath (including public-read ACL). A second run of the same release overwrites the canary; it is not published as an install artifact and is not referenced byreleases/latest. - Mirror list complete —
script/sync-registry.tsPACKAGESincludes@mimo-ai/sdkand@mimo-ai/pluginalongside@mimo-ai/cli(legacy platform package names stay for historical mirror completeness). - Step label — the publish step that runs
bun script/release.ts npm cliis named for what it actually publishes (npm — @mimo-ai/cli launcher), not “platform packages and CLI wrapper”.
Preflight is one new stage bun script/release.ts preflight invoked from release.yml. It is intentionally ordered before npm libs so a bad FDS/credential/artifact state cannot leave npm or GitHub half-shipped. Mid-upload FDS failure after a green preflight can still interrupt a release; that residual window is accepted and recovered with “Re-run failed jobs”.
Out of band: trusted-publisher / allow-publish registration stays a maintainer trust step and is not part of this preflight (CI has no npm owner session).
[S3] Out of Scope
- Changing publish order of
npm libs/github/fds/npm cli. - Fixing
trust()skip / revoke-recreate for--allow-publish(separate change). - Uploading per-archive
*.sha256. - Rewriting stale prose in
docs/compose/spec/release-ci.mdbeyond what this change requires. - Making China mirror sync a blocking release gate.
Tasks
- T1: Add
preflightstage inscript/release.ts— acceptance: missing tarball name/version mismatch and missing FDS creds throw before any upload; canary succeeds when creds and endpoint work (covers: S2) - T2: Call
preflightas the first publish step in.github/workflows/release.ymland rename thenpm clistep label (covers: S2; depends: T1) - T3: Add
@mimo-ai/sdkand@mimo-ai/plugintoscript/sync-registry.tsPACKAGES (covers: S2) - T4: Tests for preflight tarball checks and FDS credential failure (
covers: S2; depends: T1)
Compose Spec Remove Read State Gate (`docs/compose/spec/remove-read-state-gate.md`)
feature: remove-read-state-gate status: delivered updated: 2026-09-14 branch: feat/remove-read-state-gate commits: e485a2a5e096a077e381e6df56c332d8150629ae..HEAD
Remove Read-before-Edit Hard Gate
Report
What was built — Removed the session-wide assertFileRead hard gate from edit and notebook-edit. read-state.ts is deleted; tool descriptions no longer claim a recoverable failure when a path was not opened via the read tool. edit, write, and notebook-edit descriptions now advise reading first so content matches disk, which the tools still load at apply time. Permission and path guards (assertWriteAllowed, askEditUnlessMemory, memory path guard) are unchanged. Compaction guidance to re-read after context loss remains.
Verification — From packages/cli in the worktree:
bun typecheck— PASS (tsgo --noEmitclean)bun test test/tool/edit.test.ts— PASS, including no-prior-read, write-then-edit, and edit-create-then-edit casesbun test test/tool/notebook-edit.test.ts— PASS, notebook_edit replace without prior readbun test test/tool/write.test.ts/memory-edit-ask-skip.test.ts— PASS- Production grep for
assertFileRead/tool/read-stateunderpackages/cli/src— clean - Fresh reviewer subagent (impl): PASS, no CRITICAL
- Second reviewer (self-write / notebook coverage): PASS; four post-gate behaviors locked by real tool tests; gate-return would re-fail them
Journey log —
- The gate was introduced in
cb633947(PR #1243) to turn edit.txt usage notes into RecoverableError enforcement for existing-file edits. - Enforcement never covered
write/apply_patch;write.txtstill claimed hard failure that the tool never implemented — docs were ahead of code. - Real agent friction (bash-as-read, main-worktree read + linked-worktree edit absolute-path mismatch, self-written files not counting as read) made the gate a turn tax without protecting integrity:
editstill exact-matches current disk contents. - Impact surface was small: two call sites, one 44-line module, three tool .txt lines, one prompt phrase, and no dedicated unit tests for the gate itself.
- This session's own Edit tool still enforced the desktop read-before-edit rule when patching worktree
.txtfiles after only reading main-worktree copies — the path-mismatch problem reproduced live during implementation. - Confirmed on base
e485a2a5:read-state.tsonly matchespart.tool === "read";write.tsand the create branch ofeditnever record a read. Self-write → later edit is a guaranteed false negative.
[S1] Problem
assertFileRead forces edit / notebook-edit to fail unless the exact file path was previously opened with the read tool in the same conversation. Real agent workflows no longer match that contract:
- Models increasingly inspect files via bash (
cat/sed/python), which never records areadtool part, so the subsequenteditfails with a recoverable error and burns a turn. - Agents that read a path in the main worktree and then edit the same logical file inside their own worktree hit a path mismatch —
canon()compares absolute paths, so the gate rejects a legitimate edit. - Compaction / history loss can drop the original
readparts, re-triggering the gate on a file the model has already seen. - Self-authored files never count as read.
assertFileReadonly accepts completedtool === "read"parts; neitherwritenoredit(includingold_string=""create) synthesizes a read-state entry. Sowritethenedit, or multiedit's create-then-patch sequence on the same path, still trips the gate on the second step — even though the model just produced that content.
The gate does not protect file integrity: edit still loads current disk contents and exact-matches old_string before writing. write documents the same rule but never enforces it; apply_patch has no gate. The hard check is inconsistent, easy to bypass via bash, and hostile to worktree workflows.
[S2] Design
Remove the hard gate entirely.
- Delete
packages/cli/src/tool/read-state.ts. - Drop
assertFileReadfromedit(including the create-file exemption comment) andnotebook-edit. - Soften tool descriptions (
edit,write,notebook-edit) to advisory language: prefer reading the file first so edits match current contents; no claim of hard failure. Softeningwrite.txtis required because that text promised enforcement the tool never had. - Leave compaction guidance ("re-read any file you need before editing") — that is about context loss, not tool enforcement.
- Drop the "read-state tracking" claim from
default.txttool-layer benefits.
Permission / path guards (assertWriteAllowed, askEditUnlessMemory, memory path guard) stay unchanged.
[S3] Out of Scope
- Expanding bash outputs into a synthetic read-state.
- Making
writeenforce a read gate. - Changing exact-string match, fuzzy-edit flag, or apply_patch behavior.
- Compaction message rewording beyond what is needed for accuracy.
Tasks
- T1: Remove assertFileRead call sites from edit and notebook-edit — acceptance: both tools edit existing files without a prior
readtool call in the session (covers: S2) - T2: Delete read-state module and fix leftover references — acceptance: no production import of
assertFileRead/read-stateremains; tool .txt files no longer promise a hard failure (covers: S2) - T3: Verify with typecheck + focused tool tests — acceptance:
bun typecheckpasses frompackages/cli; edit tests still pass (covers: S2; depends: T1, T2)
Compose Spec Remove Task Gate Reentry (`docs/compose/spec/remove-task-gate-reentry.md`)
feature: remove-task-gate-reentry status: delivered updated: 2026-06-08 branch: feat/remove-task-gate-reentry commits: 37c3e1ef..37c3e1ef
Remove TaskGate (re-entry + passive incomplete-task reporting)
Report
What was built — Deleted the subagent completion gate entirely. Previously,
when a gate-eligible subagent finished with open/in_progress tasks it owned,TaskGate re-entered the agent (up to 2 times) demanding task done /task abandon and a re-emitted **Status**/**Summary** header; that re-emitted
text overwrote deliveredText, so the parent could receive a rewritten
conclusion. The passive layer (status downgrade to partial/blocked,incompleteTasks list, **Incomplete tasks** body suffix) was removed in the
same pass: task DB is already the source of truth.
After the change, a subagent returns its original finalText and the model's
self-reported header status. task/gate.ts is gone. completionGate remains
only as the switch for RETURN_FORMAT_INSTRUCTION injection. postStop /
SubagentProgressChecker / written-at / preStop / task_id auto-start are
untouched.
Verification —
bun typecheckinpackages/cli— PASSbun test test/task/ test/actor/spawn-task-autostart.test.ts test/actor/execution-integration.test.ts— PASS (44)bun test test/agent/agent.test.ts test/actor/return-header.test.ts test/inbox/— PASS (116)- Independent review (general-2): spec compliance PASS, correctness PASS,
no CRITICAL. Minor stale comments cleaned after review.
Journey log —
- First analysis mis-identified the target as SubagentProgressCheckerPlugin /
actor.postStop; user corrected to the pre-postStop completion gate (TaskGate). - Passive downgrade was initially kept ("提示 primary 的应该可以留"); user later
judged it also useless and asked to delete it together — final scope is full
TaskGate removal. - postStop must not replace the delivery body (TP-R14-11):
finalText/parseReturnHeadertarget the preserveddeliveredText, while hooks seelastFinalTextonly as input.
[S1] Problem
When a gate-eligible subagent finished, TaskGate re-entered the agent (up toMAX_TASK_GATE_SUBAGENT_REACT = 2) whenever it still owned open/in_progress
tasks. The nudge demanded task done / task abandon and then "re-emit your
final message starting with the Status/Summary header". The re-run'sfinalText overwrote deliveredText, so the parent could receive a rewritten
conclusion instead of the subagent's original delivery.
Even the passive layer (status downgrade to partial/blocked,incompleteTasks, **Incomplete tasks** suffix) was judged noise: task DB is
already the source of truth, and rewriting/annotating the delivery body for the
parent is unnecessary. Primary (main) has no equivalent stop-gate.
Progress checking (SubagentProgressCheckerPlugin / actor.postStop) is
unrelated and stays.
[S2] Design
Removed entirely
task/gate.ts(module deleted) and its tests.- TaskGate re-entry loop in
actor/spawn.ts(nudge + re-emit +deliveredText
overwrite +gateFailed+MAX_TASK_GATE_SUBAGENT_REACT). - Passive incomplete-task reporting:
reportedStatusdowngrade from task DB,AgentOutcome.incompleteTasks,**Incomplete tasks**body suffix. gateEligibleplumbing intoforkWork(no longer needed there).
Preserved
reportedStatus/reportedSummaryparsed from the model's**Status**/**Summary**
header only (no DB-truth override).- Delivery body is the main turn's
finalText. postStop may re-enter for
housekeeping but does not replace the delivery body. RETURN_FORMAT_INSTRUCTIONinjection andparseReturnHeader(waiter / group /
notification consume them independently).completionGateconfig still
selects which agents get the Status/Summary instruction.SubagentProgressCheckerPlugin/actor.postStop/written-at/ checkpoint
reconcile / preStop /task_idauto-start / memory-path-guard.
Behaviour after removal
A subagent leaves owned tasks open/in_progress → returns the originalfinalText and the model's self-reported status. No forced task done /task abandon round-trip. No conclusion rewrite. No suffix. Task DB remains
the source of truth for unfinished work; the parent can list tasks if needed.
[S3] Out of Scope
- Progress checker /
actor.postStop/written-at/ checkpoint reconcile. RETURN_FORMAT_INSTRUCTION/parseReturnHeader/ notification semantics.- Task tool UX, TaskRegistry schema,
task_idbinding / auto-start. - preStop / splitover / goalGate.
Tasks
- T1: Delete TaskGate re-entry + passive reporting from
spawn.ts—
acceptance: no gate-drivenrunAgentLoop; delivery body never rewritten;reportedStatusis header-only;incompleteTasksgone. (covers: S2) - T2: Delete
task/gate.ts— acceptance: module and imports gone;
typecheck clean. (covers: S2; depends: T1) - T3: Update tests — acceptance: no re-entry / downgrade / suffix
assertions remain; leftover-task cases assert delivery is untouched. (covers: S2; depends: T1, T2) - T4: Typecheck + run affected suites — acceptance:
bun typecheckinpackages/clipasses; actor/task tests pass. (covers: S2; depends: T3)
Compose Spec Rename Packages Opencode To Cli (`docs/compose/spec/rename-packages-opencode-to-cli.md`)
feature: rename-packages-opencode-to-cli status: delivered updated: 2026-09-30 branch: chore/rename-packages-opencode-to-cli commits: 327f5b63..afec3965
Rename packages/opencode → packages/cli
Report
What was built — The core package directory is now packages/cli, matching the published @mimo-ai/cli name. Every literal packages/opencode path reference was rewritten: root scripts and typecheck project refs, CI working-directory/artifact paths, bun.lock workspace tokens, install/release helpers, in-package comments and prompt templates, test fixtures and snapshots, the drive-mimo skill, living docs, and all historical docs/compose/spec/*.md. Semantic product identifiers (provider ids, external-import source opencode, root package name, shared bin key, upstream GitHub URLs, shipped migration SQL) were left untouched. There is no compatibility shim.
AGENTS.md was also slimmed in the same change: ## Core Focus is gone (tree is CLI-only after recent PRs), and Testing/Type Checking no longer teach how to run checks — only how to write tests (avoid mocks; test real implementation). CONTRIBUTING keeps its human-facing "Checks before you push" commands. Prompt templates no longer cite the deleted AGENTS how-to-run rules.
Verification — From packages/cli / repo root in the worktree:
bun ci(frozen lockfile after workspace path rewrite) — PASS- root
bun typecheck(all workspaces +tsconfig.scripts.json) — PASS bun test test/cli/tui/permission-bash-delete.test.tsx test/cli/run-completion.test.ts— PASS (33)bun test test/session/text-loop-integration.test.ts— PASS (3)- residual
rg packages/opencodeoutside this feature doc — empty
Independent review: all 6 acceptance criteria met; no critical findings. Non-critical prompt-template/AGENTS drift fixed in afec3965. Orphan test/tool/__snapshots__/tool.test.ts.snap is pre-existing (no tool.test.ts at base).
Journey log
- Bulk path replace initially skipped dotted dirs (
.github), so CI paths stayed stale until a second pass included them. - The feature document itself was rewritten by the bulk replace twice; keep historical
packages/opencodewording out of bulk-replace sweeps, or rewrite the doc after path sweeps. bun.lockmust be path-patched beforepostinstallcan run; root scripts still pointed at the old cwd and failed firstbun ci.- Workspace globs
packages/*meant nopackage.jsonworkspaces edit was needed — only lockfile path tokens. - Prompt templates embed AGENTS-style verify rules and will drift when AGENTS.md is edited independently.
[S1] Problem
The core package already publishes as @mimo-ai/cli, but its directory was stillpackages/opencode (a historical leftover). That mismatch showed up in every
developer path: AGENTS/CONTRIBUTING examples, CI working-directory, root
scripts (dev / build:local / postinstall / release), typecheck project
refs, install helpers, and ~38 archived feature docs. New contributors and
agents kept tripping over the name. We want the on-disk path to match the package
name, with every path reference updated so nothing still points at the old
directory.
[S2] Design
Rename. git mv packages/opencode packages/cli. Workspace globs
(packages/*, packages/sdk/js) pick up the new directory withoutpackage.json workspaces edits. The published npm name stays @mimo-ai/cli.
The root monorepo name stays opencode.
Path-string contract. Replace the literal path token packages/opencode
with packages/cli everywhere it refers to this package directory. Scope
covers root scripts/config (package.json, tsconfig*.json, local-install.sh),
build/release scripts (script/release.ts, publish.ts, generate.ts,meta.ts, fds-upload.ts), CI (.github/workflows/test.yml), bun.lock
workspace tokens, in-package path comments and prompt templates, test fixtures
and snapshots, packages/sdk/js/src/process.ts,.agents/skills/drive-mimo/SKILL.md, living docs (AGENTS.md, CONTRIBUTING.md),
and every historical docs/compose/spec/*.md (user decision: rewrite topackages/cli).
AGENTS.md content cleanup (user decision, same change):
- Drop
## Core Focus— after recent PRs the tree is CLI-only, so the section is noise. - Drop
## Type Checkingentirely and the "how to run tests" bullet under## Testing— roottypecheck/ packagetestscripts are self-explanatory. - Keep only how-to-write-tests guidance under
## Testing(no mocks; test real implementation). - Leave
CONTRIBUTING.md"Checks before you push" run commands in place (human onboarding). - Prompt templates must not cite deleted AGENTS how-to-run rules.
Do not change (semantic product / protocol identifiers, not directory paths):
- npm / workspace package name
@mimo-ai/cli,@mimo-ai/plugin,@mimo-ai/shared,@mimo-ai/sdk - root package
"name": "opencode" - provider ids, external-import sources, MCP origin strings, observability
serviceName, CLI$0, brew/scoop/choco formula names, GitHub URLs
(sst/opencode,anomalyco/opencode, …) packages/sharedbin keyopencode- migration journal contents under
packages/cli/migration/(only the
directory moves; do not edit shippedmigration.sql)
Lockfile. Bun records workspace package paths in bun.lock
("packages/opencode" and "@mimo-ai/cli": ["@mimo-ai/cli@workspace:packages/opencode"]).
After git mv, update those path tokens, then run bun ci to confirm the
lockfile still installs frozen.
No compatibility shim. Old paths break on purpose. CI and scripts must be
updated in the same change; we do not leave packages/opencode symlinks or
dual-path fallbacks.
Verification boundary. From the worktree: bun typecheck at root (covers
all workspaces + tsconfig.scripts.json), focused tests in packages/cli for
touched path-string surfaces, and residual packages/opencode path refs gone
outside intentional non-directory identifiers. Full suite is not required for
this rename.
[S3] Out of Scope
- Renaming npm packages or the root package name
- Renaming binaries (
mimo/opencodebin entries) - Reworking historical narrative in specs beyond path-token replacement
- Provider / external-import / MCP origin identifier changes
- Touching shipped
migration/*/migration.sqlcontent - Web/Desktop surfaces (already unmaintained)
Tasks
- T1:
git mv packages/opencode packages/cliand fixbun.lockworkspace paths — acceptance: directory ispackages/cli;bun ciinstalls with frozen lockfile;@mimo-ai/clistill resolves as workspace (covers: S2) - T2: Update root scripts/config/CI/build helper paths — acceptance:
package.json,tsconfig*.json,local-install.sh,script/*,.github/workflows/test.ymlall referencepackages/cliand none referencepackages/opencode(covers: S2; depends: T1) - T3: Update in-package path comments, prompt templates, tests, snapshot, SDK comment — acceptance:
rg packages/opencode packages/is empty except semantic product identifiers; touched path tests still pass (covers: S2; depends: T1) - T4: Update living docs and historical compose specs — acceptance:
rg packages/opencodeacross repo is empty outside intentional non-directory identifiers; AGENTS/CONTRIBUTING examples usepackages/cli; AGENTS.md drops Core Focus / how-to-run test-typecheck notes (covers: S2; depends: T1) - T5: Verify typecheck + focused tests + residual reference check — acceptance: root
bun typecheckPASS; focused tests inpackages/cliPASS; residualpackages/opencodepath refs gone (covers: S2; depends: T2, T3, T4)
Compose Spec Retire Compose Legacy (`docs/compose/spec/retire-compose-legacy.md`)
feature: retire-compose-legacy status: delivered updated: 2026-10-12 branch: feat/retire-compose-legacy commits: 4a5e2140..0cbe0b50
Retire Compose Legacy
Report
What was built — The legacy Compose surface is gone. The compose primary agent, the built-in compose workflow, and the fourteen compose:* skills with their extract/bundle pipeline are deleted, along with ConfigCompose, MIMOCODE_DISABLE_COMPOSE_SKILLS, the Compose prompt injection (session/prompt/compose.txt), and every TUI Compose special case. Historical sessions keep working: unknown stored info.agent names (including compose) fall back to the default agent (build) through Agent.resolveStoredAgent on message-read paths only — explicit live Agent.get still errors. promoteComposeProtocolFirst stays so legacy Compose synthetic protocol parts still order correctly on reopen.
Homepage tips now rotate tui.tips.compose_next as a normal weighted tip. Copy names compose-next in every language and describes the capability positively (「澄清需求、规格、实现与评审」 / "clarify, spec, implement, review", aligned with mimo-desktop composeDesc) — never "instead of Compose". tui.skill.compose-next.slash / .description follow the same naming. Docs surface (README×3, mimocode-docs skill, drive-mimo, SDK config types) describes compose-next as the orchestration entry with no legacy path.
Verification — bun run typecheck PASS (all workspaces). From packages/cli: bun test test/agent test/permission test/workflow/builtin.test.ts test/cli/cmd/tui/tips-view.test.ts test/skill/search.test.ts test/skill/bundle-discovery.test.ts test/skill/skill-description.test.ts test/session/prompt.test.ts → 334 pass / 0 fail. test/skill/skill.test.ts and test/workflow/runtime.test.ts fail identically on main in this local environment (~/.codex/skills/compose-next leaks via MIMOCODE_ENABLE_CODEX_SKILLS; SyncEvent projectors) — PRE-EXISTING, remote main is green. Independent review: spec compliance + consistency pass; one critical test-integrity bug (misnamed plan_exit assertion) and two minors (resolveStoredAgent cast, unused agentName on pickDisplayKey) fixed and re-verified.
Journey log —
- Tips copy direction came from mimo-desktop
composeDesc/ skill brief after the user rejected "instead of Compose" framing and showed the 编排模式 menu card; name stayscompose-nextin all languages, never translated to 「编排模式」. - Historical compatibility is deliberately narrow: only message-read sites use
resolveStoredAgent;Agent.getkeeps throwing for live requests so typos still surface. - Local test red on
skill.test.tslooked like a regression until compared againstmainand traced to~/.codex/skills/compose-next(not~/.claude) — always baseline local failures before blaming the diff.
[S1] Problem
compose remains as a primary agent / mode entry, a built-in compose workflow, and fourteen compose:* skills with their own extract/bundle pipeline. compose-next already covers the same spec→ship lifecycle on Build for frontier models, and the desktop UI entry is now a compose-next skill pill rather than a mode switch. The legacy surface is deprecated, still costs discovery/permission/i186/docs maintenance, and the only homepage tip that advertises compose-next is locked behind the Compose agent while its copy still says "instead of the Compose agent".
[S2] Design
S2.1 Retire the Compose agent
- Delete the native
composeprimary agent frompackages/cli/src/agent/agent.ts(definition,compose:*skill allow override,list()sort key). - Drop the default-agent
skill: { "compose:*": "deny" }rule once no bundledcompose:*skills remain. User-defined skills that merely start withcompose(no colon namespace) are unaffected. - Remove TUI Compose special cases: skill-dialog / autocomplete
compose:*filtering gated onagent.name === "compose", prompt chrome that branches oncompose, and mode-lock wording that mentions Compose isolation. - Remove
session/prompt/compose.txtand thePROMPT_COMPOSEinjection path that stamps{{compose_docs_dir}}into the first Compose user message.
S2.2 Retire the Compose workflow
- Delete
packages/cli/src/workflow/builtin/compose.jsand its tests (test/workflow/compose.test.ts, compose rows intest/workflow/builtin.test.ts). - Remove the
input.name === "compose"special case inpackages/cli/src/tool/workflow.tsthat threads_composeDocsDir. - Keep the generic workflow runtime and other built-ins (
deep-research,fact-check,research-experiment).
S2.3 Delete Compose skills and bundle logic
- Delete
packages/cli/src/skill/compose/entirely (.bundle/fourteen skills,bundle.macro.ts,extract.ts, licenses that only serve that bundle). - Remove
extractComposeBundleusage and thescope: "compose"scan frompackages/cli/src/skill/index.ts. - Remove
isComposeSkillfiltering frompackages/cli/src/skill/search.tsand thecompose:localized-alias exclusion. - Remove
MIMOCODE_DISABLE_COMPOSE_SKILLSfrompackages/cli/src/flag/flag.tsand test env fixtures. - Remove all
tui.skill.compose:*i18n keys (description + slash) from every locale.
S2.4 Delete Compose config
- Delete
packages/cli/src/config/compose.ts(ConfigCompose) and thecomposefield fromConfig.Info. Consumers today are only the Compose prompt injection and the Compose workflow. compose-nextkeeps its documented default artifact pathdocs/compose/spec/<feature>.mdas a skill convention, not a config key.
S2.5 Homepage tips: normal carousel, positive copy
tui.tips.compose_nextjoins the normal weighted tip pool (TIP_KEYS/buildTipKeys) so it rotates on every homepage visit.- Delete the Compose lock override in
pickDisplayKey(COMPOSE_LOCK_TIP/agentName === "compose"). The/loginoverride stays. - Tips (
tui.tips.compose_next): keep short and lead with the literal slash command so the user knows what to type. Never "instead of Compose" / 「代替 Compose」.- zh:
{highlight}/compose-next{/highlight} — 澄清需求、规格、实现与评审 - en:
{highlight}/compose-next{/highlight} — clarify, spec, implement, review
- zh:
- Skill description (
tui.skill.compose-next.description): do not say "Orchestration mode" / 「编排模式」 — that name belonged to the retired mode and the skill name already contains "compose". Lead with end-to-end delivery plus the four-beat capability line (tip length limits do not apply here).- zh:
端到端交付:澄清需求、规格、实现与评审 - en:
End-to-end delivery: clarify, spec, implement, review
- zh:
- Slash display name (
tui.skill.compose-next.slash): localized names are fine; not forced to the English id.- zh:
新编排模式· zht:新編排模式
- zh:
S2.6 Historical session compatibility
Unknown info.agent values in stored messages (including historical compose) resolve to the default agent (currently build, or cfg.default_agent) when the name is read back from history.
- Scope: message-read / resume paths only (
processorassistant agent,compactionparent agent,promptlastUser.agent/ resume / title-adjacent reads). ExplicitAgent.getfor a live request still errors withAgent not found. - Implementation: a small resolve helper used at those call sites —
agents.get(name)then fall back toagents.defaultAgent()when lookup is empty. Do not changeAgent.getitself. promoteComposeProtocolFirst/COMPOSE_REMINDER_MARKERstay so historical Compose synthetic protocol parts still order correctly when a legacy session is reopened.
S2.7 Documentation surface
Rewrite every user-facing mention of the Compose agent / Compose workflow / compose:* skills:
| Surface | Change |
|---|---|
README.md / README.zh.md |
Agents table drops compose; Compose Mode section becomes a direct /compose-next usage intro (end-to-end delivery, no Fable/Sol tier framing, no "must use build", end with "do not combine with plan mode"); workflow table drops the compose row; mode-lock wording becomes "Build and Plan switch anytime; other modes lock after the first message" |
README_npm.md |
Feature bullets: agents list and Compose Mode / Workflows rows |
mimocode-docs skill (SKILL.md, reference/guide.md, reference/workflows.md, reference/commands.md, reference/config.md) |
Same retirement: no compose agent path, no compose workflow, no compose config row, no MIMOCODE_DISABLE_COMPOSE_SKILLS; compose-next described as end-to-end delivery entry without model-tier definitions |
docs/compose/spec/* historical feature docs |
Do not rewrite history |
.agents/skills/drive-mimo/SKILL.md |
Drop --agent compose row if present |
[S3] Out of Scope
- Renaming
docs/compose/spec/or changing compose-next's phase contract. - Desktop (mimo-desktop) UI changes.
- Removing
promoteComposeProtocolFirstor migrating historical message rows. - Workflow runtime, other built-in workflows, or user
.mimocode/workflows/. - Plan agent / plan mode.
Tasks
- T1: Remove Compose agent + TUI mode special cases +
compose.txtprompt injection — acceptance:Agent.list()has nocompose; build/plan no longer carrycompose:*rules; TUI has no compose branch;bun test packages/cli/test/agentpasses (covers: S2.1; S2.6) - T2: Remove Compose workflow builtin +
workflow.tsdocs-dir special case — acceptance:BuiltinWorkflowhas nocomposeentry;bun test packages/cli/test/workflowpasses (covers: S2.2) - T3: Delete
skill/compose/bundle + extraction/search/alias/flag hooks — acceptance: nocompose:*skills load;MIMOCODE_DISABLE_COMPOSE_SKILLSgone;bun test packages/cli/test/skillpasses (covers: S2.3) - T4: Delete
ConfigComposeandconfig.composefield — acceptance: config schema has nocomposekey; typecheck clean (covers: S2.4) - T5: Tips carousel + i18n copy — acceptance:
compose_nextis inbuildTipKeys;pickDisplayKeyhas no compose lock; copy namescompose-nextand reads as capability in all 7 locales;bun test packages/cli/test/cli/cmd/tui/tips-view.test.tspasses (covers: S2.5) - T6: Historical unknown-agent fallback on message-read paths — acceptance: a session whose
info.agentiscompose(or any unknown name) resumes on the default agent withoutAgent not found; explicit missing agent on a live request still errors; focused test covers both (covers: S2.6; depends: T1) - T7: Docs surface (README×3 + mimocode-docs) — acceptance:
rg -i 'compose agent|compose workflow|compose:\*|Compose mode'on those files is clean of retired meanings; compose-next described positively as orchestration (covers: S2.7; depends: T1–T5) - T8: Full verification + review — acceptance:
bun test packages/cli,bun typecheck, targeted suite green; independent reviewer signs off (covers: S2; depends: T1–T7)
Compose Spec Sidebar Shrink And Press Gate (`docs/compose/spec/sidebar-shrink-and-press-gate.md`)
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 behindui/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/cli 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 throwawaytestRender 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
spyOns looked like the obvious
cause of the pre-existing failure until reading the file showedmockRestore()in afinally; 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 -rnis--replace, not recursive — it silently rewrote match output mid-investigation
and briefly madebuiltin.tslook 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 persistedsidebar: "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 resetsidebarOpen 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 ononMouseUp 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 deliveringdrag-end/up to the captured renderable, because the captured-up branch has noreturn before the generic dispatch at the end of handleMouseEvent. Dragging the
transcript scrollbar — which becomes the captured renderable via SliderRenderable'sonMouseDown/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
uptwice (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 withlastOverRenderable !== 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 toSidebarPreference = "auto" | "show" | "hide" and all logic moves into two pure
functions in routes/session/sidebar-state.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 tosetSidebar(() => 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_togglecommand 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.
onMouseDownarms only if the press coordinates fall inside the element's rect.onMouseOutandonMouseOverdisarm only when the event's new position is outside the
rect;onMouseDragdisarms when the drag lands outside;onMouseDropdisarms because adropmeans a drag captured elsewhere ended here.onMouseUpreturns early when unarmed, disarms before anything else (the duplicateup
delivered inside a captured renderable is then inert), rejects releases carryingisDragging(opentui sets that only on its two selection dispatches, so it identifies a
release closing a text selection — which never gets a precedingdrop), and re-checks
the release coordinates against the rect.onMouseOver/onMouseOutalso drive the returnedhoveraccessor, because the gate
must ownonMouseOutand 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 <text>).
Otherwise the element's own press starts a text selection, every release arrives withisDragging, and the control is silently dead. Stated in the exported doc comment.
Bounds come from the renderable captured through ref; Renderable exposes absolutex/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
onMouseUphandlers to the press gate. Not a backlog item: see
[S2.4] — most controls should keep plainonMouseUp. - Subtracting the toggle button's 3 columns from
contentWidth— a pre-existing
discrepancy; changing it would reflow every transcript. - Patching the upstream
@opentui/coredispatch bug. - The pre-existing
test/cli/tui/thread.test.tsfailure and the workflow-builtin.js
load error it surfaces when the suite runs as a batch.
Tasks
- T1: Replace the two-signal sidebar state with
sidebarVisibleFor/sidebarToggleinroutes/session/sidebar-state.tsand wire both toggle sites — acceptance: a wide collapse/expand round-trip normalises toautoso a later shrink hides the sidebar; an explicit narrow expand persists (covers: S2.1) - T2: Add
createPressinui/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) - T3: Raise the toggle above the overlay, share
SIDEBAR_WIDTH, clamp the sidebar, and adoptcreatePressin the toggle and the voice control — acceptance: the collapse glyph renders and is clickable with the overlay up;contentWidthstays positive at every reachable width (covers: S2.2; S2.4; depends: T2) - T4: Regression tests plus typecheck — acceptance:
testRender+mockMouseproves the captured-drag mis-fire is gated out, the state model is covered by pure tests, andbun typecheckpasses (covers: S2.1; S2.3; depends: T1, T3) - T5: Gate the control and the
sidebar_togglecommand onsidebarAllowed()— acceptance: a subagent view offers no sidebar affordance and cannot write the preference (covers: S2.2) - 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)
Compose Spec Skill External Root Defaults (`docs/compose/spec/skill-external-root-defaults.md`)
feature: skill-external-root-defaults status: delivered updated: 2026-09-20 branch: feat/skill-external-root-defaults commits: 895ae523..9797f74f
Skill External Root Defaults
Report
What was built — External skill discovery defaults to mimocode +
open-standard .agents. Brand roots (.claude / .codex / .opencode) are
opt-in via MIMOCODE_ENABLE_*_SKILLS; agents turns off withMIMOCODE_DISABLE_AGENTS_SKILLS. External scans no longer match dotted path
segments, so host-private namespaces such as Codex skills/.system never
enter the catalog. MIMOCODE_DISABLE_EXTERNAL_SKILLS and brandMIMOCODE_DISABLE_*_SKILLS skill gates are gone; MIMO_ONLY /MIMOCODE_DISABLE_CLAUDE_CODE no longer affect skill roots. Same-name clashes
are layered last-wins (home brands → project brands → mimocode → paths/urls),
with .agents last among brands in each home/project brand pass.
Verification — bun typecheck (packages/cli) PASS;bun test test/skill 91 pass / 0 fail (and 23 pass after the agents-order
change on the two focused files); related tool/prompt skill suites 21 pass /
3 skip (pre-existing); test/agent/agent.test.ts 52 pass. Independent review
follow-ups: ENABLE_OPENCODE coverage, layered clash documented in S2.3b,
misleading test name fixed; retired keys stay out of shipped docs.
Journey log
- Codex
skills/.systemis a SYSTEM install cache (marker.codex-system-skills.marker), not user skills —dot: falseis enough;
no marker special-case. - Flat
agents > all brandswould break project-over-home layering; keep
layered scope and document it instead of forcing a total order. - Shipped comments/README must describe live behavior only; retired env
names belong in the PR body, not the product surface.
[S1] Problem
External skill discovery treats every SKILL.md under a brand root as a user
skill. Codex Desktop/CLI installs SYSTEM skills into$CODEX_HOME/skills/.system/ (marker: .codex-system-skills.marker). Those
bodies are Codex-private (imagegen CLI, OpenAI docs self-knowledge, Codex
plugin/skill installers). MiMoCode scans ~/.codex withskills/**/SKILL.md and dot: true, so .system/*/SKILL.md enters the
catalog whenever Codex compatibility is on.
Harm: polluted available_skills, name collisions with bundled skills
(imagegen, skill-creator), and instructions that point at Codex-only tools.
Two structural defects behind that:
dot: trueon external scans — any dotted segment underskills/
(.system,.trash,.hidden) is treated as a skill container.- All brand roots default-on —
.claude/.codex/.opencodeare
scanned unless an env kill-switch is set. OpenCode's fork-era*_DISABLE_CLAUDE_CODE*/MIMOCODE_MIMO_ONLYgates are a blunt Claude
inheritance switch, not a skill-root policy. Desktop already defaults to
open-standardagentsonly viaskillPathCompat, but the engine default
does not match, and enabling a brand root still pulls that brand's private
namespaces.
Claude Code has the same class of reserved locations under .claude/skills/
(synced/, .trash/). Out of scope here except that dot: false also stops.trash from being loaded.
[S2] Design
S2.1 External scan never matches dotted path segments
Keep EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md" (nested skills stay
discoverable). Drop dot: true from the two external scan call sites inpackages/cli/src/skill/index.ts (global home roots and project up()
roots). Native patterns (MIMOCODE_SKILL_PATTERN, SKILL_PATTERN,BUILTIN_SKILL_PATTERN) already omit dot and stay unchanged.
Effect: ~/.codex/skills/.system/** and ~/.claude/skills/.trash/** never
match. Claude's reserved name synced is not special-cased (accepted
leak; non-dotted).
S2.2 Default root set = mimocode + agents; brand roots are opt-in
| Root | Default | Control |
|---|---|---|
.mimocode/skill(s), config skills.paths / skills.urls, builtin & compose bundles |
on | existing dedicated flags only |
.agents/skills (home + project up) |
on | MIMOCODE_DISABLE_AGENTS_SKILLS |
.claude/skills |
off | MIMOCODE_ENABLE_CLAUDE_CODE_SKILLS |
.codex/skills |
off | MIMOCODE_ENABLE_CODEX_SKILLS |
.opencode/skills |
off | MIMOCODE_ENABLE_OPENCODE_SKILLS |
Enabling Codex compatibility loads user skills under ~/.codex/skills/<name>/
only; .system remains invisible because of S2.1.
S2.3 Env contract (Active only in shipped docs)
User-facing controls are only these four envs. Shipped comments, README, and
mimocode-docs describe live behavior only — they do not list retired key names.
| Env | Default | Meaning |
|---|---|---|
MIMOCODE_DISABLE_AGENTS_SKILLS |
unset = agents on | Turn off the open-standard .agents root |
MIMOCODE_ENABLE_CLAUDE_CODE_SKILLS |
unset = off | Opt in .claude/skills |
MIMOCODE_ENABLE_CODEX_SKILLS |
unset = off | Opt in .codex/skills |
MIMOCODE_ENABLE_OPENCODE_SKILLS |
unset = off | Opt in .opencode/skills |
MIMOCODE_DISABLE_EXTERNAL_SKILLS and brand MIMOCODE_DISABLE_*_SKILLS keys
are no longer part of the skill-root surface (changelog / PR description only).MIMOCODE_MIMO_ONLY / MIMOCODE_DISABLE_CLAUDE_CODE still govern Claude
prompt inheritance and provider env detection; they are not skill-root controls.
Predicate (sole source of truth for EXTERNAL_DIRS filtering):
scan .agents ⇔ ¬ DISABLE_AGENTS_SKILLS
scan .claude ⇔ ENABLE_CLAUDE_CODE_SKILLS
scan .codex ⇔ ENABLE_CODEX_SKILLS
scan .opencode ⇔ ENABLE_OPENCODE_SKILLSImplementation note: Flag exposes lazy getters for the four keys above.
Remove the outer if (!Flag.MIMOCODE_DISABLE_EXTERNAL_SKILLS) guard around
external discovery. MIMOCODE_SKILL_PATTERN keeps accepting skill andskills under .mimocode/ for compatibility; shipped prompts may mention
either — do not treat path spelling as this feature's contract.
S2.3b Same-name clash order (layered, last non-bundled wins)
add() lets a later non-bundled match overwrite an earlier one; bundled never
overwrites non-bundled. Discovery is layered by scope, not a single flat
authority chain:
- builtin bundle → compose bundle
- home brand roots in
EXTERNAL_DIRSorder[.claude, .codex, .opencode, .agents](agents last → wins among home brands) - project brand roots via
fsys.up(targets in the same order per ancestor;
parent ancestors load after nearer dirs) .mimocode/ config skill dirsskills.paths, thenskills.urls
Consequences (accepted):
- Within one scope,
.agentsbeats brand copies of the same name. - A project brand skill can still beat a home
.agentsskill (more
specific scope). Do not claim a total orderagents > all brandsacross
scopes. .mimocodeand explicitskills.paths/urlsbeat every external root.
S2.4 Desktop alignment (`mimo-desktop`)
SkillPathCompat default is already{ agents: true, claude: false, codex: false, opencode: false } — identical
to the new engine default. engineSkillScanEnvFromCompat injects only deltas
from that default and stops emitting deprecated keys:
const env: Record<string, string> = {}
if (!compat.agents) env.MIMOCODE_DISABLE_AGENTS_SKILLS = "true"
if (compat.claude) env.MIMOCODE_ENABLE_CLAUDE_CODE_SKILLS = "true"
if (compat.codex) env.MIMOCODE_ENABLE_CODEX_SKILLS = "true"
if (compat.opencode) env.MIMOCODE_ENABLE_OPENCODE_SKILLS = "true"
return env| Desktop prefs | Injected env | Engine scan set |
|---|---|---|
| default | {} |
mimocode + agents |
| all off | { MIMOCODE_DISABLE_AGENTS_SKILLS: "true" } |
mimocode/config/bundle only |
| agents + codex | { MIMOCODE_ENABLE_CODEX_SKILLS: "true" } |
+ .codex user skills (no .system) |
Desktop enumeration / import / same-name checks keep reading the sameskillPathCompat prefs. Any Desktop-side filesystem scan of brand roots must
also use non-dot matching so lists and engine agree.
Write authority stays the mimocode skills root; brand roots remain read-only
compatibility (existing skill-path-compat contract).
S2.5 Docs
README*.mdenv tables andmimocode-docsreference/config.md: Active
keys only plus the non-dot scan rule and default load surface. Do not list
retired key names in shipped docs (PR / changelog only).- State migration for TUI/CLI users who relied on unset-env scanning of
~/.claudeor~/.codex(in the PR body; optional one-line README note).
[S3] Out of Scope
- Claude reserved name
synced(any capitalization). - Codex
.codex-system-skills.markerspecial-case (covered bydot: false). - Deleting
MIMOCODE_MIMO_ONLY/MIMOCODE_DISABLE_CLAUDE_CODE*from
prompt / MCP / commands / provider-env surfaces (separate feature if wanted). .skillignore/ config exclusion lists.- Changing
skills/**to single-levelskills/*. - Writing into or relocating Codex's
.systemcache. - OpenCode upstream changes.
Tasks
- T1: External skill scans use non-dot glob matching — acceptance: with a
fixture~/.codex/skills/.system/x/SKILL.mdand~/.codex/skills/user-skill/SKILL.mdplusMIMOCODE_ENABLE_CODEX_SKILLS=true, discovery lists onlyuser-skill;~/.claude/skills/.trash/y/SKILL.mdis likewise invisible when claude is
enabled. (covers: S2.1) - T2: Flip engine external-root defaults and replace env gates —
acceptance: default discovery includes.agentsand excludes.claude/.codex/.opencode; eachMIMOCODE_ENABLE_*_SKILLSopts in only
its root;MIMOCODE_DISABLE_AGENTS_SKILLSdrops agents; settingMIMOCODE_DISABLE_EXTERNAL_SKILLSor brandMIMOCODE_DISABLE_*_SKILLS
does not change the predicate;MIMOCODE_MIMO_ONLY/MIMOCODE_DISABLE_CLAUDE_CODEdo not affect skill roots. (covers: S2.2;
S2.3; depends: T1) - T3: Update engine skill tests and env hygiene — acceptance:
test/skill
covers default surface (no brand without opt-in), each opt-in includingENABLE_OPENCODE_SKILLS, agents disable, dotted-dir negative, and existing
brand-discovery cases pass withENABLE_*set where they expect brand roots.
(covers: S2.1; S2.2; S2.3; depends: T2) - T4: Align Desktop
engineSkillScanEnvFromCompatand related unit tests —
acceptance: default prefs inject{}; open brand injects only itsMIMOCODE_ENABLE_*_SKILLS; all-off injects onlyMIMOCODE_DISABLE_AGENTS_SKILLS; injected keys are exactly the four Active
keys; unit matrix matches S2.4. (covers: S2.4; depends: T2) - T5: Document Active env surface — acceptance: README and mimocode-docs
list only the four Active keys as controls, state default load surface =
mimocode + agents with brand roots opt-in, and do not list retired key names
in shipped docs. (covers: S2.5; depends: T2)
Compose Spec Skill Invocation Control (`docs/compose/spec/skill-invocation-control.md`)
feature: skill-invocation-control status: delivered updated: 2026-07-31 branch: feat/skill-invocation-control commits: 6674db7a..6236515e
Skill Invocation Control
Compose Next update (2026-08-12): The
disable-model-invocationdesign in this document remains the current contract for skills that set the field: models cannot discover or invoke them, while users can still invoke them with/name. Compose Next has since stopped setting this field, so it is model-visible and model-invocable; its own description and body govern when the model should choose that workflow.
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 fromskill_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 deadSkill.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/cli unless noted:
bun typecheck(packages/cli) — 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.tswas
confirmed to FAIL on the base commit with the intended symptom: withsrc/
stashed, the gated skill appeared in the model's catalog
(<name>skill-gated</name>present inavailable_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 straypackages/sdk/js/openapi.jsonbuild artifact
committed by accident) and one correctness nit (the not-found hint duplicating
the reachability predicate overall()), both fixed in6236515e.
Journey log
- The bug was reproduced in the authoring session itself:
/compose-next
delivered no<skill_content>block and no error.git log -Lon the
mention scan pinned the regression to4e2a3cb6, which swappedsys.all()
forsys.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. - The first design kept
denyas the hiding mechanism and special-cased the
user path. Rejected after reading Claude Code's frontmatter reference: the
upstream standard already splits this intodisable-model-invocationanduser-invocable, which named the actual defect — one rule serving two
questions — rather than patching its symptom. user-invocable: falsewas 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.- An earlier draft kept a
disable-model-invocationskill 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". git add -Aafter a failed SDK generation committed a 16,934-line scratch
file.git statusbefore 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 <skill_content name="compose-next"> 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). Since4e2a3cb6 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.denymeans 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 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. Adisable-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/skillautocomplete,
so adisable-model-invocationskill 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 ininsertReminderskeeps using it, so
a user slash invocation is blocked bydenyand by nothing else.modelInvocable(agent?)— new.available(agent)minusdisable_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'sskillruleset
(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: trueinskill/builtin/.bundle/compose-next/SKILL.md. - Add the behavioural rule in two places, so it survives a future flag flip:
indescription, 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-nextfromisComposeSkillinskill/search.ts:20-22. Its
exclusion from search is now carried by the field at the caller, and the
helper goes back to meaning onlystartsWith("compose:"). mimocode-docsrecords that/compose-nextis 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. Before4e2a3cb6it could (PR #1716); since4e2a3cb6it cannot.
This design keeps the current behaviour and makes it the documented rule:denymeans unusable. Concretely,/compose:brainstormfrom Build stays
inert; it works from the Compose agent, which allowscompose:*. - The model can no longer invoke
compose-nextby guessing its name.compose-next.mdS2 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-agentpermission.skillremains the only config-side control. - Migrating
compose:*offpermission.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 = 3budget, 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 atskill/builtin/extract.ts:75) recommends document skills to the model from a
hardcoded list, consulting neitheravailablenormodelInvocable. 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_searchdescribe block intest/tool/skill-search.test.tsisit.live.skipped onmain, 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.tsremains broken (see T8). Fixing the__schema0hoisting forToolStateCompleted.providerOutputis a separate
change; until then the generated SDK drifts from the API on every schema
edit, andproviderOutputitself is still missing fromtypes.gen.ts.
Tasks
- T1: Replace the dead
hiddenfield onSkill.Infowithdisable_model_invocation, parsed from the kebab-casedisable-model-invocationfrontmatter key inskill/index.ts— acceptance:
a SKILL.md withdisable-model-invocation: trueloads withdisable_model_invocation === true; one without it loadsundefined; no
reference toInfo.hiddenremains insrc(covers: S2) - 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, leavingSystemPrompt.availableand
theprompt.ts:864mention scan onavailable— acceptance: adisable-model-invocationskill is absent from the system-prompt catalog,
theskilltool description, andskill_searchresults, whileSkill.availableandSkill.allstill return it (covers: S2; depends: T1) - T3: Refuse
disable_model_invocationskills intool/skill.tsbeforectx.ask, and filter the not-found "Available skills" hint by the same
predicate — acceptance:skill({name})on such a skill throws an error
namingdisable-model-invocationand 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) - T4: Graduate
compose-next: delete"compose-next": "deny"fromagent/agent.ts, setdisable-model-invocation: truein its SKILL.md,
add the "only on explicit user invocation" rule to both itsdescription
and body, and dropcompose-nextfromisComposeSkillinskill/search.ts— acceptance:Permission.evaluate("skill", "compose-next", defaultAgentRules)isallow;compose:*stilldenyon
the default agent andallowon Compose;searchSkillsno longer
special-cases the name (covers: S2; depends: T1) - T5: Add a regression test that a user slash invocation of a
disable-model-invocationskill injects its body, following the real-layer
harness intest/session/prompt-skill-command-multi.test.ts— acceptance:
the test fails on the base commit (no<skill_content>part for the
invoked skill) and passes after T1-T4 (covers: S1, S2; depends: T2) - 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 plusmodelInvocablefiltering —
acceptance:bun test test/skill test/tool test/permission test/session
shows no failures attributable to this change (covers: S2; depends: T4) - T7: Record in
mimocode-docsthat/compose-nextis user-invocable only
and the model must not start it — acceptance: the skill states both facts
where it already documents/compose-next(covers: S2) - T8: Bring the published
AppSkillsResponsestype inpackages/sdk/js/src/v2/gen/types.gen.tsin line with the newSkill.Infoshape — acceptance: the skills response type carriesdisable_model_invocation?: booleanand nohidden?: boolean../packages/sdk/js/script/build.tscannot be used: it has failed sincefc74c539(2026-07-26) becauseToolStateCompleted.providerOutput
serializes to a dangling$ref: #/components/schemas/__schema0, and the
committed types.gen.ts still has noproviderOutput, 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)
Compose Spec Skill Multi Injection (`docs/compose/spec/skill-multi-injection.md`)
feature: skill-multi-injection status: delivered updated: 2026-07-27 branch: fix/skill-multi-injection commits: 014a2577..5e010a3c
Skill Multi-Injection
Report
What was built — The cmd.source === "skill" non-subtask branch ofSessionPrompt.command no longer emits a <skill_content> part. Skill bodies
now have exactly one injector: the mention scan in insertReminders, which
detects the invoked skill from the leading /${input.command} token of the
visible text part. insertReminders itself is unchanged — its alreadyWrapped
guard keeps its real job (stopping step 2+ from restacking step 1's blocks) and
loses only its obsolete job (avoiding a double wrap with the command path).
Because both sources of skills now flow into one mentioned list, theMAX_AUTOLOAD = 3 budget and the mentioned.length >= 2 orchestration reminder
count them uniformly, with no reconciliation logic. /a ... /b injects both
bodies plus the orchestration reminder; four or more mentions overflow to the
Skill-tool hint as before.
Test hygiene was fixed alongside: five skill-related test files were writingMIMOCODE_DISABLE_*_SKILLS at module scope, leaking into every file scheduled
later in the same bun test process. They now share a withEnv helper
(test/lib/env.ts) that applies flags in beforeAll and restores them inafterAll.
Verification — all from packages/cli:
bun typecheck— PASS (clean).bun test test/session/prompt-skill-command-multi.test.ts— 2 pass. Confirmed
failing before the source change, with the intended symptom: onlyskill-alphainjected, noskill-betablock, no orchestration reminder.bun test test/skill test/tool/skill-search.test.ts test/tool/skill.test.ts test/session/prompt-skill-command-multi.test.ts test/session/prompt-skill-mention.test.ts test/command— 95 pass, 4 skip,
0 fail.bun test test/session test/tool— 1536 pass, 22 skip, 1 fail.SessionCheckpoint.insertRebuildBoundary … when rebuild context is emptyisPRE-EXISTING: the same suite on base014a2577fails it too, plus an extrasnapshot raceflake. Both pass in isolation, so they are suite-order flakes
unrelated to skills.- Independent review by a fresh subagent: no critical findings; spec compliance,
correctness and consistency all cleared.
The change is delivered on fix/skill-multi-injection as 5e010a3c; this
document is committed separately, outside the reviewed range.
Journey log
- The bug was reproduced live in the authoring session itself: a message of the
form/compose-next … /mimocode-docs …delivered only the first skill's body.
The agent's own context was the evidence. - The first design considered turning
alreadyWrappedinto a per-name set so
both injectors could coexist. Rejected: it keeps two injectors in sync by
hand, and theMAX_AUTOLOADbudget plus the orchestration-reminder count both
have to be reconciled manually — exactly the drift that caused the bug. - What made the smaller fix safe was
prompt.ts:4182-4183: skills already skip
the$1..$N/$ARGUMENTStemplate machinery, and the command path wrapped
the rawtemplateCommand, byte-identical to theinfo.contentthe scan
injects. Deleting the wrap therefore loses nothing. Zero of the 22 bundled
skills use$ARGUMENTSor!`bash`. - Do not delete the whole branch: the
visibleTextpart must stay, because the
scan's only handle on the invoked skill is its leading/nametoken. - Injection correctness now depends on a regex over rendered text rather than a
registry lookup, so non-slug skill names (skill/index.ts:30accepts any
string) silently inject nothing. Accepted by explicit user decision — skills
are expected to use standard slugs.
[S1] Problem
When a user message begins with a skill slash-invocation and mentions a second
skill later in the same message, only the first skill's body is injected. The
second one survives as literal text and the model never sees its SKILL.md.
Reproduction: send /compose-next 分析一下 ... /mimocode-docs 例如 .... The
model receives one <skill_content name="compose-next"> block, no<skill_content name="mimocode-docs"> block, and no multi-skill orchestration
reminder.
Root cause is two independent injection points:
Command path.
packages/cli/src/cli/cmd/tui/component/prompt/index.tsx:1169-1177
routes any input starting with/by resolving only the first token of the
first line as a command name. Skills are registered as commands
(packages/cli/src/command/index.ts:264-276,source: "skill"), so/compose-nextmatches and everything after it — including/mimocode-docs— becomesarguments. Server-side,packages/cli/src/session/prompt.ts:4259-4269emits a visible text part
plus one<skill_content>part forinput.commandonly.Free-text scan.
packages/cli/src/session/prompt.ts:727-796scans
the message body for all skill mentions and handles multiple skills
correctly (global regex atprompt.ts:739, dedupe,MAX_AUTOLOADcap,
orchestration reminder atprompt.ts:766).
The scan is gated by alreadyWrapped (prompt.ts:724-727), a message-level
boolean that is true as soon as any part starts with <skill_content name=".
The command path already pushed such a part, so the entire scan is skipped.
The guard's granularity is the whole message, not the individual skill.
The two paths inject equivalent content, so the split serves no purpose:prompt.ts:4182-4183 makes skills skip the $1..$N / $ARGUMENTS template
machinery entirely (template = input.arguments), and prompt.ts:4265 wraps
the raw templateCommand, which is item.content
(command/index.ts:271-273) — byte-identical to the info.content the scan
injects at prompt.ts:760.
[S2] Design
Give skill-body injection a single owner: the free-text scan ininsertReminders. The command path keeps alias resolution, argument expansion,
attachment resolution, and the visible text, but stops injecting the body.
Command path contract — prompt.ts:4259-4269, non-subtaskcmd.source === "skill" branch:
- Emit
parts = [visibleText, ...attachments, ...input.parts]. Drop theskillPart. visibleTextkeeps its current form and must keep/${input.command}as the
leading token. This token is the detection signal for the scan.input.commandis the canonical skill name — the TUI resolves localized
slash aliases to it client-side viaresolveSkillSlash
(cli/cmd/tui/i18n/skill.ts:19-27, used atindex.tsx:1174) — so a
localized invocation such as/深度研究arrives as/deep-researchand
matches the scan regex.- The subtask branch (
prompt.ts:4245-4258) is unchanged. It inlines skill
content into a subtask prompt and never had the multi-skill defect. templateCommand(prompt.ts:4179) stays — the subtask branch still reads
it atprompt.ts:4247.
Scan contract — prompt.ts:720-797 requires no change:
alreadyWrappedloses its stated purpose (the command path no longer wraps)
but retains its actual load-bearing role: cross-step idempotency.insertRemindersruns on every step (prompt.ts:3387) andupdatePart
persists, so the parts injected at step 1 make the guard true at step 2+ and
prevent restacking. This is the same mechanism documented atprompt.ts:819.- The command-invoked skill and any text-mentioned skills now enter one
mentionedlist, soMAX_AUTOLOADand thementioned.length >= 2
orchestration reminder count them uniformly. No separate reconciliation
logic is needed.
Accepted behavior changes:
- The command-invoked skill counts against
MAX_AUTOLOAD = 3
(prompt.ts:749). It appears first in the message text and the scan
preserves text order, so it is never the one dropped; a fourth mention
overflows to the Skill-tool hint instead. /a ... /bnow injects the orchestration reminder
(prompt.ts:778-790), which it previously suppressed.- Skill bodies move from "immediately after the visible text, before
attachments" to "appended touserMessage.partsduringinsertReminders",
and are created during the prompt loop rather than at message construction. - Injection becomes turn-scoped rather than persisted at message construction.
If a turn dies between persisting the user message and the firstinsertReminderspass (cancel, agent-resolution failure), the message keeps
its/skill-atext with no body, and no later turn will backfill it —insertRemindersonly ever targets the last user message (prompt.ts:656). - The
command.execute.beforeplugin hook (prompt.ts:4275-4279) no longer sees
a<skill_content>part inpartsfor skill invocations, only the visible
text and attachments. No in-repo plugin reads it; external plugins that did
will observe the change.
Test isolation: Bun runs every file of a bun test invocation in one
process, so a module-level process.env write leaks into files scheduled later.
Skill tests that force MIMOCODE_DISABLE_*_SKILLS must set the flag inbeforeAll and restore it in afterAll — the flags are lazy getters
(flag/flag.ts:279-287), so scoping them this way works.
Skill naming assumption: skill names are standard slugs matching the scan
regex [A-Za-z][A-Za-z0-9_:-]*. Colon-namespaced names such as compose:ask
are covered.
Set equivalence: the command registry enumerates via Skill.all()
(skill/index.ts:298-301) and the scan via the same all(), neither applyinghidden or permission filtering (that is available(),skill/index.ts:307-314). Every command-invokable skill is therefore
scan-resolvable.
[S3] Out of Scope
- Validating or normalizing skill names.
skill/index.ts:30accepts any
string; enforcing slugs is a separate change. Non-slug names (CJK, digit-led)
are not supported by this design. - Changing the mention regex at
prompt.ts:739. - Tuning
MAX_AUTOLOAD. - The TUI leading-slash routing at
index.tsx:1169-1177and the ACP equivalent
atacp/agent.ts:1372-1383. Both are unchanged and inherit the fix, since it
is server-side. - Skill invocation dispatched as a subtask.
Tasks
- T1: Add a failing end-to-end regression test that invokes a skill via
SessionPrompt.commandwith a second skill mentioned in the arguments,
following the real-layer harness inpackages/cli/test/session/plan-reminder-dedup.test.ts(liveSessionPrompt+Sessionlayers, tmpdir fixture, stubbed SSE stream) —
acceptance: the test runs and fails against the base commit because the
user message carries only<skill_content name="skill-a">, with noskill-bblock and no orchestration reminder (covers: S2) - T2: Drop the
skillPartfrom the non-subtaskcmd.source === "skill"
branch inpackages/cli/src/session/prompt.ts:4259-4269, keeping the
visible text and attachments — acceptance: T1 passes; a lone/skill-a
still yields exactly one<skill_content name="skill-a">part;/skill-a argsstill carries the argument text and any resolved attachment
(covers: S2; depends: T1) - T3: Fix the stale source reference in
packages/cli/test/session/prompt-skill-mention.test.ts:3, which
citessrc/session/prompt.ts:684for a regex that has since moved —
acceptance: the comment points at thementionResymbol rather than a
line number, so it cannot rot again, and the existing regex tests still
pass (covers: S2) - T4: Scope the
MIMOCODE_DISABLE_*_SKILLSenv writes in the skill tests so
they no longer leak across files in a sharedbun testprocess, via a
singlewithEnvhelper — acceptance:test/skill/skill.test.ts,test/skill/loop.test.ts,test/skill/bundle-discovery.test.ts,test/tool/skill-search.test.tsand the new multi-injection test all set
their flags throughwithEnvand restore the prior values, andbun test test/session test/toolplusbun test test/skillshow no new
failures (covers: S2)
Compose Spec Synthetic Parts Persistence Cache (`docs/compose/spec/synthetic-parts-persistence-cache.md`)
feature: synthetic-parts-persistence-cache status: delivered updated: 2026-09-16 branch: analyze/recall-unpersisted-push-cache commits: b4cc11cd652195af9a80297ed543218f3172e6c4..73003a620907031d67de979ce357310e10441223
Synthetic Parts Unpersistence and Prompt-Cache Break
User selected Option A (persist + marker dedupe). Mid-turn p.text wrap stays request-only by design.
Report
What was built — Unpersisted user-side synthetic injections in session/prompt.ts now follow the harness persist-once contract.
- Recall reminder —
ensurePersistedUserSynthetic+RECALL_REMINDER_MARKER;hasMemoryOrTasksonly consulted when the marker is absent. - Loop-streak nudge — same helper +
LOOP_STREAK_REMINDER_MARKER. - Compose prompt — persist +
COMPOSE_REMINDER_MARKER; durable order viaMessageV2.promoteComposeProtocolFirston hydrate/parts load (shared by runLoop, fork capture, trajectory). In-memoryposition: "head"only aligns the same request before the first reload. - Exported pure helpers: markers,
hasSyntheticReminder,buildRecallReminderText,buildLoopStreakReminderText;COMPOSE_REMINDER_MARKERowned bymessage-v2.ts, re-exported fromprompt.tsfor tests.
Multi-step runLoop reloads msgs from DB; marker hit skips re-push → last-user tail order stays stable. Compose head is a load-time invariant (not a per-request compensating projection), so checkpoint-writer ForkContext matches the parent request prefix.
Verification —
| Command | Result |
|---|---|
bun typecheck (filter: src/session/prompt.ts, src/session/message-v2.ts, test/session/recall*) |
PASS — no errors in changed files |
bun test --timeout 20000 test/session/recall-reminder.test.ts test/session/recall-reminder-persist.test.ts test/session/plan-reminder-dedup.test.ts test/session/prompt-skill-command-multi.test.ts test/session/messages-pagination.test.ts test/session/message-v2.test.ts test/session/compose-reminder-persist.test.ts |
PASS |
| scope note | Engine persist/marker + hydrate-order invariant; no UI surface. Unit/integration receipts above are the DoD. |
Review residual (closed after CR r1) — Compose head was request-layer only. CR r1 marked that ⚠️. Fix: MessageV2.promoteComposeProtocolFirst runs on every hydrate/load (hydrate + parts()), so runLoop, checkpoint fork capture, and trajectory all see [compose, user, …]. DB storage remains PartID-asc; position is a load-time invariant, not a per-request compensating projection.
Journey log
- User screenshot line numbers
3840-3876match origin/main at analysis time (e93a49cd), not the stale main checkout. - Bare
parts.pushis half the user-side reminder contract —updatePart+ marker dedupe is the other half (skills / plan). - Cache break was order instability vs persisted
insertReminderssiblings + fork prefix DB reload, not volatile recall text. - Compose head is closed by
promoteComposeProtocolFirstat the MessageV2 load boundary — not by request-layer-only reorder. - Mid-turn
p.textwrap left request-only (intentional step≥2 steering); separate from Option A. - History search (
history/service.tsPartTable projection) does not promote compose order — non-LLM summary path, accepted boundary.
[S1] Problem
packages/cli/src/session/prompt.ts runLoop reloads history every step:
while (true) {
let msgs = yield* MessageV2.filterCompactedEffect(sessionID, { ... })
// ... inject synthetic content into msgs ...
msgs = yield* insertReminders({ messages: msgs, agent, model, session })
// ... serialize msgs into the LLM request ...
}Several synthetic injections parts.push(...) (or unshift) without sessions.updatePart(...). Injected bytes vanish on the next DB reload and reappear with a new PartID at a different index among later parts — mid-turn prompt-cache miss.
[S2] Design (mechanism + chosen contract)
Chosen contract (Option A): every durable user-side syntheti