MiMo-Code Documentation

MiMo Code: Where Models and Agents Co-Evolve

13,693 stars TypeScript TypeScript 60 files · ~126,127 tokens #ai#ai-agents#cli#mimo#mimo-code
RAW Doc

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

bash
# 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
mimo

The 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:

bash
sudo apt install xsel

macOS: rendering issues in the default terminal

MiMoCode does not support the built-in macOS Terminal (Terminal.app). If the interface is misaligned, flickers, or has other rendering issues, use iTerm2 or the VS Code integrated terminal instead:

bash
brew install --cask iterm2

TUI lag and visual animation issues

If the TUI lags when run directly over SSH, render it locally and run only the MiMoCode server on the remote host. Start the server from the remote project directory:

bash
# Remote host
mimo serve --port 4096

# Local host: create the SSH port forward
ssh -N -L 4096:127.0.0.1:4096 user@remote-host

# Local host: connect from another terminal
mimo attach http://127.0.0.1:4096

If decorative animation is causing the lag, run /vivid, or configure Vivid visuals in the ctrl+p command palette, to switch between Vivid and Minimal visuals as needed.

Windows: garbled CJK (Chinese/Japanese/Korean) output in the shell

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 (or compaction.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:

jsonc
{
  "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

bash
sudo apt install -y sox pulseaudio libasound2-plugins
export PULSE_SERVER=unix:/mnt/wslg/PulseServer

SSH remote audio (Mac → remote host)

bash
# 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 info

Non-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:

jsonc
{
  "voice": {
    "control_model": "openrouter/xiaomi/mimo-v2.6-flash"
  }
}

Internal / self-hosted relay (both ASR and voice control):

jsonc
{
  "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 with MIMOCODE_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
{
  "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:

jsonc
{
  "$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 baseURL and apiKey.
  • Preserve the base URL and model ID exactly as supplied. MiMoCode does not require a known provider and you should not add or remove /v1 unless the endpoint requires it.
  • The key under models is the upstream model ID. Model IDs containing / are supported because only the first / in model separates the provider ID from the model ID.
  • Replace custom with another unused lowercase provider ID if needed, and use the same ID in the top-level model value.
  • @ai-sdk/openai-compatible is 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 an
external_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:

json title=".mimocode/mimocode.json"
{
  "$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:

bash
# 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 mimo

This 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 leftover
ask 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

bash
bun ci                   # Install dependencies (= bun install --frozen-lockfile)
bun run dev              # Run in development mode
bun run typecheck          # Type check

Relationship 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:

bash
bun ci

To run:

bash
bun run index.ts

This 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 the Agent interface 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 the Client interface for client-side capabilities

    • File operations (readTextFile, writeTextFile)
    • Permission requests (auto-approves for now)
    • Terminal support (stub implementation)
  • 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

bash
# Start the ACP server in the current directory
opencode acp

# Start in a specific directory
opencode acp --cwd /path/to/project

Question Tool Opt-In

ACP excludes QuestionTool by default.

bash
MIMOCODE_ENABLE_QUESTION_TOOL=1 opencode acp

Enable this only for ACP clients that support interactive question prompts.

Programmatic

typescript
import { ACPServer } from "./acp/server"

await ACPServer.start()

Integration with Zed

Add to your Zed configuration (~/.config/zed/settings.json):

json
{
  "agent_servers": {
    "OpenCode": {
      "command": "opencode",
      "args": ["acp"]
    }
  }
}

Protocol Compliance

This implementation follows the ACP specification v1:

✅ Initialization

  • Proper initialize request/response with protocol version negotiation
  • Capability advertisement (agentCapabilities)
  • Authentication support (stub)

✅ Session Management

  • session/new - Create new conversation sessions
  • session/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

  1. Streaming Responses - Currently returns complete responses instead of streaming via session/update notifications
  2. Tool Call Reporting - Doesn't report tool execution progress
  3. Session Modes - No mode switching support yet
  4. Authentication - No actual auth implementation
  5. Terminal Support - Placeholder only
  6. Session Persistence - session/load doesn't restore actual conversation history

Future Enhancements

  • Real-time Streaming: Implement session/update notifications 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

bash
# 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 acp

Design 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:

ts
// 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 Bus abstraction
  • 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:

ts
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:

ts
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:

text
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, and data fields. A bus event has the type and properties fields. data and properties are 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:

ts
// 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:

ts
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.subscribe in our code to it. Then you are directly only working with sync events always.
    • Two big problems. First, Bus is 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
  • 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 convertSchema function to convert the event schema at runtime so we didn't need busSchema
    • 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.subscribe calls

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

text
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, so
uv run resolves dependencies automatically:

bash
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 check

If you don't use uv, install dependencies once:

bash
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:

python
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:

bash
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 check

Design goals

  1. No proprietary dependencies. All runtime dependencies are permissively
    licensed (python-docx MIT, lxml BSD-3-Clause). Optional external tools
    (LibreOffice, Poppler) are invoked as CLIs; nothing is bundled or linked.
  2. Standard library first. Scripts fall back to xml.etree and zipfile
    where practical, so text extraction and packing work in restricted
    environments that don't have python-docx installed.
  3. Small, self-contained scripts. Each script has one job, a docstring
    with usage examples at the top, and a main(argv) entry point you can
    inspect at a glance. No plugin systems, no shared framework code.
  4. Round-trippable. explode.py + assemble.py produce 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.py runs quick well-formedness
    checks and a python-docx round-trip; it does not validate against the
    formal schemas.
  • Not for .doc (legacy Word 97-2003), .docm (macro-enabled), or .rtf.
    Convert to .docx first 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: 0 OK, 1 failure, 2 usage/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

text
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 PDF

Start with SKILL.md — it has the routing matrix that points you at the
right sub-guide.

Quick start

Install the permissive Python-only path:

bash
python3 -m pip install --upgrade pypdf pdfplumber pypdfium2 reportlab Pillow

Add OCR and CLI helpers on demand:

bash
# macOS
brew install qpdf tesseract poppler
# Debian / Ubuntu
apt-get install -y qpdf tesseract-ocr poppler-utils

Skim any document in three commands:

bash
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 inspection

Combine three quarterly reports and encrypt:

bash
scripts/combine.py 2026Q1.pdf 2026Q2.pdf 2026Q3.pdf --out annual.pdf
qpdf --encrypt reader owner 256 --print=full -- annual.pdf annual-locked.pdf

Design goals

  1. 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.
  2. 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.
  3. 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).
  4. 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, or pypdfium2
    directly for that.
  • Not a schema validator. scripts/sanity_check.py is 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, or interactive) with a runnable example before the wrapper.
  • Exit codes: 0 OK, 1 runtime failure, 2 bad usage, 3 validation
    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 留一晚上,早上要能相信它的产出"的工作规程。核心不是具体流程,而是同一份纪律:

  1. 先立契约再动手 — 把目标、主输出、停止条件写下来,确认一次。之后就不再问。
  2. 基线先行 — 每种模式都有"我啥都没做时的答案"(原始代码 / 头三个搜索结果 / 未变换的原始数据)。先记录,否则"更好"和"显著"都无从谈起。
  3. 每一步都进机读日志,失败也写 — TSV(不是 CSV,因为描述里会有逗号)。悄悄丢掉失败的尝试是最快的自欺方式。
  4. 循环中不请示 — 契约定好后 agent 不停下来问"要不要继续"。人可能在睡觉。
  5. 不作弊 — 不改 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 里显式说:

text
「按 super-research 的 <mode> 模式,跑一晚,产物放 <dir>/」

典型的对话流

text
你 : 帮我在 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 分钟内验证工作、知道下一步看哪。不是讲故事。


目录结构

text
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:需要放到工作目录的 fixture
  • expectations:可编程验证的断言列表

跑评测的推荐路径:用 skill-creator skill(/skill-creator 或让 Claude 触发),它会:

  1. 对每个 case 起两个 subagent —— 一个带 skill、一个不带(baseline)
  2. 收集产物,跑断言,产出 benchmark.json 和 HTML 视图
  3. 让人点开对比 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 in LICENSE.

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:

  1. Copy this skill's LICENSE into your THIRD_PARTY_LICENSES/ or
    equivalent directory in your product.
  2. Add the license texts of openpyxl, pandas, lxml, xlsxwriter (if you
    depend on them) — they usually live in the LICENSE file of each installed
    package.
  3. If you install LibreOffice as part of your product, ship its LICENSE and
    NOTICE files too.
  4. 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

bash
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 libreoffice

Note on the overview.py name: the script is deliberately not named
inspect.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, run
scripts/bake.py after saving. LibreOffice does the recalculation in a
headless subprocess.

Non-goals

  • .xls (legacy Excel 97-2003). Convert to .xlsx first with LibreOffice:
    soffice --headless --convert-to xlsx old.xls.
  • VBA macros. This skill does not produce .xlsm files 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 prints status=error and 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. Prints score: X.YYYYYY (higher is better) and seed: N.
  • Component switches at the top: ENABLED_NORMALIZE, ENABLED_DEDUPE, ENABLED_WEIGHT_BOOST, ENABLED_SMOOTH, ENABLED_GUARD. Toggle each to False to ablate.
  • SEED is 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-1 is a working reference (all tests pass). commit-3 introduces a subtle regression (see below). commit-5 (HEAD) shows the symptom.
  • The repro: python solver.py 0 should print even but raises ValueError: unexpected parity result: 0. python test_solver.py exits 1 with one failing case (classify(0)).
  • Tests: python test_solver.py — plain assertions, no pytest dependency. Passes fully on commit-1 / commit-2, fails one case on commit-3 onward.

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). Prints val_loss: X.YYYYYY (lower is better) and peak_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). No context field on JSON schema or shell parser.
  • z.strictObject rejects any context key 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.spawn with context: "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 contextMode registry semantics or fork runLoop itself.

Tasks

  • T1: Remove context from actor tool schema, shell parser, execute path, and tool descriptions — acceptance: spawn/run schema rejects context; shell rejects --context; execute always uses context:"none"; descriptions omit model-facing context (covers: S2)
  • T2: Update/add unit tests — acceptance: shell rejects --context; schema rejects context; description tests do not require context="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.fn so no loader would try. It worked, and it was the
    wrong shape: the defect was that the scripts were reachable as modules, not that they were
    named .js. Renaming also took them out of oxlint and editor highlighting, and touched every
    reference to them. Asking what makes the failure impossible rather than unlikely gave a
    better answer than iterating on the first one that worked.
  • The macro form was already in this codebase twice for the same job. Searching for precedent
    before inventing an extension would have found it immediately.
  • Two Bun constraints only surfaced by running into them, and the published pattern in
    skill/builtin/extract.ts already encodes both — reading it properly instead of assuming its
    shape would have saved two failed builds.
  • An explicit list of the four filenames survived into the first macro version out of habit.
    Nothing outside this module references those filenames, so it was pure ceremony from the era
    when static imports forced it.

[S1] Problem

The four scripts in src/workflow/builtin/ are workflow function bodies, not modules: each
ends in a top-level return, because the sandbox evaluates them inside a function wrapper.
They were imported as raw text via with { type: "text" } so that they would embed into the
compiled binary, which has no source tree to read at runtime.

That left them reachable as modules, and under bun test one was occasionally loaded through
the ECMAScript parser instead of the text loader, where a top-level return is a syntax error:

text
# Unhandled error between tests
error: Top-level return cannot be used inside an ECMAScript module
    at .../src/workflow/builtin/fact-check.js:1:1

No assertion produced it. Bun counts the event once as a failure and once as an error, and one
test never starts, so a test file silently loses coverage. Reproducible with two files, in
either order, each of which passes alone:

text
bun test test/cli/tui/plugin-toggle.test.ts test/cli/tui/thread.test.ts

Continuous integration missed it because test.yml shards test files across four processes, so
the two files that collide are usually not in the same one.

Two measurements pinned it down. An onResolve hook showed the importer was always
builtin.ts itself — the legitimate text import, with no second importer anywhere — and that
thread.test.ts alone re-evaluates builtin.ts on the order of a hundred times in one
process, of which a handful took the ESM path. Three explanations were tried and falsified:
leaked test state (both files restore their spies), a static-plus-dynamic import race, and
cache exhaustion under concurrent re-imports; neither of the latter two reproduces standalone.

This reads as a Bun defect — a static import carrying with { type: "text" } should reach the
text loader every time — but no minimal standalone reproduction was isolated, so the trigger
for the re-evaluation remains unexplained.

[S2] Design

builtin.macro.ts reads builtin/*.js with fs.readdirSync / fs.readFileSync and returns
{ file, script }[]. builtin.ts consumes it through with { type: "macro" }, so the call is
evaluated at transpile and the sources are inlined as string literals. A file read at build time
is never in the module graph, whatever it is named, which is why this fixes the cause rather
than the symptom — and why the scripts keep their .js names, stay inside oxlint's coverage,
and need no changes anywhere else.

The directory is the registry, as it is for built-in skills in skill/builtin/bundle.macro.ts.
Nothing outside this module refers to the filenames; consumers look workflows up by meta.name,
which each script declares itself.

Three consequences follow from that, all accepted because the directory is curated. Losing a
script is no longer a boot failure — it simply stops registering, and callers get the existing
unknown-workflow error — so builtin.test.ts asserts the registered set to make a deletion
loud. A stray .js dropped there becomes a shipped workflow rather than being inert, and a
malformed meta in it fails app boot. Two scripts declaring the same meta.name silently
last-wins, unchanged from before.

Two Bun constraints shape the call site, both already encoded in the pattern
skill/builtin/extract.ts established:

  • Macros are not expanded in every transpile path. Under bun test the macro import is stripped
    without the call being replaced, surfacing as a ReferenceError, so the macro module is also
    imported normally and the macro form falls back to it. A try/catch is warranted here
    against the repository's general preference because a non-expanded macro is not otherwise
    detectable.
  • Macro arguments must be statically known. A per-filename signature would make a misspelled
    name a build error, but it cannot pass through the fallback wrapper — the argument stops being
    static and the build fails with Cannot convert identifier to JS.

[S3] Out of Scope

  • Reporting upstream. This removes the repository's exposure, not the loader behaviour. The
    reproduction and measurements are recorded above so a report can be assembled without
    repeating the work.
  • Why thread.test.ts re-evaluates builtin.ts a hundred times. It is the condition that made
    the collision likely and it presumably still holds.
  • Rejected: renaming to .js.fn or .txt (treats the extension as the defect, costs lint
    coverage; .txt would also collide with session/prompt/compose.txt), and making the scripts
    valid ESM (the top-level return is the sandbox contract that user-authored workflows depend
    on).

Tasks

  • 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 test loads the registry rather than throwing ReferenceError, and bun typecheck passes 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.catch and Effect.ignore do not see a throw inside Effect.fn; best-effort boundaries need catchCause with a hasInterrupts re-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, but
Provider.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, past
prune.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 tryStartCheckpointWriter between
    writers.get and writers.set (not reproduced; would need a reservation
    mechanism).
  • Changing Provider.getModel / Session.get to typed failures (wide blast radius).

Tasks

  • T1: Wrap capture closure in interrupt-preserving catchCause — acceptance: real prefixCaptureRef.current called with an unknown modelID and with an unknown sessionID succeeds with the empty prefix; new tests fail before the fix (covers: S2)
  • T2: drainWriters counts via Deferred.isDone over the snapshot — acceptance: checkpoint-drain.test.ts hanging-writer case still reports { drained: 0, timedOut: 1 }, and a settled writer reports drained: 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.prompt could 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-writer may target only files under the resolved memory root.
  • Within the memory root, assertMemoryWriteAllowed remains authoritative for canonical MEMORY.md, checkpoint.md, notes.md, permitted spillovers, and permitted task narratives.
  • checkpoint-writer must not write project source, repository configuration, any worktree path, or .mimocode/.
  • dream and distill retain 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, including write, edit, apply_patch, and notebook_edit, receives the same boundary. apply_patch must 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-writer receives the checkpoint-specific policy in [S2.3].
  • Existing system agents must declare an explicit policy. The declared policy keys and SYSTEM_SPAWNED_AGENT_TYPES must remain synchronized by a regression test so a future system agent cannot silently inherit a default user-facing retry.
  • dream and distill require 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_COMPLETE without 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_patch cannot 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.error resolves 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_patch cannot 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 typecheck pass from packages/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.messages cannot 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 or ProviderTransform replaces the image before the wire.
  • A small context window (compaction.max_context or MIMOCODE_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_url inside 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 the file: URL when source.path is 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: true under 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 file parts.
  • The non-replay continue message and its overflow wording.
  • Repairing messages already persisted with the placeholder text.

Tasks

  • T1: Copy media file parts verbatim in the overflow replay — acceptance: after provider-overflow compaction with a replayed user message containing an image/png file part, the persisted replayed message contains a file part with the same mime, filename, url and source as 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 test before the review fixes — 4359 pass / 4 fail; every failure reproduced at base or passed in isolation (test/util/ssrf.test.ts DNS fail-closed fails at base; test/workflow/runtime.test.ts has 2 fails at base vs 1 here; test/session/checkpoint-rebuild-unify.test.ts passes in isolation in both trees). CI runs the full suite.
  • CLI: MIMOCODE_CONFIG_CONTENT='{"compaction":{"max_context":{"openai/gpt-5*":"300K","openai/gpt-5.6":200000}}}' bun run src/index.ts models openai → gpt-5.6 window 922K / budget 200K / compacts at 180K; gpt-5.6-sol budget 300K / 280K; gpt-5.3-codex (272K cap) shows no budget because 300K clamps away; gpt-4o and o3 unchanged.
  • Live TUI (tmux, isolated MIMOCODE_HOME, xiaomi/mimo-v2.5): picking 300K wrote "xiaomi/mimo-v2.5": 300000 to the global mimocode.jsonc, footer became 33.0K/260K↓ (13%), and /status showed window 1.05M · budget 300K · compacts at 260K. The sidebar follow-up renders the user setting against the model limit (limit 300K of 1.05M) and covers it with a TUI render test. Custom "50%" wrote 524288 (compacts at 484K). "Model default" wrote 0 and restored compacts at 1.01M. Selecting a tier mid-stream left the config untouched and kept the dialog open; the same action once idle wrote 200000.

Journey log

  1. PR #1926's real defect was not "insufficient" but inert: usable() prefers limit.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 set limit.input, not just limit.context.
  2. Instance.dispose() — which every config write triggers — cancels all runners via the SessionRunState scope finalizer. Any TUI feature that persists config mid-session needs a busy guard; sync.data.session_status is the server-authoritative signal (message-derived status can stay "working" forever after a crash).
  3. Config merges cannot delete keys, and null would fail schema validation, so "reset to model default" is expressed as 0. 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 JSON mergeDeep and the JSONC patchJsonc writers.
  4. SDK regeneration is currently broken on main: bun dev generate emits a dangling #/components/schemas/__schema0 ref from ToolStateCompleted.providerOutput, so @hey-api/openapi-ts fails. Confirmed at base commit; new config fields therefore need a narrow cast at the TUI boundary until that is fixed.
  5. usable() can be 0 for a positive window (window smaller than the reserves, or a large compaction.reserved), which would have rendered Infinity% in three footers. Any UI that divides by it needs the guard now in tui/util/model.contextWindow.
  6. 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):

ts
if (modelID.startsWith("gpt-")) model.limit.context = 300_000

Defect 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:

ts
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 by compaction.ts:49-54 and prune.ts:274-292 as a safety buffer, and produces a nonsensical number for anyone reading the config.
  • provider..models..limit.input (config/provider.ts:40-46) does already move the trigger, but it is per-model JSON archaeology, it lies about the provider's real cap, and for the Codex case it is overwritten by the plugin (Defect 3).
  • MIMOCODE_DISABLE_AUTOCOMPACT is all-or-nothing.

S1.5 Requirement summary

  1. A user-settable working budget, distinct from the provider cap, expressible in common tiers (200K / 300K / 500K / 1M) plus "model default" and a custom value.
  2. Never exceeds the provider's effective cap — the setting clamps, it never raises.
  3. Discoverable from the TUI without editing JSON, and the resulting number must be printable ("what is my current context window, and where will it compact?").
  4. The provider-layer Codex bug fixed correctly and independently of (1)–(3).

S1.6 A completed high-usage turn is rebuilt twice

SessionProcessor marks a successfully completed model turn as "overflow" when its reported usage reaches Overflow.usable(). The post-process overflow handler rebuilds immediately, but the same completed assistant usage remains visible to later prompt loops. The next user turn can therefore consume that usage again in the preflight overflow check, insert a second checkpoint boundary, re-arm checkpoint thresholds, and run tail microcompaction again.

For a configured 372K budget with the default 20K reserve, the first trigger is 352K (94.6% of the configured limit). This is the intended trigger. The defect is processing that one high-water usage record twice, not the trigger percentage.

[S2] Design — route-independent core

S2.1 Vocabulary

  • hardCap(model) = model.limit.input || model.limit.context — the largest prompt the API accepts. 0 keeps its current meaning: unknown/not applicable, overflow handling disabled.
  • budget(cfg, model, …) = user-requested working budget, or undefined.
  • effectiveCap = hardCap === 0 ? 0 : min(hardCap, budget ?? hardCap).
  • usable() keeps its current meaning: effectiveCap minus reserves. It stays the single source of truth for "when do we compact".
  • Checkpoint thresholds only schedule checkpoint writers. Crossing the final threshold does not rebuild or compact; usable() remains the only automatic context-switch boundary.

S2.2 `Overflow.usable()` becomes budget-aware

packages/cli/src/session/overflow.ts:13-24 is the only place the arithmetic changes:

ts
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.input branch keeps outputReserve = 0; changing that is PR #1265's scope, not ours).
  • A budget larger than hardCap is silently clamped, never applied upward.
  • A budget below reserved + outputReserve yields 0; isOverflow then fires on the first turn. Reject such values at the write boundary (S2.4) rather than letting the session brick.
  • pressureLevel / contextPressureLevel inherit the new denominator for free, so checkpoint thresholds (prune.ts:274-292) and preserveRecentBudget (compaction.ts:49-54) follow the budget automatically.

New exported helper for callers that need the numbers without the reserves:

ts
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:

  1. session override (only if Route C is chosen)
  2. compaction.max_context keyed by "/<modelID>", longest-match glob allowed ("openai/gpt-5*")
  3. compaction.max_context scalar (all models)
  4. none → hardCap

S2.4 Config schema

Added to the existing compaction struct (packages/cli/src/config/config.ts:239-258):

ts
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:

ts
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 = 372000 for the gpt-5.6 variants (openai/codex#31860 quotes the served catalog, which also applies effective_context_window_percent: 95 and 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-edit mimocode.json.
  • Scope: global or per-model, per project or per user. Persistent. Read by every usable() call site for free (cfg is already an argument at all 6 sites).
  • Cost: smallest. No SDK regeneration beyond the generated Config type, no migration, no new dialog.
  • Gap: does not satisfy the user's "slash option with preset tiers" ask; not discoverable.
  • Picker dialog: Model default · 200K · 300K · 500K · 1M · Custom…, applied to the currently selected providerID/modelID, written as compaction.max_context["openai/gpt-5.6"].
  • Mechanism already proven: sdk.client.global.config.update({ config: patch }) → sdk.client.instance.dispose() → sync.bootstrap(), exactly as component/dialog-model.tsx:318-338 and dialog-modalities.tsx:72-96 do. 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_context column on session (session/session.sql.ts:14-48), migration folder migration/<ts>_session_max_context/migration.sql (ALTER TABLE session ADD max_context integer;), touch fromRow / toRow / toPartialRow (session.ts:64-112, projectors.ts:37-63), Session.setMaxContext next to setTitle (session.ts:681-720), extend the session.update body 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 6 usable() 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 have sessionID in scope.
  • Writes are safe mid-turn (patch() is a SyncEvent.run, no dispose) and propagate to the TUI automatically via session.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.context as 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 in compaction.ts and prune.ts.
  • TUI KV / model.json as 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 to mimo run / serve / ACP clients.
  • Per-request PromptInput.maxContext: not sticky across compaction continuations, retries, or LoopInput wakes (prompt.ts:4460), and needs threading into Processor anyway.

[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.input output-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.ts with a clamp, including limit.input and the limit.context === 0 guard — acceptance: bun test test/plugin/codex.test.ts asserts gpt-5.6-sol → {context: 372K, input: 372K}, gpt-5.3-codex → {context: 372K, input: 272K}, gpt-4o → {context: 128K} unchanged and no input introduced, 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 = 372000 for 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 a compaction.max_context recipe instead of baked into the cap.

Route A (substrate):

  • T3: Extract the threshold grammar from prune.ts into Token.parseQuantity and unit-test "300K" / "1.5M" / "50%" / plain number / invalid input — acceptance: bun test covers all five, prune.ts behaviour and error messages unchanged (covers: S2.4)
  • T4: Add compaction.max_context to 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 add Overflow.contextWindow() per S2.2 — acceptance: unit tests show (a) no budget → identical numbers to main for a limit.input model and a context-only model, (b) budget above the cap is clamped, (c) budget 300K on a 1.05M/922K model yields usable = 280K and isOverflow fires there (covers: S2.2; depends: T4)
  • T6: Document compaction.max_context wherever compaction.* 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-limit command + preset picker dialog writing compaction.max_context["/<model>"] via global.config.update → instance.dispose() → sync.bootstrap() — acceptance: selecting 300K on a 1M model persists to the global config, the footer denominator updates without restart, and Model default resets the model (covers: S3 Route B; depends: T5). Reset writes 0 instead 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 /status dialog showing provider window, budget + source, reserved, compact-at, current tokens and % — acceptance: with and without a configured budget the block renders correct numbers and the correct source label (covers: S2.6; depends: T5)
  • T11: Surface the context window in the models CLI command without --verbose — acceptance: mimocode models openai prints each model's provider window and compact-at (covers: S2.6; depends: T5)
  • T12: Decouple the final checkpoint threshold from the prompt-loop rebuild condition — acceptance: crossing the final checkpoint threshold below usable() writes a checkpoint but inserts no rebuild or compaction boundary; reaching usable() still follows the existing rebuild path (covers: S1.3, S2.1; depends: T5)
  • 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 —

  1. User guess "cron never marks the session" was only half-right: createdBySessionId existed on schedule but was ignored at fire time and never written by armLoop.
  2. loop is not a standalone tool: /loop is a skill wrapping cron schedule; cron also has a loop action (armLoop). Both shared the wrong-session fire path.
  3. 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.
  4. Keepalive affinity needed two layers: inherit-from-prior covers supersession of a live row; LoopState.createdBySessionId covers the post-fire path where the one-shot row is already gone. Persist owner on the longer-lived state object.
  5. e2e fireToInject mirrors production onFire rather 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 scheduled
cron / loop prompts fire into the wrong session. Code confirmed this:

  • CronBridge.onFire always injected into handle.sessionID (the first session
    that called start) and never read task.createdBySessionId.
  • CronBridge is a single-slot singleton; a second session's start is a no-op,
    so every fire in the process landed on the first session.
  • scheduler.armLoop never wrote createdBySessionId, so loop tasks had no
    owner stamp at all (and keepalive re-arm dropped any stamp).
  • get / delete / rename only applied a session filter when the caller
    explicitly passed session_id; the tool schema claims "defaults to current
    session" but did not enforce it.
  • LoopState is a process-global map keyed by prompt text only.

[S2] Design

Fire routing

When a task fires, inject into the task's owning session:

  1. Target session = task.createdBySessionId when present (truthy).
  2. 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.add stamps createdBySessionId when input.session_id is set.
  • armLoop accepts and stamps session_id. Owner resolution is truthiness
    ("" = unmarked): explicit session_id, else a stamped prior same-prompt
    loop task, else LoopState.createdBySessionId. LoopState outlives 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 loop action passes sessionID (resolved like schedule).

Ownership on read/update/delete

  • CronTool always passes session_id: sessionID (resolved from
    op.session_id || ctx.sessionID) to get / delete / rename, matching
    list / schedule.
  • findById ownership check: when a session filter is supplied, a task with a
    different createdBySessionId is not visible. A task with no
    createdBySessionId stays visible (legacy tolerance), matching the fire-time
    fallback. When no filter is supplied (internal callers), behavior is
    unchanged.
  • list uses 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 from
sessionStatus[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 isLoading gating (busy of one session still delays the whole
    tick).
  • Re-keying LoopState by (session, prompt).
  • Migrating or deleting legacy unmarked durable tasks.
  • Desktop UI / Automations page changes; 30s sidebar discovery lag.

Tasks

  • T1: Route onFire through task.createdBySessionId with 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 createdBySessionId in armLoop and pass session_id from the cron tool loop action — acceptance: created loop task has createdBySessionId equal to the calling session (covers: S2; depends: T1)
  • T3: Always pass resolved sessionID into get/delete/rename in 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:

  1. pick the first allowed provider
  2. run sort() over that provider's models (priority substrings gpt-5 / claude-sonnet-4 / gemini-3-pro, then latest, then id desc)

That sort was written for TUI menu ranking, not as a product default. On MiMo Desktop:

  • cfg.model is not injected
  • TUI-only state/model.json recent is 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.

text
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. Only defaultModel() 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 InstanceState provider 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 recent from Desktop

[S3] Out of Scope

  • Changing getSmallModel / genTitle to prefer the conversation model
  • Injecting Desktop model_groups.lite
  • Rewriting TUI's own UI fallback in packages/app or cli/cmd/tui/context/local.tsx
  • Deleting Provider.sort or defaultModelIDs
  • 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/provider passes; new cases assert the chain order and that gpt-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. Explicit port: N binds only N.
  • CLI: yargs default remains 0, so an unspecified --port now means ephemeral. Fixed ports require an explicit flag or config.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 / serverUrl fallback uses http://mimocode.internal (not a conventional listen port). Real traffic uses Server.url after listen or in-process app.fetch.
  • Generated SDK default baseUrl: http://localhost:4096 is 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.port schema (still optional and > 0).
  • Rewriting all multilingual web docs / unmanaged packages/app UI placeholders beyond note-level consistency.

Tasks

  • T1: node+bun adapter 0 → start(0) — acceptance: source has no start(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 0 as ephemeral (covers: S2)
  • T4: unit test port 0 does not prefer 4096 when free/bound — acceptance: bun test green 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 by
buildMemoryInstructions). 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 — PASS
  • bun test test/agent/agent.test.ts — PASS (52)
  • bun test test/session/llm-system-prompt.test.ts — PASS
  • bun 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 1 read casing + glob tool qualifiers) — prompt-only; pre-push typecheck PASS
  • compose-next review: acceptance criteria met after casing + general inspect/verify follow-ups

Journey log

  1. Memory rewrite was wrong — already in buildMemoryInstructions; delete the section.
  2. 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.
  3. Subagent return format belongs on spawn task injection, not the main memory block.
  4. general must keep a nested-spawn ban and inspect/verify work-face: toolAllowlist is unset so it can inherit actor; without verify bullets the parent-report contract is unimplementable.
  5. Tool-name casing across all injectors: English imperatives stay plain English (Read all sources, Glob \pattern`, Read CHECKPOINT_PATH). When naming the registered tool, use Use `glob` with …/the `grep` tool/ call formglob("…")— never jam a backticked id in front of an argument (``glob path`` 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 buildMemoryInstructions or 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.env on every spawn; freezing that fallback broke existing CLI runtime environment updates.
  • Review found Effect commands with undefined env and native child_process calls 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:

ts
setChildProcessEnv(env: NodeJS.ProcessEnv): void
childProcessEnv(explicit?: NodeJS.ProcessEnv): NodeJS.ProcessEnv

The 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:

text
configured childEnv (or current process.env when no host set one)
  -> existing inherited-credential scrub
  -> explicit tool/plugin/MCP/LSP env overrides
  -> spawn

The 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 of NODE_ENV.
  • Adding model-visible per-call environment controls to the Bash schema.
  • Reusing Env.Service for 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

  1. Flooding and PascalCase are independent of the FIFO/fail-cascade gate; the
    gate file stayed out of the removal diff.
  2. 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:

  1. 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.
  2. 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.ts FIFO 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 modelName from tool definitions and the MIMOCODE_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_name plumbing from tool/tool.ts,
    tool/registry.ts, session/prompt.ts, session/llm-request-prefix.ts,
    session/prefix-snapshot.ts, and session/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,
    releasedCallID skip) and related retrySafe special cases from
    session/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 ToolCompat name repair stay as they are.
  • doom_loop keeps 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; no
    MIMOCODE_PASCAL_CASE_TOOLS / modelName remains; 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):

ts
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 exec result envelope or the existing counts/recent summary 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_parts on 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 viewExecSubtools projection 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 in 1e463724 (dead pending-branch props, missing stripAnsi); the third (clip() being a plain function rather than a memo) was rejected — reads of expanded() 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 in 6e23a566. 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 in 6479a56b.
  • 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. exec is 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. exec returns JSON via JSON.stringify(parsed, null, 2), but each string value inside it (a nested bash stdout, a grep result set) keeps its \n escaped, so 40 rows of text arrive as one source line. Budget rendered rows for anything block-shaped.

  • Bun.stringWidth returns 0 for a tab (and for a newline) — found via component/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.stringWidth is 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.define infers metadata as the union of execute's return literals, and bun typecheck covers test/, so a non-uniform union makes result.metadata.counts inaccessible in tests. All five terminal returns now carry counts (empty map for the pre-execution pair, behaviorally identical). §S2 records the delivered rule.

  • Hoisting trace above the code-size guard so tally() could be shared briefly left a duplicated const trace declaration; caught by grepping declarations, not by the first typecheck pass.

  • Anything streamed via ctx.metadata must 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 InlineTool line — » exec 12 calls · read×7 grep×4. Neither the script nor its output is visible.
  • Expanded: a BlockTool dumping the full input.code and the full props.output with 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 no input.code yet, keep the existing single InlineTool pending line (~ Writing script...). Once input.code is a non-empty string, render BlockTool for the rest of the part's life, with spinner={isRunning()} on the title.
  • BlockTool title stays # exec · , where summary keeps its current composition (N calls · read×7 grep×4(1!), prefixed with the failure status when the run did not complete).
  • Body, in order:
    1. script source — clipped to a 10-rendered-row budget when collapsed with a … marker; full source when expanded;
    2. output — the props.output string with ANSI stripped (nested bash calls 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 as bash shows raw stdout. Coloured theme.error when the run failed.
    3. hint line Click to expand / Click to collapse, rendered only when at least one of the two blocks overflows.
  • onClick is 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: exec returns 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 is ceil(width(line) / columns) where width is Bun.stringWidth plus one cell per tab (Bun.stringWidth reports 0 for tabs, and the real tab stop in a static <text> is unverified) — so CJK and emoji count as two cells. columns comes from ctx.width minus 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.stringWidth is 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 with Collapse.
  • Known limitation (accepted, follow-up): the height estimate assumes character wrapping, but <text> renders through @opentui TextBufferRenderable whose wrapMode defaults 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 in Collapse (duplicates renderer internals) and forcing wrapMode="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 by Bash and ToolScript — bash has the same long-single-line defect. hasLongDisplayLine / Write's line count reuse Collapse.lines.
  • The pending branch is only reachable while code is empty, so it carries no failure color, spinner, or summary. InlineTool's iconColor prop 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 exec panel with per-call expansion (WorkflowPanel-style).
  • Parsing the <exec> envelope to render return_value / trace as distinct sections.
  • TypeScript syntax highlighting of the script body.
  • Any change to the exec enablement 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 counts in exec terminal metadata — acceptance: bun test test/tool/tool-script.test.ts passes with a new assertion that a completed run's metadata carries counts with per-tool n/errors, and that a failed inner call is reflected in errors (covers: S2)
  • T2: Render exec as a bash-style collapsible block — acceptance: ToolScript renders BlockTool whenever input.code is 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 typecheck clean (covers: S2)
  • T3: Budget the collapsed height in rendered rows — acceptance: bun test test/cli/tui/collapse.test.ts passes, covering wrapped-height counting, whole-line drops, mid-line slicing at the budget, and the column floor; Bash and ToolScript both consume the shared helper (covers: S2)
  • T4: Measure height in display cells — acceptance: bun test test/cli/tui/collapse.test.ts covers 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 wipes
ActorRegistryTable before each test, because renderRebuildContext reads the process-wide
ActorRegistry.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.hang for hangUntil + release, the
    idiom runtime-worktree.test.ts uses — 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), and
    process._getActiveHandles() empty (so: an Effect fiber, not a socket).
  • Naming the stuck disposer needed the stack captured at the InstanceState.make call site, outside
    the returned Effect.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.timeout placed 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.

  1. test/util/ssrf.test.ts asserts that assertSafeUrl rejects an unresolvable hostname by asking
    the real resolver for a .invalid name. 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.
  2. test/session/checkpoint-rebuild-unify.test.ts asserts that insertRebuildBoundary returns
    false when there is nothing to push. renderRebuildContext reads
    ActorRegistry.listActive(), which is process-wide, and the SQLite client is a process-level
    singleton, so background actors left pending/running by an earlier test file make the context
    non-empty. It passes alone and fails in a whole-suite run.
  3. 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 isolation
test/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, and
Runner.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.disposeDirectory already bounds its path with DIRECTORY_DISPOSE_TIMEOUT; the direct
    dispose() 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.hang call sites in test/workflow/runtime.test.ts. They pass today; converting
    them is unverified churn.

Tasks

  • T1: Inject the resolver into assertSafeUrl and rewrite the DNS assertions against a stub —
    acceptance: bun test test/util/ssrf.test.ts passes with no network dependency (covers: S2)
  • T2: Wipe leaked ActorRegistryTable rows in checkpoint-rebuild-unify — acceptance:
    bun test test/actor test/session/checkpoint-rebuild-unify.test.ts passes, having failed
    before (covers: S2)
  • T3: Quiesce the parent session before teardown in the cancel-cascade test — acceptance:
    bun test test/workflow/runtime.test.ts passes with zero failures (covers: S2)
  • T4: Confirm no further local-only failures across two full runs — acceptance: the union of two
    bun test runs 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.ts green 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 — previewToolOutput extracted from truncate.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*24 before 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.ts re-exports and owns model-facing Truncate.output
  • Extract also strips data-URLs before preview so binary payloads never consume the tool-result budget
  • Budget: MAX_BYTES=50KiB / MAX_LINES=2000 from tool/preview.ts, head+tail when tail looks like errors; index bodies hard-clamp to this ceiling
  • Migration batch (migration-batch.ts) is Log-free; scheduler startIndexMigration accepts optional migrate for tests and clears jobs after 5 failed attempts
  • Migration v6 SQL marks prior history_index_migration versions done so superseded state cannot linger at clean
  • Search keeps temporary limit*24 over-fetch (debt history-search-overfetch-after-v6); tighten to limit after v6 — see Follow-ups
  • Search uses CROSS JOIN so SQLite evaluates MATCH once, then rowid lookup (Node SQLite plan regression covered in tests)
  • Tool body composition: input JSON previewed at ~25% budget; tool output previews keep the Full output saved to: path hint inside budget
  • Migration scheduler: limited error backoff (5 attempts) + jobs slot clear on done/error/abort so restart works; stopIndexMigration deletes the slot
  • previewToolOutput reserves MARKER_RESERVE bytes so omission markers stay within MAX_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 get reads full PartTable text
  • 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_ad for 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.ts verifies 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_index replaces 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), set service.ts search fetchLimit back to limit and drop the legacy over-fetch comment.

[S3] Out of Scope

  • buildFtsQuery AND/OR semantics
  • Changing which part types are indexed
  • Desktop engine-pin bump / shipping
  • Online surgery of production DB outside the background migration

Tasks

  • T1: index-preview.ts + extract() tool-output budget (covers: S2)
  • T2: chunk-write.ts shared upsert/delete; writer + import (covers: S2)
  • T3: service.ts search/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.

ts
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):

json
{
  "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

  1. 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.
  2. 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.
  3. Unmatched native network/timeout errors retain mandatory persistent recovery (including HTTP408/504 legacy handling).
  4. 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 typecheck and git diff --check pass.
  • 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 own constructor; strict JSON validation plus a detached clone now preserves those keys without reading inherited members during pointer resolution. Package bun typecheck and git diff --check pass.
  • 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 typecheck and git diff --check pass.
  • 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 投递)走同一套受理、认领、结算、停止逻辑。

目标

  1. 一个调度归属:inbox 是 lane 的唯一输入队列与受理/认领/结算所有者。Runner 只持有执行租约。
  2. 输入同构:任何来源的输入在受理时写成接收方切片的一条 user 消息加一行 inbox 记录,不按发送方或 type 分叉行为。
  3. 唤醒同构:任何受理都递增 input_revision,显式 actor.wait 与自动等待一律以 interrupted 结束。
  4. 读取聚合:每个步骤边界认领该 lane 全部 accepted 记录,多条输入同边界一起对模型可见。
  5. 可见性:只对模型隐藏仍在排队的输入(accepted 未认领,或被其他运行认领)。
  6. 停止语义:用户停止把 accepted/claimed 记录结算为 cancelled,消息保留且对模型可见,不自动执行。
  7. 回执契约稳定:HTTP prompt_async 202 {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):

  1. 表重建 inbox:建 inbox_new(上表列)→ 把旧行拷入(content 保留、input_message_id 置 NULL、state='accepted'、time_updated=created_at)→ 丢旧表 → 改名 → 建四个索引。
  2. 建 inbox_lane_state、inbox_session_epoch。
  3. 不写清理 turn_receipt / turn_lane_state / turn_session_epoch / turn_legacy_bootstrap 的语句(那四个表与两个迁移目录从未发布,当从不存在,直接删掉目录即可)。

旧行转成消息不在 SQL 里做:生成 MessageID 与确定 synthetic 消息的 agent/model 都依赖运行时,见「已发布数据」。

状态机

text
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)

一个事务内完成:

  1. 把输入写成接收方切片的一条 user 消息。agent 发来的标记 synthetic,消息 provenance 为 { source: "agent", senderSessionID?, senderActorID? }。synthetic 消息的 agent/model 在受理时按接收方确定(resolveDrainSeed 语义:standing peer 跨切片搜索,无历史时使用注册 agent 与默认模型;workflow 父 agent 只搜本切片)。缺少接收方模型来源的协调切片使用 system agent/model 保存消息;laneKick 仅在接收方切片能够解析真实模型时执行,防止子 actor 通知凭空产生真实回合。
  2. 写一行 inbox 记录:state=accepted、epoch、input_message_id 指向该消息、幂等键、type、发送方。
  3. 递增该 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_async 202 {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.log that 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 dev writes its logs under the checkout's .dev-home (MIMOCODE_HOME in script/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 before init() produces no stderr output, while --print-logs still 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-loop keys 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

  1. Physical layer is audit-only. Loop recovery never removeMessages. DB keeps the full trajectory for replay, undo, and forensics.
  2. Request-layer crop, no new user. Align with turn recovery (resume): do not create a Continue./recovery user. Reuse the existing parent user (lastUser unchanged → parentID matches resume). Persist the span as an ignored + synthetic text part on that user. Trailing-assistant repair stays in transform.ts.
  3. Thinking is always in scope. Deletion unit is a whole assistant message (reasoning + text + tools + embedded results). No part-level surgery in v1.
  4. Anchor is mandatory. The message immediately before the streak start is never removed (user or non-matching assistant).
  5. No snapshot rollback. Dirty workspace after an edit streak is treated like an external file change; the model re-reads and re-plans.
  6. 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:

text
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 present

Thinking 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 like
normalizeForLoopDetection 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:

  1. 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.
  2. 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 one key.
    • Walk backward while key matches; predecessor is the anchor (always kept).
    • Require span.toId === lastFinished.id.
    • On hit: crop the request view, persist an ignored + synthetic text part on the existing lastUser whose metadata.origin is { kind: "loop_streak_crop", fromId, toId, key, truncated }. Do not create a new user; do not reassign lastUser.
  3. 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

text
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 user

Later 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.id order.
  • 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:

text
session_id, spanFrom, spanTo, anchorId,
nMessages, nParts, omittedBlocks, keptBlocks,
remainingSimilar, truncatedByCeiling,
cacheRisk: omittedBlocks > 20

omittedBlocks ≈ 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.ts
Continue. 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/key from 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_WAIT or connection lifecycle.
  • Mid-turn ambient push into history.
  • Full JSON Schema validation of mcp_tool_use arguments.
  • Consuming MCP.ToolsChanged as a dirty signal.
  • Extending readiness reminders to GPT/exec or 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.

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,b loads 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; +term requires term in 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_search and cached local BM25 discovery.
  • T2: Separate registered executors from model-visible activeTools and 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 auto threshold, names-only deferred list, select: / +required queries, 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 native
mcp_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 at
mcp_tool_search. Direct calls to deferred tools are rejected after the actor-whitelist check, with a
mcp_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 test on 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 executeTool checks the persisted part's tool name, so dispatched calls must batch under mcp_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 by
MCP_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_use tool. A native tool call (not text/XML) with parameters
    { name: string, arguments?: object }. It is exposed exactly when mcp_tool_search is exposed
    (deferred mode, non-GPT toolset, model supports tool calls) and placed in activeTools next to it.
    It is built in SessionPrompt (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;
    loadedMcpTools and MCP_TOOL_SEARCH_MAX_LOADED are removed. mcp_tool_search keeps returning the
    <functions> schema block (appended to history, so it never touches the cached prefix) and keeps its
    matchedTools metadata.
  • Dispatch. mcp_tool_use resolves name against 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 is mcp_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 with exec).
  • Errors (recoverable, loop continues).
    • Unknown or unauthorized name → error naming the tool and telling the model to use
      mcp_tool_search.
    • arguments not an object, or missing a top-level required property 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.)
  • 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 through mcp_tool_use.
  • Guidance text. The mcp_tool_search description, the deferred-tools <system-reminder>, and the
    search result tell the model to call tools via mcp_tool_use with { name, arguments }.
  • Provider filter. llm.ts resolveTools exempts mcp_tool_use from permission-disabled filtering
    when it is active, as it does for mcp_tool_search (real-tool permission is enforced at dispatch).
  • TUI. A mcp_tool_use part renders as the underlying tool: name from input.name, arguments from
    input.arguments.
  • Non-deferred mode (MIMOCODE_MCP_TOOL_SEARCH=false, or auto below threshold) and the GPT/exec path
    are unchanged.
  • Stability across MCP churn (amendment).
    • An empty catalog counts as deferred: unless mode is never, mcp_tool_search/mcp_tool_use are
      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, auto stays deferred even if the catalog drops under its threshold or empties. In auto, 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_search always searches the live catalog meanwhile.

[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 to tool/mcp-tool-search.ts; update search description/reminder/result wording; remove MCP_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_use dispatcher in session/prompt.ts, drop search-based loading, reject direct deferred calls, exempt it in llm.ts — acceptance: session tests show the tool list is identical before and after a search, a mcp_tool_use call 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_use parts 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_use where 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 typecheck from packages/cli — PASS
  • bun 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=1 for 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 appended encoded the old coupling; inverted to core-on + ckpt-extras-off.
  • Flag-on test must not assert absence of the string checkpoint-writer — base agent.prompt mentions that agent name; assert memory-system-specific copy instead.
  • memory.disable_write remains 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 system into desktop-base.

[S1] Problem

# Memory system is injected by buildSystemArray only when
servesCheckpoint && !Flag.MIMOCODE_DISABLE_CHECKPOINT
(packages/cli/src/session/llm.ts). Desktop embeds the engine with
MIMOCODE_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:

  1. session/llm.ts buildSystemArray — injection if is servesCheckpoint
    only; buildMemoryInstructions(projectID, memoryRoot) internal
    checkpointEnabled splits ckpt extras.
  2. buildMemoryInstructions / injection-site comments state always-on for
    main/peer; flag only narrows content.
  3. tool/checkpoint-description.ts — unchanged. Base memory.txt stays;
    only memory.checkpoint.txt extra stays gated.
  4. Session recall reminder (session/prompt.ts hasMemoryOrTasks) — already
    independent of the checkpoint flag; no change.
  5. memory.disable_write remains 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_write semantics 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.ts always inject # Memory system when servesCheckpoint; flag only filters ckpt extras — acceptance: with MIMOCODE_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 buildMemoryInstructions comments 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.ts for 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.ts from 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-mimo as 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 bundled unset 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

  1. Multi-download is not postinstall picking wrong — npm already fetched every os/cpu match; baseline/musl siblings share those fields.
  2. Do not put the launcher binary under Global.Path.cache (wiped on CACHE_VERSION).
  3. Upgrade = uninstall old package + install new; do not paper over orphans with legacy node_modules lookup.
  4. Bun.spawnSync deadlocks tests that also Bun.serve in-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`)

  1. MIMOCODE_BIN_PATH → spawn that.
  2. /vendor/mimo or vendor/mimo.exe → spawn that.
  3. Else download from FDS into vendor/, then spawn.
  4. 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-release or ldd --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 (default https://mimocode.cnbj1.mi-fds.com/mimocode/mimocode) + /releases/v${packageVersion}/${target}.tar.gz|.zip — pinned to the package version, never releases/latest.
  • Stream to temp, optional .sha256 check when published (404 skips), extract the single mimo[.exe], chmod 755, *.partial + rename.
  • postinstall.mjs calls 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/latest pin, 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 libc on platform packages — abandoned (launcher supersedes; wrapper has no optionalDependencies)
  • T2: Version-pinned FDS launcher in @mimo-ai/cli (mimo.js + launcher.js) — bin is mimo; package-local vendor/; MIMOCODE_BIN_PATH first; 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 .sha256 is published (follow-up)
  • T4: script/release.ts packs only the launcher (mimo.js/launcher.js/postinstall.mjs); npm no longer publishes platform packages; trust registers 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/cli and 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 —

  1. First draft sent the header on every provider; user required Go-only so non-Go stays byte-identical.
  2. Field-list detection (model.api.url / model.options / provider.options) was brittle — replaced by effectiveBaseURL matching resolveSDK precedence.
  3. 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).
  4. Non-stream() inference paths (predict, goal, agent.generate, llm-server, mcp/sampling) are intentionally unwired — operator configuration if pointed at Go.
  5. Integration tests key off providerID="opencode-go"; they do not prove item.options is 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 with Request 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.headers hooks / plugins that re-send input.sessionID under 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:

  1. model.providerID starts with opencode-go (covers opencode-go, opencode-go-chat), or
  2. effectiveBaseURL(model, providerOptions) contains opencode.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> — including ephemeral: true calls (title / summary / compaction).
  • Keep x-session-affinity and x-parent-session-id gated on !input.ephemeral exactly as today (all providers).
  • Do not invent a new identifier. Reuse the existing stable per-conversation sessionID.
  • Do not send the header when sessionID is 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 sessionID is 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-session on OpenCode Go requests only (including ephemeral) — acceptance: unit test for isOpenCodeGoTarget; integration shows Go non-ephemeral carries x-opencode-session == sessionID next to x-session-affinity; Go ephemeral carries x-opencode-session and omits x-session-affinity; non-Go carries neither change (x-opencode-session null, affinity unchanged). (covers: S2)
  • T2: Regression test + typecheck/lint — acceptance: new tests fail if the header is removed or scoped wrong; bun run typecheck and targeted bun test for session/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:

  1. Wait permissionAskTimeoutMs milliseconds.
  2. On expiry, auto-reply reject (publish Event.Replied), fail the Deferred with CorrectedError carrying actionable feedback.
  3. 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

typescript
// 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:

text
// 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 current permissionAskTimeoutMs
  • POST /instance/permission/ask-timeout — body: { "ms": number | null }, sets permissionAskTimeoutMs

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.title
  • tui.permission_timeout.title / hint / option.* / toast_*

[S3] Out of Scope

  • autoApproveDelete mechanism (already an independent toggle).
  • --dangerously-skip-permissions CLI flag behavior (injects allow-all via config ruleset, bypasses skipAll runtime toggle).
  • FORCED_ASK permission set definition.
  • Parent-grant inheritance.
  • Persisting permissionAskTimeoutMs to the database (runtime-only, same as skipAll).

Tasks

  • T1: Service layer split — add permissionAskTimeout / setPermissionAskTimeout to Interface and State; generalize ask() timeout from skipAll && forced to permissionAskTimeoutMs != 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_MS maps to permissionAskTimeoutMs initial value. acceptance: new Instance's permissionAskTimeout() returns the env-derived value (covers: S2.3)
  • T3: Server API — add GET/POST /instance/permission/ask-timeout endpoints. acceptance: HTTP calls can read and write the timeout setting (covers: S2.7)
  • T4: TUI command — add /permission-timeout slash 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.ts for 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 TUI
plan_enter → plan switch mapping, and the tui.question.plan_enter.* block in
all seven locales. plan_exit and everything else about plan mode are untouched,
so build and plan now expose exactly the same mode tool and Tab still
round-trips between them. The system prompt lost its plan-mode advocacy paragraph
and gained no replacement instruction; the user-facing answer to "how do I enter
plan mode" moved to mimocode-docs, which loads only when someone asks how
MiMoCode works.

Verification — from packages/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 base
ce124cbd too, verified by stashing). git diff --check PASS. Root
bun run lint — 0 errors, ~4040 PRE-EXISTING warnings. Independent reviewer:
spec compliance met after two fixes (see log), no correctness bugs, style
consistent.

Journey log

  1. First attempt gated plan_enter behind a default-deny permission rule, then
    behind a registration flag. Both work mechanically; neither was the question.
    The question was whether anything justifies keeping the surface at all, and
    nothing did — the deciding evidence was the entrance table in S2, not any
    property of the gating mechanism.
  2. The first prompt rewrite replaced the advocacy paragraph with instructions on
    how to talk about plan mode. That is the same interruption in a new costume:
    a model told how to discuss plan mode will discuss it. The fix was deletion,
    moving the user-facing answer to an on-demand skill.
  3. mimocode-docs routing keys off the frontmatter description (BM25 over name
    • aliases + description, skill/search.ts:98). The body can hold a perfect
      answer and still never load; the mode/keybinding vocabulary had to go into the
      description.
  4. rg -rn silently means --replace n, printing n where matches
    were. Two searches during this change reported false clean states. Use
    rg -n.
  5. No locale key-parity test exists in this package, and test/cli/tui/i18n is
    not a real path — a verification band naming it exercises nothing. Question
    i18n is fail-soft anyway (routes/session/question.tsx:24 falls back to the
    DB-stored text), so deleted keys cannot break historical replay.

[S1] Problem

Users report that plan mode behaves badly with frontier models, and that the
model keeps putting itself into plan mode unasked.

Plan mode's workflow was designed for weaker models: a five-phase curriculum
(parallel explore subagents → a general design subagent → review → write the
plan file → plan_exit) injected as a ~90-line system-reminder on every entry
(packages/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, its
hardPermission write-block, the plan file, and the plan workflow prompt
exactly as they are.

Why deletion, not a flag

1. It is not a user-facing entry point. Plan mode has five other entrances,
none of which this change touches:

Entrance Site Affected
Tab / shift+tab agent cycle config/keybinds.ts:64-65 → cli/cmd/tui/context/local.tsx:113 no
Agent dialog cli/cmd/tui/component/dialog-agent.tsx:30 no
Startup --agent plan cli/cmd/tui/thread.ts no
Input-bar 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 both
build and plan expose exactly plan_exit, so switching modes still does not
mutate the tool list (the invariant from PR #1207).

Accepted consequences

  • Natural-language planning no longer flips the mode. "帮我先做个计划" in
    build now yields planning in the reply, not a read-only agent, and the
    hardPermission write-block does not engage. prompt/default.txt is
    corrected so the model recommends the Tab switch instead of silently losing
    the affordance (see below). This is the intended trade: the user owns the
    mode.
  • One-time prefix-cache invalidation. build's tool schema loses an entry,
    so the first request of every pre-existing session after upgrade recomputes
    its prefix. This is a version-upgrade-level cost, unavoidable for any tool
    removal, and it does not recur.

Prompt correction

prompt/default.txt is the fallback system prompt (session/system.ts:49) —
i.e. the one MiMo's own models get; anthropic.txt / gpt.txt / codex.txt /
gemini.txt / beast.txt / deepseek.txt / glm.txt / minimax.txt /
trinity.txt contain no plan-mode instructions at all, and kimi.txt:17 only
mentions plan mode as an example of a system-reminder. So exactly one prompt
needs editing:

  • default.txt:87 — drop plan-enter from the "Mode / safety" tool list.
  • default.txt:132 (item 5 of "Plan mode in detail") — absorb the entry rule into
    the existing exit rule: the user switches in and out themselves (Tab or the
    agent dialog); the model cannot enter plan mode; and the model must not tell
    the user they could switch manually unless the user raises plan mode first
    .
    The model's one mode tool remains plan_exit, which requests approval of a
    finished plan and the switch back to build.
  • default.txt:134 — delete the "Enter plan mode for non-trivial implementation
    work…" paragraph outright. Do not replace it with a paragraph about what to do
    instead: an instruction that discusses plan mode is itself a prompt to bring
    plan mode up. Frontier models should just do the work.

The net effect on the prompt is one shortened line and one deleted paragraph — no
new behavioural instruction, no standing invitation.

session/prompt/compose.txt also names plan_enter (line 20) but is
deliberately left byte-identical: it is a model-facing system prompt for the
deprecated Compose agent, and any change invalidates prefix cache for every
existing Compose session (constraint carried from compose-next.md S5). A stale
"do not use a tool that no longer exists" sentence is harmless.

Documentation surfaces

Two audiences need different treatment, and conflating them is what made the
first draft of the prompt edit wrong.

The model, always: nothing. Removing the advocacy paragraph is the whole
change. It carries no guidance about recommending plan mode, because a model that
has been told how to talk about plan mode will talk about plan mode.

The user, on demand: one genuine question survives — "how do I get into plan
mode?" / "why don't you switch to plan any more?" That answer belongs in
mimocode-docs, which is loaded exactly when a user asks how MiMoCode itself
works (skill_search BM25 over name + description, or explicit /mimocode-docs),
and costs nothing on every other turn. Users also learn the Tab gesture from
the home tips, so this is a fallback for the confused case, not the primary
teaching surface.

  • mimocode-docs/SKILL.md frontmatter description — add mode / keybinding
    vocabulary ("agent modes (build / plan / compose) and how to switch between
    them", "how to enter or leave plan mode") so the routing actually fires on that
    question. Without it the skill's description never mentions modes and BM25 has
    nothing to match.
  • mimocode-docs/SKILL.md:18 — the Agents / modes row states that only the user
    enters a mode, that no tool switches into plan, and that plan_exit is the
    agent's one move from inside plan.
  • mimocode-docs/reference/commands.md:130 — under Keybindings, the concrete
    answer: Tab or the agent dialog to enter; Tab or plan_exit to leave; and
    that the agent will not offer plan mode unasked (so the user reads the silence
    as intended behaviour, not a regression).

mimocode-docs/reference/guide.md:114 and config.md:92 mention plan only as a
Compose-legacy skill name and an agent-config key; both stay accurate and are
left alone. The localized tui.skill.mimocode-docs.description strings are the
dialog copy, not the routing input, so they are untouched.

[S3] Implementation

Delete:

  • packages/cli/src/tool/plan-enter.txt
  • PlanEnterTool in packages/cli/src/tool/plan.ts (keep getLastModel
    and PlanExitTool)
  • packages/cli/src/tool/registry.ts — the plan_enter import, its
    Tool.init entry, and tool.planenter in builtin
  • packages/cli/src/tool/tool-script-ref.ts:29 — "plan_enter" exclusion
  • packages/cli/src/agent/agent.ts — the plan_enter rules at :113
    (defaults deny), :141 (build allow), :181 (plan allow)
  • packages/cli/src/cli/cmd/run.ts:356-360 — the plan_enter deny rule
  • packages/cli/src/cli/cmd/tui/routes/session/plan-switch.ts:7 — the
    plan_enter → "plan" mapping
  • the tui.question.plan_enter.* block (6 keys plus its comment header) from all
    seven locale files under packages/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:24 falls back to the
    DB-stored question text when a tui.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.md and
    reference/commands.md — per S2 Documentation surfaces.

Tests:

  • test/tool/plan.test.ts — drop the plan_enter "No" case; plan_exit cases
    unchanged.
  • test/cli/tui/plan-switch.test.ts — drop the plan_enter → "plan" cases and
    keep one inverted assertion: a completed plan_enter part must now map to
    undefined. Resumed sessions still hold historical plan_enter parts in the
    DB, and replaying them must not switch the mode.
  • test/agent/agent.test.ts:160,177 — assert on plan_exit only.
  • test/permission/disabled.test.ts:53-65 — these exercise Permission.disabled
    semantics using tool names as data; rename the subjects to plan_exit /
    question so no test references a deleted tool.
  • test/tool/tool-script.test.ts:557 — drop plan_enter from the exclusion-set
    assertion.
  • New: test/tool/plan-enter-absent.test.ts — registry.ids() does not contain
    plan_enter, and does contain plan_exit. This is the regression guard
    against a re-add.

Verification, from packages/cli: bun test test/tool test/cli/tui test/skill test/agent test/permission, bun typecheck, git diff --check, and
bunx prettier --check on the touched files. Two baseline caveats: root
bun run lint reports ~4040 pre-existing warnings (0 errors), and
src/cli/cmd/tui/i18n/*.ts, src/tool/registry.ts and src/agent/agent.ts are
already prettier-nonconformant on main, so those files must be compared against
their own baseline rather than to a clean prettier --check.

Do not touch: session/prompt/compose.txt, Session.plan(), the plan agent's
hardPermission, the plan workflow reminder in session/prompt.ts, the
build/plan free-switch group, or the delivered reports and specs that describe
past state (docs/compose/reports/sticky-agent-mode.md,
docs/compose/spec/plan-no-continue.md) — they document the state at their own
delivery and stay as written.

[S4] Roadmap — deliberately not in this change

compose-next.md S4.5 already parked "plan-mode dissolution and Tab permission
presets" as independent work. This section records the intended direction so the
present change is legible as a first step toward it, and fixes what must not be
done yet. None of it is in scope here, and none of it is committed to.

Direction: plan mode goes away. The current lean is to stop shipping plan as
an agent at all and split it along its two real concerns:

  1. Permission handled by another mechanism — the only part of plan mode
    carrying durable value is the hardPermission write-block. As a permission
    preset (read-only / ask / accept-edits / bypass) switchable mid-session, it
    applies to whatever agent the user is already in instead of forcing them into
    a different one, and it composes with the existing ruleset machinery
    (permission/index.ts) with no new name-branching. Claude Code's shift+tab
    cycle is the reference shape. One constraint that design must respect:
    Permission.evaluate is findLast over the flattened rulesets with no
    specificity scoring (permission/evaluate.ts:9-15), and
    --dangerously-skip-permissions merges {"*": "allow"} into the last user
    layer (config/config.ts:953) — so a read-only preset expressed as an ordinary
    ruleset deny would be silently defeated by allow-all. Today's plan mode dodges
    this via hardPermission being re-appended after the user merge; a preset needs
    an equivalent last-layer story.
  2. A plan skill — the ~90-line workflow injected as a system-reminder
    (session/prompt.ts:991-1073) is curriculum, not policy, and per S1 it is
    curriculum aimed at weaker models. compose-next already established the
    migration pattern: collapse the curriculum into one compact executable
    contract, loaded on demand by explicit invocation, instead of taxing every
    plan turn's prompt.

Ordering constraint. The permission mechanism must ship before plan leaves
the Tab cycle. Plan mode is today the only read-only backstop in the TUI;
removing it first would leave users with no way to get one.

Keybinding. shift+tab is agent_cycle_reverse today
(config/keybinds.ts:65). It frees up naturally: once plan and compose exit
the primary cycle, the cycle holds one or two entries and Tab alone covers it.
Do not reassign shift+tab before that happens.

Do not mark Plan "(legacy)" yet. Compose could carry the label
(compose-next.md S2) because its successor had already shipped and users had
somewhere to go. Plan has no successor in the tree: no permission preset, no plan
skill. Labelling it now would announce the mode is going away while it is still
the only way to get a write-block, which is worse than saying nothing. The label
belongs in the PR that lands the permission mechanism.

[S5] Out of scope

  • Removing the plan agent, its hardPermission, plan_exit, the plan file,
    or the plan workflow prompt.
  • Any keybinding change, including reassigning shift+tab.
  • Introducing permission presets or a plan skill (S4 direction only).
  • Marking Plan deprecated in any UI surface.
  • Any byte change to session/prompt/compose.txt.
  • Rewriting the plan workflow reminder's content (only plan_exit survives in
    it, and that reference stays valid).
  • Hardening bash against writes in plan mode (existing "trust the model,
    permission is a backstop" stance).
  • packages/web/src/content/docs/** — the upstream opencode website, carried in
    ~12 locales. Per AGENTS.md the TUI is the supported surface; syncing that
    corpus is its own change.

Tasks

  • T1: delete PlanEnterTool, plan-enter.txt, and its registry / tool-script-ref wiring — acceptance: registry.ids() omits plan_enter and still contains plan_exit; bun typecheck clean (covers: S2, S3)
  • T2: remove the three plan_enter permission rules in agent/agent.ts and the deny rule in cli/cmd/run.ts — acceptance: no plan_enter string remains in src/agent or src/cli/cmd/run.ts; build and plan agents both expose exactly plan_exit (covers: S2, S3; depends: T1)
  • T3: remove the plan_enter branch in plan-switch.ts and the tui.question.plan_enter.* block from all seven locales — acceptance: rg "plan_enter" src/cli/cmd/tui returns nothing; no locale file is left with a dangling comment header or a double blank line; plan_exit switch mapping still returns "build" (covers: S3; depends: T1)
  • T4: correct prompt/default.txt — acceptance: no plan-enter in the tool list; item 5 of "Plan mode in detail" states the user switches modes, forbids unprompted suggestions to switch, and keeps plan_exit as the model's request path; the "Enter plan mode for non-trivial implementation work" paragraph is gone with no behavioural replacement (covers: S2)
  • T5: make mimocode-docs answer "how do I enter/leave plan mode" — acceptance: the frontmatter description carries mode/keybinding vocabulary so the question routes to the skill; SKILL.md and reference/commands.md both state that entering is a user gesture (Tab / agent dialog), that no tool enters plan, that plan_exit is the agent's only move, and that the agent will not raise plan mode unasked (covers: S2, S3)
  • T6: update the five affected test files and add test/tool/plan-enter-absent.test.ts — acceptance: no test asserts a deleted tool is available; the historical-part guard in plan-switch.test.ts proves a replayed plan_enter part no longer switches modes; the new test fails if plan_enter is re-registered (covers: S3; depends: T1, T2, T3)
  • T7: verification band — acceptance: the S3 test bands, bun typecheck, and git diff --check all pass from packages/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: false to metadata — assert with toMatchObject, not toEqual.
  • Question.Service.reject takes a bare requestID, unlike reply which 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 RejectedError for dismissal originates inside question.ask's deferred, not from the removed answer === "No" re-throw.
  • The continuation reminder was first written as 3 lines; user flagged token cost on chatty planning sessions — compressed to one line.
  • insertReminders runs 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 guard messages.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.ts throws Question.RejectedError, whose message is the intent-free "The user dismissed this question". processor.ts:377 then sets ctx.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 the edit tool is hard-blocked).
  • Subsequent plan turns — the full plan-mode system-reminder is injected only on the transition into plan mode (prompt.ts:810 returns 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 the question tool 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 call plan_exit again when ready.
  • Esc (dismiss): unchanged. The RejectedError for Esc originates inside question.ask itself (deferred failure), not from the removed answer === "No" re-throw, so turn-stop behavior for Esc is preserved automatically.
  • Loop consequence: with no error thrown on "No", processor.ts:377 no 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 (question tool) or calling plan_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_deny behavior.
  • 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 PlanExitTool and PlanEnterTool (packages/cli/src/tool/plan.ts) — acceptance: replying "No" to either tool resolves successfully with switched: false and 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 with QuestionRejectedError; covered by new unit tests under packages/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 is plan and previous assistant message agent is plan, 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 typecheck passes in packages/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.plugin build.module for a bare specifier wins over node_modules both under bun run and in bun build --compile binaries; no opentui-style specifier rewriting is needed.
  • Bun.build ignores runtime Bun.plugin registrations; mark the SDK external and let the runtime import resolve it.
  • Filesystem.contains fails open across Windows drives (path.relative returns an absolute path); its other call sites still have this.
  • Embedders shim part of Bun on Node (Desktop defines only Bun.file / Bun.write), so typeof Bun is not a runtime test; probe Bun.plugin. test/plugin/host-node.test.ts bundles host.ts for Node and runs it with and without such a shim.
  • Follow-ups: drop the always-false loader retry argument; host-SDK tests need outsideGit: true, since tmpdirs under packages/cli resolve the workspace node_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 its source path lies inside Flag.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.loadExternal applies 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 inside Flag.MIMOCODE_CONFIG_DIR. It applies to file-hook discovery.
  • When the policy is active and MIMOCODE_CONFIG_DIR is 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_modules imports tool from @mimo-ai/plugin and registers a tool.
  • With a fake node_modules/@mimo-ai/plugin next to the plugin, the plugin receives the host module.
  • A file hook with a value import from @mimo-ai/plugin loads with no node_modules.
  • With MIMOCODE_ONLY_CONFIG_DIR_PLUGINS=1, a plugin declared in MIMOCODE_CONFIG_DIR loads and a project-local plugin does not. With the flag set and MIMOCODE_CONFIG_DIR unset, no external plugin loads.
  • Config loading leaves no package-lock.json or node_modules/@mimo-ai/plugin behind (existing plugin-dependencies tests).

[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 retry argument 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-dependencies tests pass and no config-dir npm install remains in config.ts or tui.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 in PluginLoader.loadExternal and 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.
  • InstallationLocal is channel-based (=== "local"), not version-based — desktop runs channel=latest + non-semver version, so both flags matter for the pin decision.
  • npm install stringifies as [name, version].filter(Boolean).join("@"); undefined correctly 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:

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.ts
  • packages/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 (checkPluginCompatibility already skips non-semver host versions).

Tasks

  • T1: Add pluginSdkNpmVersion pure helper + PluginSdkNpmVersion binding in installation/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.ts and tui.ts @mimo-ai/plugin install sites at PluginSdkNpmVersion — acceptance: neither site passes InstallationLocal ? undefined : InstallationVersion anymore; no leftover unused imports (covers: S2; depends: T1)
  • T3: Run package typecheck + targeted unit tests — acceptance: bun typecheck and the new plugin-sdk-npm-version tests pass from packages/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 of
698f0f29..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
    for headers, lifecycle, oauth-auto-connect, oauth-browser, and
    stdio-exit-observe passed 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):

    sh
    bun 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 120000
  • Root bun run typecheck passed. Root bun run lint reported 3,287 warnings
    and 0 errors; this is not a warning-free lint result.

  • SDK generation and bun tsc -b packages/sdk/tsconfig.json --force passed.
    Generation also surfaced unrelated existing SDK drift, which is excluded
    from this change.

  • bun script/build-node.ts from packages/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 --check passed.

  • 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

  1. Keep refresh limited to model settings. Removing extra credential and MCP
    registration mechanisms kept the change within the requested scope.
  2. 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.
  3. Preserve cold state and reuse initialized plugin hooks; refresh must not
    accidentally run full configuration or plugin initialization.
  4. 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.
  5. 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 30000 before 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 30000 after 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 typecheck on 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 --check at 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

  1. 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.
  2. 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.
  3. Keep the small actor abandonment update: actor waiters depend on its persisted terminal status.
  4. Public part writes refresh actor activity. Crash fixtures must backdate the actor after seeding messages and parts.
  5. 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 typecheck in packages/cli — PASS
  • bun 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-types extension lookup, not content sniffing — .ts → video/mp2t is a known IANA collision with TypeScript.
  • Feature lineage: audio/video attach landed 2026-09-12 (049fe862, cca753c0); image/PDF earlier; sniffing 7fa302e3 (2026-09-07).
  • User chose a stricter-than-MiMo allowlist (no BMP/FLAC/M4A/OGG/MOV/AVI/WMV) over a modality tool 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
PDF 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:

  1. MIME in a finite list → existing size / model-capability / adapter-declaration gates, then attach.
  2. 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 / .mts fix).
  3. 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.txt stays 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 modality parameter to read.
  • Changing AppFileSystem.mimeType / mime-types itself.
  • Broadening or narrowing provider capability declarations in capability-registry.ts.
  • prompt-side user attachments, MCP sampling, or view_image MIME policy.
  • Supporting MPEG-TS video (.ts/.m2ts) even when binary — callers convert to video/mp4 first.

Tasks

  • T1: Export finite read-attachment MIME allowlists and membership helpers from util/media.ts — acceptance: helpers return true only for listed MIMEs; video/mp2t is not a read image/audio/video/pdf mime (covers: S2)
  • T2: Gate tool/read.ts media 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: .ts TypeScript source reads as text; .webm binary still refuses with convert hint (covers: S2; depends: T1)
  • T3: Add/adjust regression tests in test/util/media.test.ts and test/tool/read.test.ts — acceptance: tests cover .ts/.mts as 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-launcher retired npm platform packages — npm now publishes SDK/plugin + the @mimo-ai/cli launcher 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-thenable warnings on await expect(...).rejects, the pattern used elsewhere in the repo.
  • Full MIMOCODE_VERSION=v0.1.15 bun script/release.ts build in a throwaway clone with HEAD tagged — PASS: 12 archives and 15 tarballs, checkout clean afterwards; sdk/plugin/cli/platform package.json fields match the published 0.1.15 on npm; darwin-arm64 binary is ad-hoc signed like v0.1.15 and prints 0.1.15.
  • Independent review — spec compliance met for T1–T5, no critical findings. Adopted: release environment gate, CONTRIBUTING notes on manual start from the tag and one release at a time. Accepted as-is: npm view network errors fall through to a failing publish (retry resolves), an already-published GitHub Release is skipped without asset checks, the mirror-sync log prints the v prefix.

Not run: a real tag-triggered release, OIDC publication, FDS upload. These need the trusted publishers configured on npmjs.com first.

Journey log

  1. bun pm pack already rewrites workspace: and catalog: specifiers; only exports needs moving from src to dist, so packing stays in place with a restore in finally.
  2. v0.1.15 was built on macOS (ad-hoc, linker-signed darwin binaries, clean tarballs); building on macos-latest keeps that rather than trusting cross-signing from Linux.
  3. Bun's $.cwd() mutates the shared shell; tests need new $.Shell().cwd(dir).
  4. 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 tag vX.Y.Z on that main commit. The tag push starts .github/workflows/release.yml.
  • workflow_dispatch with 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

mermaid
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]
  1. verify (ubuntu): bun script/release.ts verify. Fails unless the ref is a tag matching vMAJOR.MINOR.PATCH (no prerelease/leading zeros), HEAD is the tag commit and is reachable from origin/main, and packages/{cli,sdk,plugin,shared}/package.json versions all equal the tag version.
  2. checks: the existing typecheck, lint and test workflows gain a workflow_call trigger and run against the tag commit.
  3. build (macos-latest, matching how past releases were produced: darwin binaries ad-hoc signed by the native toolchain): bun script/release.ts build re-runs verify, builds all 12 targets via packages/cli/script/build.ts --archive, compiles SDK/plugin from committed sources with tsc (never the SDK generator), packs 15 npm tarballs, writes dist/release/{archives,npm}/, then asserts the checkout has no tracked or untracked changes. The directory is uploaded as one Actions artifact.
  4. 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 release vX.Y.Z with --verify-tag --generate-notes and only the 12 archives. If a draft exists (earlier partial attempt) upload with --clobber and publish it; if a published release exists, skip.
    • fds — upload the 12 archives to releases/vX.Y.Z/, then write releases/latest last.
    • npm cli — 12 platform packages, then the @mimo-ai/cli wrapper.
    • script/sync-registry.ts mirror sync with continue-on-error.

npm publication

  • Trusted Publishing (OIDC) only: no NPM_TOKEN secret. Each of the 15 packages must have a trusted publisher configured on npmjs.com for repository XiaomiMiMo/MiMo-Code, workflow release.yml, environment release; bun script/release.ts trust registers it for every published package with npm 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 installs npm@11 and 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 pack resolves workspace:* / catalog:; the script temporarily rewrites SDK/plugin exports from ./src/*.ts to ./dist/*.{js,d.ts}, copies README_npm.md and LICENSE, and restores the original files afterwards. After packing, the tarball's package.json must have the expected name/version, no workspace:/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> replaces script/release.ts, script/version.ts, script/publish.ts and packages/{cli,sdk,plugin}/script/publish.ts. Version comes from MIMOCODE_VERSION. script/fds-upload.ts and script/sync-registry.ts stay.
  • packages/cli/script/build.ts no longer uploads anything; --archive produces the archives. MIMOCODE_RELEASE and Script.release are removed.
  • .github/actions/setup-bun pins Bun via bun-version-file: package.json (build.ts refuses any other Bun) and installs with bun 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.ts with verify/build/github/fds/npm stages and delete the replaced scripts — acceptance: bun script/release.ts verify rejects 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, drop MIMOCODE_RELEASE — acceptance: host --single --archive build 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_call on 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.

  1. Tarballs present and coherent — for each package in npmGroups.libs + npmGroups.cli (@mimo-ai/sdk, @mimo-ai/plugin, @mimo-ai/cli), require dist/release/npm/<tarball> to exist and its embedded package/package.json name/version to match the release version. Fail with the full missing/mismatched list. This reuses the same name/version rule pack() enforces at build time.
  2. FDS usable — fail fast unless MIMO_FDS_AK and MIMO_FDS_SK are set. Then prove signing + network + bucket with one small canary upload (releases/v{version}/.preflight-ok, body = version text). Upload uses the existing putObject path (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 by releases/latest.
  3. Mirror list complete — script/sync-registry.ts PACKAGES includes @mimo-ai/sdk and @mimo-ai/plugin alongside @mimo-ai/cli (legacy platform package names stay for historical mirror completeness).
  4. Step label — the publish step that runs bun script/release.ts npm cli is 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.md beyond what this change requires.
  • Making China mirror sync a blocking release gate.

Tasks

  • T1: Add preflight stage in script/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 preflight as the first publish step in .github/workflows/release.yml and rename the npm cli step label (covers: S2; depends: T1)
  • T3: Add @mimo-ai/sdk and @mimo-ai/plugin to script/sync-registry.ts PACKAGES (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 --noEmit clean)
  • bun test test/tool/edit.test.ts — PASS, including no-prior-read, write-then-edit, and edit-create-then-edit cases
  • bun test test/tool/notebook-edit.test.ts — PASS, notebook_edit replace without prior read
  • bun test test/tool/write.test.ts / memory-edit-ask-skip.test.ts — PASS
  • Production grep for assertFileRead / tool/read-state under packages/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 —

  1. The gate was introduced in cb633947 (PR #1243) to turn edit.txt usage notes into RecoverableError enforcement for existing-file edits.
  2. Enforcement never covered write/apply_patch; write.txt still claimed hard failure that the tool never implemented — docs were ahead of code.
  3. 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: edit still exact-matches current disk contents.
  4. 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.
  5. This session's own Edit tool still enforced the desktop read-before-edit rule when patching worktree .txt files after only reading main-worktree copies — the path-mismatch problem reproduced live during implementation.
  6. Confirmed on base e485a2a5: read-state.ts only matches part.tool === "read"; write.ts and the create branch of edit never 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:

  1. Models increasingly inspect files via bash (cat / sed / python), which never records a read tool part, so the subsequent edit fails with a recoverable error and burns a turn.
  2. 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.
  3. Compaction / history loss can drop the original read parts, re-triggering the gate on a file the model has already seen.
  4. Self-authored files never count as read. assertFileRead only accepts completed tool === "read" parts; neither write nor edit (including old_string="" create) synthesizes a read-state entry. So write then edit, 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 assertFileRead from edit (including the create-file exemption comment) and notebook-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. Softening write.txt is 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.txt tool-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 write enforce 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 read tool call in the session (covers: S2)
  • T2: Delete read-state module and fix leftover references — acceptance: no production import of assertFileRead / read-state remains; tool .txt files no longer promise a hard failure (covers: S2)
  • T3: Verify with typecheck + focused tool tests — acceptance: bun typecheck passes from packages/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 typecheck in packages/cli — PASS
  • bun 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 —

  1. First analysis mis-identified the target as SubagentProgressCheckerPlugin /
    actor.postStop; user corrected to the pre-postStop completion gate (TaskGate).
  2. Passive downgrade was initially kept ("提示 primary 的应该可以留"); user later
    judged it also useless and asked to delete it together — final scope is full
    TaskGate removal.
  3. postStop must not replace the delivery body (TP-R14-11): finalText /
    parseReturnHeader target the preserved deliveredText, while hooks see
    lastFinalText only as input.

[S1] Problem

When a gate-eligible subagent finished, TaskGate re-entered the agent (up to
MAX_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's
finalText 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

  1. task/gate.ts (module deleted) and its tests.
  2. TaskGate re-entry loop in actor/spawn.ts (nudge + re-emit + deliveredText
    overwrite + gateFailed + MAX_TASK_GATE_SUBAGENT_REACT).
  3. Passive incomplete-task reporting: reportedStatus downgrade from task DB,
    AgentOutcome.incompleteTasks, **Incomplete tasks** body suffix.
  4. gateEligible plumbing into forkWork (no longer needed there).

Preserved

  • reportedStatus / reportedSummary parsed 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_INSTRUCTION injection and parseReturnHeader (waiter / group /
    notification consume them independently). completionGate config still
    selects which agents get the Status/Summary instruction.
  • SubagentProgressCheckerPlugin / actor.postStop / written-at / checkpoint
    reconcile / preStop / task_id auto-start / memory-path-guard.

Behaviour after removal

A subagent leaves owned tasks open/in_progress → returns the original
finalText 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_id binding / auto-start.
  • preStop / splitover / goalGate.

Tasks

  • T1: Delete TaskGate re-entry + passive reporting from spawn.ts —
    acceptance: no gate-driven runAgentLoop; delivery body never rewritten;
    reportedStatus is header-only; incompleteTasks gone. (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 typecheck in
    packages/cli passes; 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/opencode outside 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/opencode wording out of bulk-replace sweeps, or rewrite the doc after path sweeps.
  • bun.lock must be path-patched before postinstall can run; root scripts still pointed at the old cwd and failed first bun ci.
  • Workspace globs packages/* meant no package.json workspaces 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 still
packages/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 without
package.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 to
packages/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 Checking entirely and the "how to run tests" bullet under ## Testing — root typecheck / package test scripts 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/shared bin key opencode
  • migration journal contents under packages/cli/migration/ (only the
    directory moves; do not edit shipped migration.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 / opencode bin entries)
  • Reworking historical narrative in specs beyond path-token replacement
  • Provider / external-import / MCP origin identifier changes
  • Touching shipped migration/*/migration.sql content
  • Web/Desktop surfaces (already unmaintained)

Tasks

  • T1: git mv packages/opencode packages/cli and fix bun.lock workspace paths — acceptance: directory is packages/cli; bun ci installs with frozen lockfile; @mimo-ai/cli still 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.yml all reference packages/cli and none reference packages/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/opencode across repo is empty outside intentional non-directory identifiers; AGENTS/CONTRIBUTING examples use packages/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 typecheck PASS; focused tests in packages/cli PASS; residual packages/opencode path 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 —

  1. Tips copy direction came from mimo-desktop composeDesc / skill brief after the user rejected "instead of Compose" framing and showed the 编排模式 menu card; name stays compose-next in all languages, never translated to 「编排模式」.
  2. Historical compatibility is deliberately narrow: only message-read sites use resolveStoredAgent; Agent.get keeps throwing for live requests so typos still surface.
  3. Local test red on skill.test.ts looked like a regression until compared against main and 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 compose primary agent from packages/cli/src/agent/agent.ts (definition, compose:* skill allow override, list() sort key).
  • Drop the default-agent skill: { "compose:*": "deny" } rule once no bundled compose:* skills remain. User-defined skills that merely start with compose (no colon namespace) are unaffected.
  • Remove TUI Compose special cases: skill-dialog / autocomplete compose:* filtering gated on agent.name === "compose", prompt chrome that branches on compose, and mode-lock wording that mentions Compose isolation.
  • Remove session/prompt/compose.txt and the PROMPT_COMPOSE injection 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.js and its tests (test/workflow/compose.test.ts, compose rows in test/workflow/builtin.test.ts).
  • Remove the input.name === "compose" special case in packages/cli/src/tool/workflow.ts that 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 extractComposeBundle usage and the scope: "compose" scan from packages/cli/src/skill/index.ts.
  • Remove isComposeSkill filtering from packages/cli/src/skill/search.ts and the compose: localized-alias exclusion.
  • Remove MIMOCODE_DISABLE_COMPOSE_SKILLS from packages/cli/src/flag/flag.ts and 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 the compose field from Config.Info. Consumers today are only the Compose prompt injection and the Compose workflow.
  • compose-next keeps its documented default artifact path docs/compose/spec/<feature>.md as a skill convention, not a config key.
  • tui.tips.compose_next joins 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 /login override 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
  • 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
  • Slash display name (tui.skill.compose-next.slash): localized names are fine; not forced to the English id.
    • zh: 新编排模式 · zht: 新編排模式

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 (processor assistant agent, compaction parent agent, prompt lastUser.agent / resume / title-adjacent reads). Explicit Agent.get for a live request still errors with Agent not found.
  • Implementation: a small resolve helper used at those call sites — agents.get(name) then fall back to agents.defaultAgent() when lookup is empty. Do not change Agent.get itself.
  • promoteComposeProtocolFirst / COMPOSE_REMINDER_MARKER stay 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 promoteComposeProtocolFirst or 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.txt prompt injection — acceptance: Agent.list() has no compose; build/plan no longer carry compose:* rules; TUI has no compose branch; bun test packages/cli/test/agent passes (covers: S2.1; S2.6)
  • T2: Remove Compose workflow builtin + workflow.ts docs-dir special case — acceptance: BuiltinWorkflow has no compose entry; bun test packages/cli/test/workflow passes (covers: S2.2)
  • T3: Delete skill/compose/ bundle + extraction/search/alias/flag hooks — acceptance: no compose:* skills load; MIMOCODE_DISABLE_COMPOSE_SKILLS gone; bun test packages/cli/test/skill passes (covers: S2.3)
  • T4: Delete ConfigCompose and config.compose field — acceptance: config schema has no compose key; typecheck clean (covers: S2.4)
  • T5: Tips carousel + i18n copy — acceptance: compose_next is in buildTipKeys; pickDisplayKey has no compose lock; copy names compose-next and reads as capability in all 7 locales; bun test packages/cli/test/cli/cmd/tui/tips-view.test.ts passes (covers: S2.5)
  • T6: Historical unknown-agent fallback on message-read paths — acceptance: a session whose info.agent is compose (or any unknown name) resumes on the default agent without Agent 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

Report

What was built — The right sidebar's two overlapping state variables collapse into one
persisted tri-state SidebarPreference, normalised on every toggle so that a result the
terminal width would have chosen anyway is stored as auto. The sticky state that kept a
manually-expanded sidebar visible in terminals too narrow to dock it is now
unrepresentable, rather than cleared by a resize handler. An expanded sidebar always offers
a collapse control — the narrow-terminal overlay used to paint over the only one — while a
collapsed sidebar offers an expand control only where it can dock, and a subagent view
offers neither and cannot write the preference. contentWidth stops reserving the
sidebar's columns in overlay mode, where the sidebar takes no layout space.

Mouse activation for the sidebar toggle and the voice control moves behind
ui/press.ts's createPress, which takes a stable click only: press and release on
the element with no out in between. This replaces onMouseUp-only handling, which
opentui fires on whatever sits under the cursor when a drag captured elsewhere ends there —
the scrollbar-drag mis-fire that prompted the work. The gate is a narrow opt-in, not a
migration target: plain onMouseUp remains correct for the other ~127 call sites, and its
entry criterion is a control where an accidental activation is itself the defect.

Verification — bun typecheck in packages/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 throwaway
testRender probes.

Journey log

  • Three review rounds each found a real CRITICAL, and the third one found a defect the
    second round's own fix had introduced. When a fix keeps reopening its own area, the
    problem is usually the model, not the patch: this gate was accumulating disarm hooks to
    approximate browser click semantics against a dispatcher that does not supply the events
    for it.
  • The product owner cut that knot by naming the contract instead of the mechanism — stable
    click, leaving discards, don't care about the rest. That reverted a fix (d0bb626b →
    50fee2d1) and turned a reviewer's CRITICAL into an asserted contract. The asymmetry is
    the whole point: a dropped click is a non-event the user repeats, an unintended one is the
    bug. Encode such a rule as a test, or the next contributor "fixes" it.
  • The contract then had to be read precisely, because "no out" and "never left" are not
    the same predicate here. Reverting wholesale also discarded clicks that merely drifted
    inside the control, which the contract never asked for. Restoring the geometric check
    turned out to cost nothing: the mis-fire it had been reverted for only reproduces when a
    press arrives with no preceding pointer motion, which no real pointer does. The lesson is
    that the earlier revert leaned on a reviewer's synthetic repro without asking whether the
    input sequence was reachable — measure the cost of a guard before paying for it.
  • Two hypotheses died to cheap experiments. Unrestored spyOns looked like the obvious
    cause of the pre-existing failure until reading the file showed mockRestore() in a
    finally; a static-plus-dynamic double import of a text-loaded module looked like the
    Bun bug until a 4-file standalone repro refused to reproduce. Both cost minutes and
    saved a wrong fix.
  • rg -rn is --replace, not recursive — it silently rewrote match output mid-investigation
    and briefly made builtin.ts look like it imported ./builtin/n.js.
  • The upstream-facing half of that failure (a Bun text-import/ESM loader collision) is
    parked with a worktree and no commits; CI is green because it shards test files across
    four processes, so the two files that collide rarely share one.

[S1] Problem

Two independent mouse/layout defects in the session TUI.

S1.1 — sidebar survives a shrink it cannot fit into, with no way to close it.
routes/session/index.tsx carried two overlapping pieces of sidebar state: a persisted
sidebar: "auto" | "hide" (index.tsx:199) and an in-memory sidebarOpen signal
(index.tsx:200). Visibility was

text
sidebarVisible = agent === "main" && (sidebarOpen || (sidebar === "auto" && wide))

sidebarOpen short-circuited ahead of the wide term and nothing ever cleared it.
Collapsing then re-expanding on a wide terminal left sidebarOpen === true for the rest
of the session, so shrinking below the width > 120 threshold no longer auto-hid the
sidebar: it flipped to the narrow full-area overlay branch (index.tsx:1497-1509), which
paints over the 3-column toggle button (index.tsx:1480-1491) because the overlay is the
later sibling with no zIndex. The user saw a sidebar that pops out with no visible
control. Reproducible every time after one collapse/expand cycle; a fresh session reset
sidebarOpen and looked fine, which is why it read as intermittent.

The overlay buried the toggle in every narrow case, not just the sticky one — opening
via Ctrl+X B on a narrow terminal produced the same unclosable-by-mouse sidebar. A
throwaway testRender probe confirmed the layering: without zIndex the button's glyph
is absent from the captured frame entirely.

contentWidth (index.tsx:239) also subtracted the sidebar's hardcoded 42 columns even in
overlay mode, where the sidebar takes no layout space. Below 46 columns that drove it
non-positive.

S1.2 — scrollbar drags mis-trigger neighbouring buttons. The sidebar toggle
(index.tsx:155) and the voice control (component/prompt/index.tsx:1834-1851) fired on
onMouseUp alone, with no record of where the press began. @opentui/core's renderer
dispatches a bare up to the renderable under the cursor in addition to delivering
drag-end/up to the captured renderable, because the captured-up branch has no
return before the generic dispatch at the end of handleMouseEvent. Dragging the
transcript scrollbar — which becomes the captured renderable via SliderRenderable's
onMouseDown/onMouseDrag — and releasing with the cursor drifted onto an adjacent
button therefore activated that button. The button also receives over during the drag,
so its hover highlight lights up and the mis-fire looks intentional. Confirmed with a
baseline testRender + mockMouse probe: an onMouseUp-only button fires when a drag
captured on a neighbour is released over it.

Two dispatch details constrain any fix:

  • Releasing inside a captured renderable delivers up twice (once from the captured
    branch, once from the generic dispatch), so a press gate must consume its armed state
    exactly once.
  • A captured renderable never receives out (the dispatcher guards with
    lastOverRenderable !== capturedRenderable), so "press the button, drag away, release
    outside" cannot be detected by hover tracking alone and needs a geometric bounds check
    at release time.

[S2] Design

[S2.1] One tri-state preference, normalised on toggle

sidebarOpen is deleted. The persisted preference widens to
SidebarPreference = "auto" | "show" | "hide" and all logic moves into two pure
functions in routes/session/sidebar-state.ts:

ts
export function sidebarVisibleFor(preference: SidebarPreference, wide: boolean) {
  if (preference === "auto") return wide
  return preference === "show"
}

export function sidebarToggle(preference: SidebarPreference, wide: boolean): SidebarPreference {
  const next = !sidebarVisibleFor(preference, wide)
  if (next === wide) return "auto"
  return next ? "show" : "hide"
}

The normalisation is the fix for S1.1: a toggle whose resulting visibility matches what
the width would have picked anyway stores auto rather than an override. A
collapse/expand round-trip on a wide terminal therefore ends at auto, and a later
shrink hides the sidebar again. No resize effect is needed — the sticky state cannot be
represented.

An explicit expand on a narrow terminal still yields show, which deliberately survives
further shrinking: the user asked for it, so it stays until they collapse it. Collapsing
it lands back on auto by the same rule. Existing "auto" / "hide" values on disk
remain valid, so no kv migration is required.

Both toggle call sites (sidebar_toggle command and the button) collapse to
setSidebar(() => sidebarToggle(sidebar(), wide())), removing the duplicated two-signal
update and the batch it needed.

[S2.2] Toggle affordance rules

  • Expanded → a collapse control at any width.
  • Collapsed → an expand control only when wide enough to dock.
  • Subagent views → no control at all, and the sidebar_toggle command disabled.

The render condition sidebarVisible() || wide() already expressed the first two; what was
missing is that the narrow overlay painted over the button. Rather than raise the button
above the overlay, it now rides inside it as a right-aligned row sibling placed before the
panel. That keeps one invariant across both modes — the control sits immediately to the left
of the sidebar — instead of the control appearing left of the panel when docked and on the
panel's right edge when overlaid. It also removes the zIndex the raised version needed, and
simplifies the in-flow gate to sidebarAllowed() && wide() since the overlay now owns its
own control. Verified by comparing captured frames: the glyph occupies the same column and
the sidebar starts at the same column in both modes, with exactly one control rendered.

The third rule is new. sidebarVisible() was already gated on currentAgentID() === "main"
while sidebarToggle is width-only, so on a subagent view the control offered "expand"
and a click wrote "hide" — persisting a hidden sidebar for the main view. That gate is
now a named sidebarAllowed() memo used by the panel, the button's Show, and the
command's enabled. main rendered a dead button there (it mutated state but the agent
gate suppressed any visible effect); the control is removed rather than made to work,
since the sidebar itself cannot appear on a subagent view.

contentWidth now subtracts the sidebar only when docked (sidebarVisible() && wide()),
using a shared SIDEBAR_WIDTH constant exported from sidebar.tsx instead of a second
hardcoded 42. This removes the overlay-mode reflow and lifts the non-positive-width
threshold from "below 46 columns" to "4 columns or fewer" — not a floor, just narrow
enough to be unreachable; no clamp was added for a terminal that small. The sidebar itself
clamps to Math.min(SIDEBAR_WIDTH, dimensions().width) so it cannot overflow a terminal
narrower than itself.

[S2.3] Stable-click gate

New ui/press.ts exporting createPress(onPress: () => void), returning a hover
accessor plus a spreadable prop bag. The contract is a stable click: the press and the
release both land on the element and the pointer never leaves its bounds in between, once
per press. Movement within the element is fine.

That asymmetry is the design. A dropped click is a non-event the user repeats; an
unintended activation is the defect this exists to prevent, so every ambiguity resolves
toward not firing. Browser semantics — where the pointer may leave the element and return
and still produce a click — are explicitly not the goal.

"Left the element" has to be decided geometrically rather than from the event name, because
opentui raises out and over on intra-element hit-target changes as well: a child glyph
and the box's own cells are separate hit targets, and both events bubble to the parent, so
the parent sees out while the pointer is still inside it. MouseEvent.target does not
settle it either, since an out is dispatched to the element being left — which is that
same child both when the pointer merely crosses an internal boundary and when it exits the
control entirely. The coordinates do settle it: out carries the pointer's new position.

  • onMouseDown arms only if the press coordinates fall inside the element's rect.
  • onMouseOut and onMouseOver disarm only when the event's new position is outside the
    rect; onMouseDrag disarms when the drag lands outside; onMouseDrop disarms because a
    drop means a drag captured elsewhere ended here.
  • onMouseUp returns early when unarmed, disarms before anything else (the duplicate up
    delivered inside a captured renderable is then inert), rejects releases carrying
    isDragging (opentui sets that only on its two selection dispatches, so it identifies a
    release closing a text selection — which never gets a preceding drop), and re-checks
    the release coordinates against the rect.
  • onMouseOver/onMouseOut also drive the returned hover accessor, because the gate
    must own onMouseOut and callers cannot register a second handler for it.

One limitation is accepted and documented rather than worked around: a press that arrives
with no preceding pointer movement onto the element cannot be disarmed when it drags away,
because opentui then delivers the element no event at all for that press — it is too narrow
to become the capture target, and lastOverRenderable was never pointed at it. This was
measured rather than assumed: with a realistic hover-then-press sequence the drag-off does
deliver an out and disarms, and only a synthetic press with no prior motion reproduces the
stale arm. Real pointers always generate that movement first.

Consumers must render unselectable content (selectable={false} on any <text>).
Otherwise the element's own press starts a text selection, every release arrives with
isDragging, and the control is silently dead. Stated in the exported doc comment.

Bounds come from the renderable captured through ref; Renderable exposes absolute
x/y/width/height in the same coordinate space as MouseEvent's x/y.

[S2.4] Adoption, and where this must NOT spread

SidebarToggleButton and the voice control consume createPress. The voice control's
five Match branches share one gate instance created outside the Switch; the
non-interactive finishing branch keeps no handlers. Both render unselectable glyphs.

The gate is deliberately not a general replacement for onMouseUp, and the remaining
~127 onMouseUp sites are not queued for migration. Handling only up is correct for the
great majority of controls; routing one of them through the gate buys nothing and costs it
dropped clicks. The entry criterion is a control where an accidental activation is itself
the defect — in practice, one adjacent to a drag surface (a scrollbar, selectable
transcript text) whose action the user cannot casually undo.

[S3] Out of Scope

  • Migrating the other ~127 onMouseUp handlers to the press gate. Not a backlog item: see
    [S2.4] — most controls should keep plain onMouseUp.
  • Subtracting the toggle button's 3 columns from contentWidth — a pre-existing
    discrepancy; changing it would reflow every transcript.
  • Patching the upstream @opentui/core dispatch bug.
  • The pre-existing test/cli/tui/thread.test.ts failure and the workflow-builtin .js
    load error it surfaces when the suite runs as a batch.

Tasks

  • T1: Replace the two-signal sidebar state with sidebarVisibleFor/sidebarToggle in routes/session/sidebar-state.ts and wire both toggle sites — acceptance: a wide collapse/expand round-trip normalises to auto so a later shrink hides the sidebar; an explicit narrow expand persists (covers: S2.1)
  • T2: Add createPress in ui/press.ts — acceptance: press outside + release inside does not fire; press inside + release inside fires exactly once; press inside + release outside does not fire (covers: S2.3)
  • T3: Raise the toggle above the overlay, share SIDEBAR_WIDTH, clamp the sidebar, and adopt createPress in the toggle and the voice control — acceptance: the collapse glyph renders and is clickable with the overlay up; contentWidth stays positive at every reachable width (covers: S2.2; S2.4; depends: T2)
  • T4: Regression tests plus typecheck — acceptance: testRender + mockMouse proves the captured-drag mis-fire is gated out, the state model is covered by pure tests, and bun typecheck passes (covers: S2.1; S2.3; depends: T1, T3)
  • T5: Gate the control and the sidebar_toggle command on sidebarAllowed() — acceptance: a subagent view offers no sidebar affordance and cannot write the preference (covers: S2.2)
  • 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 with
MIMOCODE_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 brand
MIMOCODE_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

  1. Codex skills/.system is a SYSTEM install cache (marker
    .codex-system-skills.marker), not user skills — dot: false is enough;
    no marker special-case.
  2. Flat agents > all brands would break project-over-home layering; keep
    layered scope and document it instead of forcing a total order.
  3. 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 with
skills/**/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:

  1. dot: true on external scans — any dotted segment under skills/
    (.system, .trash, .hidden) is treated as a skill container.
  2. All brand roots default-on — .claude / .codex / .opencode are
    scanned unless an env kill-switch is set. OpenCode's fork-era
    *_DISABLE_CLAUDE_CODE* / MIMOCODE_MIMO_ONLY gates are a blunt Claude
    inheritance switch, not a skill-root policy. Desktop already defaults to
    open-standard agents only via skillPathCompat, 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 in
packages/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):

text
scan .agents    ⇔  ¬ DISABLE_AGENTS_SKILLS
scan .claude    ⇔  ENABLE_CLAUDE_CODE_SKILLS
scan .codex     ⇔  ENABLE_CODEX_SKILLS
scan .opencode  ⇔  ENABLE_OPENCODE_SKILLS

Implementation 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 and
skills 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:

  1. builtin bundle → compose bundle
  2. home brand roots in EXTERNAL_DIRS order
    [.claude, .codex, .opencode, .agents] (agents last → wins among home brands)
  3. project brand roots via fsys.up (targets in the same order per ancestor;
    parent ancestors load after nearer dirs)
  4. .mimocode / config skill dirs
  5. skills.paths, then skills.urls

Consequences (accepted):

  • Within one scope, .agents beats brand copies of the same name.
  • A project brand skill can still beat a home .agents skill (more
    specific scope). Do not claim a total order agents > all brands across
    scopes.
  • .mimocode and explicit skills.paths/urls beat 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:

ts
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 same
skillPathCompat 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*.md env tables and mimocode-docs reference/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
    ~/.claude or ~/.codex (in the PR body; optional one-line README note).

[S3] Out of Scope

  • Claude reserved name synced (any capitalization).
  • Codex .codex-system-skills.marker special-case (covered by dot: 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-level skills/*.
  • Writing into or relocating Codex's .system cache.
  • OpenCode upstream changes.

Tasks

  • T1: External skill scans use non-dot glob matching — acceptance: with a
    fixture ~/.codex/skills/.system/x/SKILL.md and
    ~/.codex/skills/user-skill/SKILL.md plus
    MIMOCODE_ENABLE_CODEX_SKILLS=true, discovery lists only user-skill;
    ~/.claude/skills/.trash/y/SKILL.md is likewise invisible when claude is
    enabled. (covers: S2.1)
  • T2: Flip engine external-root defaults and replace env gates —
    acceptance: default discovery includes .agents and excludes
    .claude/.codex/.opencode; each MIMOCODE_ENABLE_*_SKILLS opts in only
    its root; MIMOCODE_DISABLE_AGENTS_SKILLS drops agents; setting
    MIMOCODE_DISABLE_EXTERNAL_SKILLS or brand MIMOCODE_DISABLE_*_SKILLS
    does not change the predicate; MIMOCODE_MIMO_ONLY /
    MIMOCODE_DISABLE_CLAUDE_CODE do 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 including
    ENABLE_OPENCODE_SKILLS, agents disable, dotted-dir negative, and existing
    brand-discovery cases pass with ENABLE_* set where they expect brand roots.
    (covers: S2.1; S2.2; S2.3; depends: T2)
  • T4: Align Desktop engineSkillScanEnvFromCompat and related unit tests —
    acceptance: default prefs inject {}; open brand injects only its
    MIMOCODE_ENABLE_*_SKILLS; all-off injects only
    MIMOCODE_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-invocation design 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 from
skill_search, and the skill tool refuses to load it with an error that
points at the user's slash command instead of dead-ending. /name typed by the
user is untouched. The field name is kebab-case to match Claude Code and the
agentskills.io standard; internally it is Info.disable_model_invocation.

Mechanically this is one new registry accessor, Skill.modelInvocable(agent?)
= available(agent) minus the flag, feeding the three model-facing call sites,
while available() and all() stay as the user-facing sets. The dead
Skill.Info.hidden field, parsed but never read since PR #1725, is gone.
compose-next graduated onto the new field: its exact deny rule is deleted,
its SKILL.md sets the flag, and both its description and body now state that
the workflow starts only on explicit user invocation — belt and braces, so it
still behaves if the flag is ever removed. skill-creator and its frontmatter
reference document the field for skill authors; mimocode-docs records that
/compose-next is user-only, which is the channel through which a model learns
the skill exists at all.

Verification — all from packages/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.ts was
    confirmed to FAIL on the base commit with the intended symptom: with src/
    stashed, the gated skill appeared in the model's catalog
    (<name>skill-gated</name> present in available_skills).
  • bun lint (root oxlint) — 0 errors; 4043 warnings is the repo-wide baseline,
    and the seven changed source files carry 12, all pre-existing rule classes.
  • git diff --check — clean.
  • ./packages/sdk/js/script/build.ts — FAIL, PRE-EXISTING-SDK-CODEGEN. See T8.
  • Independent review by a fresh subagent: all eight acceptance criteria met; one
    critical finding (a stray packages/sdk/js/openapi.json build artifact
    committed by accident) and one correctness nit (the not-found hint duplicating
    the reachability predicate over all()), both fixed in 6236515e.

Journey log

  1. The bug was reproduced in the authoring session itself: /compose-next
    delivered no <skill_content> block and no error. git log -L on the
    mention scan pinned the regression to 4e2a3cb6, which swapped sys.all()
    for sys.available(runtimeAgent) and deleted the comment recording why the
    bypass existed. A comment that explains a non-obvious choice is load-bearing;
    deleting it is how the choice gets undone.
  2. The first design kept deny as the hiding mechanism and special-cased the
    user path. Rejected after reading Claude Code's frontmatter reference: the
    upstream standard already splits this into disable-model-invocation and
    user-invocable, which named the actual defect — one rule serving two
    questions — rather than patching its symptom.
  3. user-invocable: false was deliberately dropped from the port. No in-repo
    skill needs a model-only skill, and shipping an unused second axis would
    reintroduce exactly the ambiguity being removed.
  4. An earlier draft kept a disable-model-invocation skill listed in the
    catalog with an annotation, so the model could suggest /compose-next.
    Rejected: obra/superpowers#345 shows what an advertised-but-unloadable skill
    costs — the model retries the tool and then tells the user the skill does not
    exist. Documentation skills are the right channel for "this exists, you
    invoke it".
  5. git add -A after a failed SDK generation committed a 16,934-line scratch
    file. git status before staging would have caught it; the reviewer did.
    It is now gitignored.

[S1] Problem

A user typing /compose-next gets nothing. The visible text /compose-next …
reaches the model, no <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). Since
4e2a3cb6 there has been no way to express "invisible to the model, still
usable by the user": the only mechanism that hides a skill also disables it.

The registry already carries a field for the visibility half — Skill.Info.hidden
(skill/index.ts:35, parsed at :102, assigned at :129) — but no code reads
it
, and no bundled SKILL.md sets it. It has been dead since PR #1725.

Separately, compose-next has now been through its trial period and should
graduate: it is no longer an experiment to be kept out of the way, it is the
recommended entry point for multi-step feature work. What it still must not do
is start itself.

[S2] Design

Split the two axes. Permission keeps exactly one meaning; a new frontmatter
field carries the other.

  • permission.skill = authorization. deny means unusable, by anyone,
    through any surface — model and user. Nothing bypasses it.
  • disable-model-invocation = model reachability. The model cannot see or
    invoke the skill. A user slash invocation is unaffected.

Field

disable-model-invocation, boolean, optional, default false. Kebab-case in
YAML frontmatter, matching Claude Code and the
agentskills.io open standard so a skill folder is
portable in both directions. Internally it is Info.disable_model_invocation
(repo snake_case convention); add() in skill/index.ts maps the kebab
frontmatter key onto it.

Skill.Info.hidden is removed in the same change. It is dead, unset by every
bundled skill, and keeping a second half-named visibility flag beside the new
field is the exact ambiguity this feature removes.

The counterpart field in the upstream standard, user-invocable: false ("only
the model may invoke"), is deliberately not implemented — see S3.

Semantics

Behaviour matrix for one skill, given a default-agent skill: "*": "allow":

frontmatter model sees it model may invoke user /name works
(default) yes yes yes
disable-model-invocation: true no no yes
any value + permission.skill deny no no no

"Model sees it" covers every list the model reads: the system-prompt catalog,
the skill tool description, and skill_search results. A
disable-model-invocation skill appears in none of them, so the model does not
learn the name from the harness at all — it learns that /compose-next exists
from documentation skills such as mimocode-docs, which also state that the
model must not start the workflow itself.

Registry contract

skill/index.ts gains one accessor beside the existing all / available:

  • all() — unchanged. No filtering. Feeds the command registry
    (command/index.ts:264), the app skills endpoint, and /skill autocomplete,
    so a disable-model-invocation skill still autocompletes and still has a
    slash command.
  • available(agent?) — unchanged. Authorization filter only
    (Permission.evaluate("skill", name, agent.permission) !== "deny"). This is
    the user surface: the mention scan in insertReminders keeps using it, so
    a user slash invocation is blocked by deny and by nothing else.
  • modelInvocable(agent?) — new. available(agent) minus
    disable_model_invocation. This is the model surface.

Three call sites move from available to modelInvocable:
session/system.ts:181 (catalog), tool/registry.ts:328 (describeSkill),
tool/skill-search.ts:37. session/system.ts:206 (SystemPrompt.available,
consumed only by the mention scan at prompt.ts:864) keeps available.

Skill tool

tool/skill.ts refuses a disable_model_invocation skill before ctx.ask,
with an error that redirects rather than dead-ends: the model is told the user
must type /name and that retrying the tool will not help. This mirrors Claude
Code's cannot be used with Skill tool due to disable-model-invocation, whose
bare form is a known dead-end (obra/superpowers#345 — the model retried and then
gave up instead of telling the user).

The not-found branch's "Available skills: …" hint (tool/skill.ts:37-39) is
filtered by the same predicate, so a typo near a hidden skill's name does not
leak it back to the model.

compose-next graduation

  • Delete "compose-next": "deny" from the default agent's skill ruleset
    (agent/agent.ts:111). Permission stops carrying visibility for it. The
    legacy "compose:*": "deny" rule stays exactly as is: those skills are
    denied on the default agent and allowed on the Compose agent, which is an
    agent-scoped decision that frontmatter cannot express.
  • Set disable-model-invocation: true in
    skill/builtin/.bundle/compose-next/SKILL.md.
  • Add the behavioural rule in two places, so it survives a future flag flip:
    in description, that the model must not use the skill unless the user
    invoked it or asked for it by name; in the body, that it must not enter the
    compose workflow without an explicit user request or invocation.
  • Drop compose-next from isComposeSkill in skill/search.ts:20-22. Its
    exclusion from search is now carried by the field at the caller, and the
    helper goes back to meaning only startsWith("compose:").
  • mimocode-docs records that /compose-next is user-invocable only and that
    the model must not start it — this is the intended channel through which the
    model learns the skill exists.

Accepted behaviour changes

  • A deny'd skill can no longer be loaded by an explicit user slash
    invocation. Before 4e2a3cb6 it could (PR #1716); since 4e2a3cb6 it cannot.
    This design keeps the current behaviour and makes it the documented rule:
    deny means unusable. Concretely, /compose:brainstorm from Build stays
    inert; it works from the Compose agent, which allows compose:*.
  • The model can no longer invoke compose-next by guessing its name.
    compose-next.md S2 previously accepted guessed invocation; this feature
    makes it a real gate, which is the whole point of the field.

[S3] Out of Scope

  • user-invocable: false (model-only skills, hidden from the / menu). No
    in-repo skill needs it, and adding an unused axis reintroduces the ambiguity
    this change removes. Skill.all() therefore remains the single user-facing
    set.
  • Settings-level overrides equivalent to Claude Code's skillOverrides
    (on / name-only / user-invocable-only / off). Per-agent
    permission.skill remains the only config-side control.
  • Migrating compose:* off permission.skill. Its deny is agent-scoped and
    disappears with legacy Compose removal.
  • Other frontmatter fields from the upstream standard (allowed-tools,
    context: fork, argument-hint, paths, model).
  • The MAX_AUTOLOAD = 3 budget, the mention regex, and the TUI/ACP
    leading-slash routing.

Known gaps left open (surfaced by review, deliberately not fixed here)

  • matchDocumentSkills (session/prompt.ts:843, table at
    skill/builtin/extract.ts:75) recommends document skills to the model from a
    hardcoded list, consulting neither available nor modelInvocable. No entry
    in that table is gated today, so this is latent, not live; it becomes a real
    leak the day someone sets the flag on a document skill.
  • The entire tool.skill_search describe block in
    test/tool/skill-search.test.ts is it.live.skipped on main, so the
    compose-next invisibility assertions there — updated to the new contract in
    this change — do not run. The mechanism itself is covered by running tests
    over fixture skills; only the shipped-builtin wiring is inert. Un-skipping
    that block needs the builtin bundle extracted in the test environment, which
    is its own change.
  • ./packages/sdk/js/script/build.ts remains broken (see T8). Fixing the
    __schema0 hoisting for ToolStateCompleted.providerOutput is a separate
    change; until then the generated SDK drifts from the API on every schema
    edit, and providerOutput itself is still missing from types.gen.ts.

Tasks

  • T1: Replace the dead hidden field on Skill.Info with
    disable_model_invocation, parsed from the kebab-case
    disable-model-invocation frontmatter key in skill/index.ts — acceptance:
    a SKILL.md with disable-model-invocation: true loads with
    disable_model_invocation === true; one without it loads undefined; no
    reference to Info.hidden remains in src (covers: S2)
  • T2: Add Skill.modelInvocable(agent?) and move the three model-facing
    call sites (session/system.ts:181, tool/registry.ts:328,
    tool/skill-search.ts:37) onto it, leaving SystemPrompt.available and
    the prompt.ts:864 mention scan on available — acceptance: a
    disable-model-invocation skill is absent from the system-prompt catalog,
    the skill tool description, and skill_search results, while
    Skill.available and Skill.all still return it (covers: S2; depends: T1)
  • T3: Refuse disable_model_invocation skills in tool/skill.ts before
    ctx.ask, and filter the not-found "Available skills" hint by the same
    predicate — acceptance: skill({name}) on such a skill throws an error
    naming disable-model-invocation and directing the model to have the user
    type /name; the name does not appear in the not-found hint for a
    mistyped query (covers: S2; depends: T1)
  • T4: Graduate compose-next: delete "compose-next": "deny" from
    agent/agent.ts, set disable-model-invocation: true in its SKILL.md,
    add the "only on explicit user invocation" rule to both its description
    and body, and drop compose-next from isComposeSkill in
    skill/search.ts — acceptance: Permission.evaluate("skill", "compose-next", defaultAgentRules) is allow; compose:* still deny on
    the default agent and allow on Compose; searchSkills no longer
    special-cases the name (covers: S2; depends: T1)
  • T5: Add a regression test that a user slash invocation of a
    disable-model-invocation skill injects its body, following the real-layer
    harness in test/session/prompt-skill-command-multi.test.ts — acceptance:
    the test fails on the base commit (no <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 plus modelInvocable filtering —
    acceptance: bun test test/skill test/tool test/permission test/session
    shows no failures attributable to this change (covers: S2; depends: T4)
  • T7: Record in mimocode-docs that /compose-next is user-invocable only
    and the model must not start it — acceptance: the skill states both facts
    where it already documents /compose-next (covers: S2)
  • T8: Bring the published AppSkillsResponses type in
    packages/sdk/js/src/v2/gen/types.gen.ts in line with the new
    Skill.Info shape — acceptance: the skills response type carries
    disable_model_invocation?: boolean and no hidden?: boolean.
    ./packages/sdk/js/script/build.ts cannot be used: it has failed since
    fc74c539 (2026-07-26) because ToolStateCompleted.providerOutput
    serializes to a dangling $ref: #/components/schemas/__schema0, and the
    committed types.gen.ts still has no providerOutput, confirming the file
    predates that commit. Record the field-level hand edit and leave the
    generator defect to its own change (covers: S2; depends: T1)

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 of
SessionPrompt.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, the
MAX_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 writing
MIMOCODE_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 in
afterAll.

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: only
    skill-alpha injected, no skill-beta block, 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 empty is
    PRE-EXISTING: the same suite on base 014a2577 fails it too, plus an extra
    snapshot race flake. 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

  1. 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.
  2. The first design considered turning alreadyWrapped into a per-name set so
    both injectors could coexist. Rejected: it keeps two injectors in sync by
    hand, and the MAX_AUTOLOAD budget plus the orchestration-reminder count both
    have to be reconciled manually — exactly the drift that caused the bug.
  3. What made the smaller fix safe was prompt.ts:4182-4183: skills already skip
    the $1..$N / $ARGUMENTS template machinery, and the command path wrapped
    the raw templateCommand, byte-identical to the info.content the scan
    injects. Deleting the wrap therefore loses nothing. Zero of the 22 bundled
    skills use $ARGUMENTS or !`bash`.
  4. Do not delete the whole branch: the visibleText part must stay, because the
    scan's only handle on the invoked skill is its leading /name token.
  5. Injection correctness now depends on a regex over rendered text rather than a
    registry lookup, so non-slug skill names (skill/index.ts:30 accepts 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:

  1. 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-next matches and everything after it — including
    /mimocode-docs — becomes arguments. Server-side,
    packages/cli/src/session/prompt.ts:4259-4269 emits a visible text part
    plus one <skill_content> part for input.command only.

  2. Free-text scan. packages/cli/src/session/prompt.ts:727-796 scans
    the message body for all skill mentions and handles multiple skills
    correctly (global regex at prompt.ts:739, dedupe, MAX_AUTOLOAD cap,
    orchestration reminder at prompt.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 in
insertReminders. 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-subtask
cmd.source === "skill" branch:

  • Emit parts = [visibleText, ...attachments, ...input.parts]. Drop the
    skillPart.
  • visibleText keeps its current form and must keep /${input.command} as the
    leading token. This token is the detection signal for the scan.
    input.command is the canonical skill name — the TUI resolves localized
    slash aliases to it client-side via resolveSkillSlash
    (cli/cmd/tui/i18n/skill.ts:19-27, used at index.tsx:1174) — so a
    localized invocation such as /深度研究 arrives as /deep-research and
    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 at prompt.ts:4247.

Scan contract — prompt.ts:720-797 requires no change:

  • alreadyWrapped loses its stated purpose (the command path no longer wraps)
    but retains its actual load-bearing role: cross-step idempotency.
    insertReminders runs on every step (prompt.ts:3387) and updatePart
    persists, so the parts injected at step 1 make the guard true at step 2+ and
    prevent restacking. This is the same mechanism documented at
    prompt.ts:819.
  • The command-invoked skill and any text-mentioned skills now enter one
    mentioned list, so MAX_AUTOLOAD and the mentioned.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 ... /b now 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 to userMessage.parts during insertReminders",
    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 first
    insertReminders pass (cancel, agent-resolution failure), the message keeps
    its /skill-a text with no body, and no later turn will backfill it —
    insertReminders only ever targets the last user message (prompt.ts:656).
  • The command.execute.before plugin hook (prompt.ts:4275-4279) no longer sees
    a <skill_content> part in parts for 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 in
beforeAll 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 applying
hidden 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:30 accepts 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-1177 and the ACP equivalent
    at acp/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.command with a second skill mentioned in the arguments,
    following the real-layer harness in
    packages/cli/test/session/plan-reminder-dedup.test.ts (live
    SessionPrompt + Session layers, 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 no
    skill-b block and no orchestration reminder (covers: S2)
  • T2: Drop the skillPart from the non-subtask cmd.source === "skill"
    branch in packages/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 args still 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
    cites src/session/prompt.ts:684 for a regex that has since moved —
    acceptance: the comment points at the mentionRe symbol rather than a
    line number, so it cannot rot again, and the existing regex tests still
    pass (covers: S2)
  • T4: Scope the MIMOCODE_DISABLE_*_SKILLS env writes in the skill tests so
    they no longer leak across files in a shared bun test process, via a
    single withEnv helper — acceptance: test/skill/skill.test.ts,
    test/skill/loop.test.ts, test/skill/bundle-discovery.test.ts,
    test/tool/skill-search.test.ts and the new multi-injection test all set
    their flags through withEnv and restore the prior values, and
    bun test test/session test/tool plus bun test test/skill show 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.

  1. Recall reminder — ensurePersistedUserSynthetic + RECALL_REMINDER_MARKER; hasMemoryOrTasks only consulted when the marker is absent.
  2. Loop-streak nudge — same helper + LOOP_STREAK_REMINDER_MARKER.
  3. Compose prompt — persist + COMPOSE_REMINDER_MARKER; durable order via MessageV2.promoteComposeProtocolFirst on hydrate/parts load (shared by runLoop, fork capture, trajectory). In-memory position: "head" only aligns the same request before the first reload.
  4. Exported pure helpers: markers, hasSyntheticReminder, buildRecallReminderText, buildLoopStreakReminderText; COMPOSE_REMINDER_MARKER owned by message-v2.ts, re-exported from prompt.ts for 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

  1. User screenshot line numbers 3840-3876 match origin/main at analysis time (e93a49cd), not the stale main checkout.
  2. Bare parts.push is half the user-side reminder contract — updatePart + marker dedupe is the other half (skills / plan).
  3. Cache break was order instability vs persisted insertReminders siblings + fork prefix DB reload, not volatile recall text.
  4. Compose head is closed by promoteComposeProtocolFirst at the MessageV2 load boundary — not by request-layer-only reorder.
  5. Mid-turn p.text wrap left request-only (intentional step≥2 steering); separate from Option A.
  6. History search (history/service.ts PartTable 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:

ts
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