Getting Started & Interface
eval ”$(omp completions zsh)“
bash — add to ~/.bashrc
eval ”$(omp completions bash)“
fish
omp completions fish > ~/.config/fish/completions/omp.fish
## Every tool, _benchmaxxed_.
Edits that land on the first attempt. Reads that summarize files instead of dumping their content. Searches that return instantly. Pick any model — omp will get it right.
| model | metric | what |
| ---------------- | ------------ | --------------------------------------------------------------------- |
| Grok Code Fast 1 | 6.7% → 68.3% | Tenfold lift the moment the edit format stops eating the model alive. |
| Gemini 3 Flash | +5 pp | Over str_replace — beats Google's own best attempt at the format. |
| Grok 4 Fast | −61% tokens | Output collapses once the retry loop on bad diffs disappears. |
| MiniMax | 2.1× | Pass rate more than doubles. Same weights, same prompt. |
- `read` : summarized snippets · ideal defaults · selector hit rate
- `search` : fastest in the west
- `lsp` : everything your IDE knows, the agent knows
- `prompts` : adjusted relentlessly for each model
[Read the full post ↗](https://blog.can.ac/2026/02/12/the-harness-problem/)
## The Pi _you love_, with **batteries included**.
Originally built on [Mario Zechner](https://github.com/mariozechner)'s wonderful [Pi](https://github.com/badlogic/pi-mono), omp adds everything you're missing.
### 01 · Code execution w/ tool-calling
Most harnesses give the agent a Python sandbox and call it done. Ours runs persistent Python and a Bun worker, and either kernel can call back into the agent's own tools — read, search, task — over a loopback bridge. The agent loads a CSV with tool.read from inside Python, charts it from JavaScript, and never leaves the cell.
![omp TUI: a single eval session with `[1/2] pandas describe` (Python) printing a real DataFrame.describe() table, followed by `[2/2] top scorer` (JavaScript) running a reduce. Footer: 'Both kernels ran in one session.'](https://omp.sh/captures/eval.webp)
### 02 · LSP wired into every write
Ask for a rename and you get a rename. The call goes through workspace/willRenameFiles, so re-exports, barrel files, and aliased imports update before the file moves. Everything your IDE knows, the agent knows.

### 03 · Drives a real debugger
A C binary segfaults: the agent attaches lldb, steps to the bad pointer, reads the frame. A Go service hangs: it attaches dlv and walks the goroutines. A Python process is wedged: debugpy, pause, inspect, evaluate. Most agents are still sprinkling print statements.

_[Watch the capture ↗](https://omp.sh/clips/dap.mp4)_
### 04 · Time-traveling stream rules
Your rules sit dormant until the model goes off-script. A regex match aborts the stream mid-token, injects the rule as a system reminder, and retries from the same point. You get course-correction without paying context tax on every turn. Injections survive compaction, so the fix sticks.

_[Watch the capture ↗](https://omp.sh/clips/ttsr.mp4)_
### 05 · First-class subagents
Split a job across workers and get typed results back. task fans out into isolated worktrees, each worker runs its own tool surface, and the final yield is a schema-validated object the parent reads directly. No prose to parse, no merge conflicts between siblings, no orphaned edits.

_[Watch the capture ↗](https://omp.sh/clips/irc.mp4)_
### 06 · A second model, watching every turn.
Pair a reviewer model to the 'advisor' role and it reads every turn the main agent takes, injecting notes inline — a quiet aside, a concern, or a hard blocker. It runs on its own context and its own model, so it catches what the doer rushed past. The main agent sees the note and course-corrects, or tells you why it won't.

_[Watch the capture ↗](https://omp.sh/clips/advisor.mp4)_
### 07 · Hand someone the link, they're in.
/collab puts your live session on a relay and hands back a link — and a QR. A teammate joins from another terminal with omp join, or just opens it in a browser. Share read-write to pair on the same agent, or /collab view for a read-only link anyone can watch but no one can steer. Frames are sealed client-side; the relay never sees your keys.

_[Watch the capture ↗](https://omp.sh/clips/collab.mp4)_
### 08 · Read a pdf on arxiv, why not?
web_search chains fourteen ranked providers and hands whatever URLs it finds straight to read. Arxiv PDFs, GitHub pages, Stack Overflow threads come back as structured markdown with anchors intact — the same tool surface you use on local files. Cite, follow, quote, never lose where you came from.

_[Watch the capture ↗](https://omp.sh/clips/web.mp4)_
### 09 · Unapologetically native. Even on Windows.
Other agents shell out to rg, grep, find, and bash. On many machines those binaries don't exist, and on the ones where they do, every call costs a fork-exec round-trip. omp links the real implementations into the process. ripgrep, glob, find: in-process. brush is the bash, with sessions that survive across calls. The same omp binary runs on macOS, Linux, and Windows — no WSL bridge.
### 10 · Code review with priorities and a verdict
Get a clear verdict on whether the change ships, with every issue ranked P0 through P3 and scored for confidence. /review spawns dedicated reviewer subagents that sweep branches, single commits, or uncommitted work in parallel. You tackle what blocks release first; nothing important hides in a wall of prose.
### 11 · Hashline: edit by content hash
Perfect edits, fewer tokens. The model points at anchors instead of retyping the lines it wants to change, so whitespace battles and string-not-found loops just stop happening. Edit a stale file and the anchors diverge — we reject the patch before it corrupts anything. Grok 4 Fast spends 61% fewer output tokens on the same work.
### 12 · GitHub is just another filesystem
Other harnesses bolt on gh_issue_view, gh_pr_view, gh_search — each with its own parameters the agent has to learn and you have to debug. We skipped that. read already handles paths; PRs are paths. One interface to teach the model, one surface to keep correct.
### 13 · Hindsight: memory the agent curates
The agent remembers your codebase between sessions. It writes facts mid-run with retain, pulls them back with recall, and compresses each session into a mental model that loads on the first turn of the next one. Project-scoped by default, so what it learns about this repo stays with this repo.
### 14 · ACP: editor-drivable agent
Run omp inside Zed and you get the same agent you drive from the terminal — reading the buffer you're actually looking at, writing through the editor's save path, spawning shells in the editor's terminal. Destructive tools pause for a permission prompt you can answer once and forget. No bridge, no plugin, no second brain to keep in sync.
### 15 · Inherits what your other tools already wrote
Every other agent ships an importer and expects you to convert. omp reads the eight formats already on disk in their native shape — Cursor MDC, Cline .clinerules, Codex AGENTS.md, Copilot applyTo, and the rest. No migration script, no YAML-to-TOML port, no "supported subset" footnotes. The config your team wrote last quarter still works tonight.
### 16 · omp commit: atomic splits, validated messages
omp reads the working tree through git_overview, git_file_diff, and git_hunk, then splits unrelated changes into atomic commits ordered by their dependencies. Cycles are rejected before anything is written. Source files score above tests, docs, and configs, so the headline commit is the one that matters. Lock files are excluded from analysis entirely.
### 17 · Read PRs. _Walk skills._ Pull JSON out of subagents.
Twelve internal schemes — `pr://`, `issue://`, `agent://`, `skill://`, `rule://`, and the rest — resolve transparently inside every FS-shaped tool the agent already calls. `read pr://1428` returns the same shape as `read src/foo.ts`. `search` walks a diff like a directory. `agent://<id>/findings.0.path` pulls a field out of a subagent's output by path.
![omp TUI reading pr://can1357/oh-my-pi/1063 and then /diff/1, showing hunk headers, added lines, and a [MODIFIED] (+12 -0) summary.](https://omp.sh/captures/pr.webp)
### 18 · Conflict resolution, made easy.
Each merge conflict becomes one URL. The agent writes `@theirs`, `@ours`, or `@base` to `conflict://N` and the file resolves cleanly. Bulk form: `conflict://*`.

_[Watch the capture ↗](https://omp.sh/clips/conflict.mp4)_
### 19 · Preview, then accept.
`ast_edit` returns a _(proposed)_ card with the replacement count. The change is staged. The agent calls `resolve` with a reason; the TUI turns it into an **Accept** card and the disk move happens — atomic, all or nothing.

_[Watch the capture ↗](https://omp.sh/clips/codemod.mp4)_
### 20 · Drives a _real browser_. _Or your Slack?_
Stealth's on by default, so pages see a normal user instead of a headless bot. The same API drives any Electron app in place — point it at Slack and the agent reads your DMs the way it reads the web.

## Whatever the task needs, _it's already in the box_.
32 tools live in the same namespace as `read` and `bash`. Pin the active set with `--tools read,edit,bash,…` and the rest stay hidden but indexed — `search_tool_bm25` pulls them back in mid-session when `tools.discoveryMode` says so.
**Files & search**
- `read` — files, dirs, archives, SQLite, PDFs, notebooks, URLs, and internal `://` schemes through one path.
- `write` — create or overwrite a file, archive entry, or SQLite row.
- `edit` — hashline patches with content-hash anchors and stale-anchor recovery.
- `ast_edit` — structural rewrites previewed before apply, via ast-grep.
- `ast_grep` — structural code queries over 50+ tree-sitter grammars.
- `search` — regex over files, globs, and internal URLs.
- `find` — glob-based path lookup; reach for `search` when you need content matches.
**Runtime**
- `bash` — workspace shell, with optional PTY or background-job dispatch.
- `eval` — persistent Python and JavaScript cells with shared prelude and tool re-entry.
- `ssh` — one remote command against a configured host.
**Code intelligence**
- `lsp` — diagnostics, navigation, symbols, renames, code actions, raw requests.
- `debug` — drive a DAP session — breakpoints, stepping, threads, stack, variables.
**Coordination**
- `task` — fan out subagents in parallel, optionally workspace-isolated.
- `irc` — short prose between live agents in this process.
- `todo` — ordered mutations over the session todo list with phase tracking.
- `job` — wait on or cancel background jobs.
- `ask` — structured follow-up questions for interactive runs.
**Outside the box**
- `browser` — Puppeteer tabs over headless Chromium or CDP-attached apps.
- `web_search` — one query across configured providers, returning answer plus citations.
- `github` — GitHub CLI ops — repo, PR, issues, code search, Actions run-watch.
- `generate_image` — generate or edit raster images via Gemini, GPT, or xAI Grok image models.
- `inspect_image` — vision-model analysis of a local image file.
- `tts` — text-to-speech via xAI Grok Voice — five built-in voices, WAV or MP3.
**Memory & state**
- `checkpoint` — mark conversation state for a later collapse-and-report.
- `rewind` — prune exploratory context, keep a concise report.
- `retain` — queue durable facts into the active Hindsight bank.
- `recall` — search the Hindsight bank for raw memories.
- `reflect` — ask Hindsight to synthesize an answer over the bank.
**Misc**
- `resolve` — apply or discard a queued preview action.
- `search_tool_bm25` — BM25 over the hidden tool index; activates top matches mid-session.
Setting-gated, off by default: `github`, `inspect_image`, `tts`, `checkpoint`, `rewind`, `search_tool_bm25`, `retain`, `recall`, `reflect`. Flip them on once, scoped per project.
[Full reference →](https://omp.sh/docs/tools)
## Forty-plus providers, hundreds of models, _one /model away_.
Roles route work by intent. `default` for normal turns. `smol` for cheap subagent fan-out. `slow` for deep reasoning. `plan` for plan mode. `commit` for changelogs. Override at launch with `--smol`, `--slow`, or `--plan`; cycle through the configured models for the active role with `Ctrl+P`. Swap the active model mid-session with the `/model` slash command.
Auth tags below: `oauth` signs in with your provider account, `plan` routes through a coding-plan subscription, `local` runs against a local server with the key optional.
### Frontier APIs
Direct APIs and gateways. Mix providers per role.
Anthropic `oauth` · OpenAI · OpenAI Codex `oauth` · Google Gemini · Google Antigravity `oauth` · xAI · Mistral · Groq · Cerebras · Fireworks · Together · Hugging Face · NVIDIA · OpenRouter · Synthetic · Vercel AI Gateway · Cloudflare AI Gateway · Wafer Serverless · Perplexity `oauth`
### Coding plans
Subscription-routed. `/login` attaches the session.
Cursor `oauth` · GitHub Copilot `oauth` · GitLab Duo · Kimi Code `plan` · Moonshot · MiniMax Coding Plan `plan` · MiniMax Coding Plan CN `plan` · Alibaba Coding Plan `plan` · Qwen Portal · Z.AI / GLM Coding Plan `plan` · Xiaomi MiMo · Qianfan · NanoGPT · Venice · Kilo · ZenMux · OpenCode Go · OpenCode Zen
### Run it yourself
OpenAI-compatible `/v1/models`. Local instances skip the key.
Ollama `local` · Ollama Cloud · LM Studio `local` · llama.cpp `local` · vLLM `local` · LiteLLM
### Four knobs that make routing useful
- **Custom providers** — Declare anything that speaks `openai-completions`, `openai-responses`, `openai-codex-responses`, `azure-openai-responses`, `anthropic-messages`, `google-generative-ai`, or `google-vertex` in `~/.omp/agent/models.yml`.
- **Fallback chains** — Per-role chains under `retry.fallbackChains`. When the primary throws 429s or hits a quota wall, the next entry takes the rest of the turn — restored on cooldown.
- **Path-scoped models** — Scope `enabledModels` and `disabledProviders` entries to a `path:` prefix to pin a different model set on one repo without touching the global config. Scoped entries cover the path and everything under it.
- **Round-robin credentials** — Stack API keys per provider and the runtime rotates with session affinity and per-credential backoff. Useful when one key would burn its quota by lunch.
Full provider & routing reference at [omp.sh/docs/providers](https://omp.sh/docs/providers).
## Fourteen backends. _One tool the agent already knows_.
`web_search` is built in, not bolted on. `auto` walks a fourteen-provider chain; pin one by name if you already pay for it. Behind every hit, site-aware extraction turns GitHub, registries, arXiv, Stack Overflow, and docs into structured markdown — anchors and link targets survive.
### Search providers
Fourteen backends. Pin one, or let `auto` walk the chain in order.
| provider | auth |
| ------------ | ---------------------- |
| `auto` | chain |
| `exa` | `EXA_API_KEY` (or mcp) |
| `brave` | `BRAVE_API_KEY` |
| `jina` | `JINA_API_KEY` |
| `kimi` | `MOONSHOT_API_KEY` |
| `zai` | `ZAI_API_KEY` |
| `anthropic` | oauth |
| `perplexity` | `PERPLEXITY_API_KEY` |
| `gemini` | oauth |
| `codex` | oauth |
| `tavily` | `TAVILY_API_KEY` |
| `parallel` | `PARALLEL_API_KEY` |
| `kagi` | `KAGI_API_KEY` |
| `synthetic` | `SYNTHETIC_API_KEY` |
| `searxng` | self-hosted |
### Specialised handlers
The agent gets structured content, not stripped HTML.
- **Code hosts** — github, gitlab
- **Package registries** — npm, PyPI, crates.io, Hex, Hackage, NuGet, Maven, RubyGems, Packagist, pub.dev, Go packages
- **Research sources** — arxiv, semantic scholar
- **Forums** — stack overflow, reddit, hn
- **Docs** — mdn, readthedocs, docs.rs
Pages convert to markdown with link structure intact. The agent can cite, follow, and quote without losing anchors.
### Security databases
Vuln lookups answer with vendor data, not blog summaries.
- **NVD** — national vulnerability database
- **OSV** — open source vuln feed
- **CISA KEV** — known exploited vulns
[`web_search` reference ↗](https://omp.sh/docs/tools#web_search)
## Roughly **~55,000** lines of Rust, doing the work other harnesses shell out for.
Four crates, one platform-tagged N-API addon. Search, shell, AST, highlight, PTY, image decode, BPE counting — all in-process on the libuv pool. No fork/exec on the hot path.
- Crates: `pi-natives`, `pi-shell`, `pi-ast`, `pi-iso`
- Platforms: `linux-x64`, `linux-arm64`, `darwin-x64`, `darwin-arm64`, `win32-x64`
The table below is a per-module breakdown that intentionally omits glue and tests.
| Module | What it does | Powered by | ~LoC |
| ---------- | ------------------------------------------------------------------------------------ | ----------------------------------------- | ----: |
| shell | Embedded bash · persistent sessions · timeout/abort · custom builtins | brush-shell (vendored) | 3,700 |
| grep | Regex search · parallel/sequential · glob & type filters · fuzzy find | grep-regex · grep-searcher | 1,900 |
| keys | Kitty keyboard protocol with xterm fallback · PHF perfect-hash lookup | phf | 1,490 |
| text | ANSI-aware width · truncation · column slicing · SGR-preserving wrap | unicode-width · segmentation | 1,450 |
| summary | Tree-sitter structural source summaries with elision controls | tree-sitter · ast-grep-core | 1,040 |
| ast | ast-grep pattern matching and structural rewrites | ast-grep-core | 1,000 |
| fs_cache | Mtime-keyed file cache shared by read · grep · lsp | in-tree | 840 |
| highlight | Syntax highlighting · 11 semantic categories · 30+ aliases | syntect | 470 |
| pty | Native PTY allocation for sudo · ssh interactive prompts | portable-pty | 455 |
| glob | Discovery with glob · type filters · mtime sort · gitignore respect | ignore · globset | 410 |
| workspace | Workspace walker with gitignore + AGENTS.md discovery in one pass | ignore | 385 |
| appearance | Mode 2031 + native macOS dark/light via CoreFoundation FFI | core-foundation | 270 |
| power | macOS power-assertion API for idle/system/display-sleep prevention | IOKit FFI | 270 |
| task | Blocking work on libuv thread pool · cancellation · timeout · profiling | tokio · napi | 260 |
| fd | Filesystem walker for find-tool replacement | ignore | 250 |
| iso | Workspace isolation shim · apfs · btrfs · zfs · reflink · overlayfs · projfs · rcopy | pi-iso (PAL) | 245 |
| prof | Circular buffer profiler with folded-stack and SVG flamegraph output | inferno | 240 |
| ps | Cross-platform process-tree kill and descendant listing | libc · libproc · CreateToolhelp32Snapshot | 195 |
| clipboard | Text copy and image read from system clipboard · no xclip/pbcopy | arboard | 80 |
| tokens | O200k / Cl100k BPE token counting · both tables embedded | tiktoken-rs | 65 |
| sixel | Terminal image rendering · decode PNG · JPEG · WebP · GIF · resize · SIXEL encode | icy_sixel · image | 55 |
| html | HTML to Markdown with optional content cleaning | html-to-markdown-rs | 50 |
## Four entry points: _interactive_, _one-shot_, RPC, and ACP.
Same engine, four wrappers. `omp` runs the TUI. `omp -p` answers a single prompt and exits. The Node SDK embeds the session in your process. `omp --mode rpc` and `omp acp` hand the wheel to another program over stdio.
### Interactive — when in doubt, the agent asks
The TUI is the default surface. Tool calls render as cards, edits preview before they land, and ambiguity routes through the `ask` tool — a structured option picker the agent can call mid-turn. The keyboard handles the rest.
The same prompt cards surface over ACP, so editors get the picker without writing one.

### SDK — embed in Node
`@oh-my-pi/pi-coding-agent`
Node and TypeScript hosts pull the engine in directly. The package exposes `ModelRegistry`, `SessionManager`, `createAgentSession`, and `discoverAuthStorage`; the session emits typed events you subscribe to.
```ts
import {
ModelRegistry,
SessionManager,
createAgentSession,
discoverAuthStorage,
} from "@oh-my-pi/pi-coding-agent";
const auth = await discoverAuthStorage();
const models = new ModelRegistry(auth);
await models.refresh();
const { session } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
authStorage: auth,
modelRegistry: models,
});
await session.prompt("list .ts files");
RPC — drive over stdio
omp --mode rpc
For non-Node embedders, or when you want process isolation. NDJSON commands in, response and event frames out. --mode rpc-ui adds tool cards, selectors, and dialogs as extension_ui_request frames the host must answer.
$ omp --mode rpc --no-session
> {"id":"r1","type":"prompt","message":"list .ts files"}
< {"id":"r1","type":"response", ...}
> {"id":"r2","type":"set_model","provider":"anthropic","modelId":"sonnet-4.5"}
> {"id":"r3","type":"abort"}
ACP — speak to editors
omp acp
The Agent Client Protocol over JSON-RPC. When the editor advertises capabilities, tool I/O routes through it and writes are gated by session/request_permission.
| omp tool | ACP route |
|---|---|
bash | terminal/create + terminal/output |
read | fs/read_text_file |
write | fs/write_text_file |
edit, bash | session/request_permission |
Full reference: omp.sh/docs/sdk.
A harness worth keeping is one you don’t outgrow.
Pick it up at omp.sh.
omp is a fork of Pi by Mario Zechner, rewritten as a coding-first surface: sessions, subagents, slash commands, extensions — all TypeScript, all MIT, all on GitHub. Shape it from config, hook it from outside, or read the source when you need to.
Primitives
An extension is a TypeScript module. Same tool API, same slash-command registry, same hotkey table, same TUI primitives the built-ins use. Nothing is reserved.
Discovery
On first run omp inherits whatever is already on disk: rules, skills, and MCP servers from .claude, .cursor, .windsurf, .gemini, .codex, .cline, .github/copilot, and .vscode. No migration script.
Extensibility
Ask omp to write the piece you’re missing, then /reload-plugins. Keep it local, ship it in a marketplace, or publish it to npm.
Philosophy
omp is a fork of pi-mono by Mario Zechner, extended with a batteries-included coding workflow.
Key ideas:
- Keep interactive terminal-first UX for real coding work
- Include practical built-ins (tools, sessions, branching, subagents, extensibility)
- Make advanced behavior configurable rather than hidden
Development
Getting started from source
Fresh clones need both workspace dependencies and the local Rust/N-API addon before the source CLI can start.
bun setup
bun devbun setup installs Bun workspaces and builds @oh-my-pi/pi-natives. Re-run bun run build:native after changing Rust crates or packages/natives.
For a non-interactive smoke check:
bun dev -- --versionDebug Command
/debug opens tools for debugging, reporting, and profiling.
For architecture and contribution guidelines, see DEVELOPMENT.md.
Monorepo Packages
| Package | Description |
|---|---|
| collab-web | Browser guest client, mock host, and local relay for collab live sessions |
| pi-ai | Multi-provider LLM client with streaming and model/provider integration |
| pi-catalog | Model catalog: bundled model database, provider descriptors, and identity |
| pi-agent-core | Agent runtime with tool calling and state management |
| pi-coding-agent | Interactive coding agent CLI and SDK |
| pi-tui | Terminal UI library with differential rendering |
| pi-natives | N-API bindings for grep, shell, image, text, syntax highlighting, and more |
| omp-stats | Local observability dashboard for AI usage statistics |
| pi-utils | Shared utilities (logging, streams, dirs/env/process helpers) |
| pi-wire | Shared collab live-session protocol types and relay constants |
| hashline | Line-anchored patch language and applier behind the edit tool |
| pi-mnemopi | Local SQLite memory engine for Oh My Pi agents |
| snapcompact | Bitmap-frame context compression package and SQuAD eval suite |
| swarm-extension | Swarm orchestration extension package |
Rust Crates
| Crate | Description |
|---|---|
| pi-natives | Core Rust native addon (N-API cdylib) used by @oh-my-pi/pi-natives; aggregates the crates below |
| pi-shell | Embedded shell / PTY / process management split out of pi-natives (wraps brush-*) |
| pi-ast | tree-sitter-based code summarizer and AST utilities (50+ language grammars) |
| pi-iso | Task isolation backend resolver: APFS clones, btrfs/zfs reflinks, overlayfs, projfs, rcopy |
| brush-core-vendored | Vendored fork of brush-shell for embedded bash execution |
| brush-builtins-vendored | Vendored bash builtins (cd, echo, test, printf, read, export, etc.) |
Contributing
Issues are open to everyone. Pull requests require a vouch — PRs from
unvouched or denounced authors are closed automatically. If you’re not yet
vouched, open a Discussion
and ask a maintainer to !vouch you rather than opening a PR (which would be
closed on sight). See CONTRIBUTING.md and
.github/VOUCHED.td for the full policy.
License
MIT. See LICENSE.
© 2025 Mario Zechner
© 2025-2026 Can Bölük
made for terminals that stay open
This document describes how slash commands are discovered, deduplicated, surfaced in interactive mode, and expanded at prompt time in coding-agent.
Implementation files
src/extensibility/slash-commands.tssrc/capability/slash-command.tssrc/discovery/builtin.tssrc/discovery/claude.tssrc/discovery/codex.tssrc/discovery/claude-plugins.tssrc/capability/index.tssrc/discovery/helpers.tssrc/session/agent-session.tssrc/modes/interactive-mode.tssrc/modes/controllers/input-controller.tssrc/modes/utils/ui-helpers.tssrc/modes/controllers/command-controller.ts
1) Discovery model
Slash commands are a capability (id: "slash-commands") keyed by command name (key: cmd => cmd.name).
The capability registry loads all registered providers, sorted by provider priority descending, and deduplicates by key with first wins semantics.
Provider precedence
Current slash-command providers and priorities:
native(OMP) — priority100omp-plugins(extension packages) — priority90claude— priority80claude-plugins— priority70agents(.agent/.agentsstandard dirs) — priority70codex— priority70opencode— priority55
Tie behavior: equal-priority providers keep registration order. Current import order registers claude-plugins before agents before codex, so plugin commands win over both on name collisions.
Name-collision behavior
For slash-commands, collisions are resolved strictly by capability dedup:
- highest-precedence item is kept in
result.items - lower-precedence duplicates remain only in
result.alland are marked_shadowed = true
This applies across providers and also within a provider if it returns duplicate names.
File scanning behavior
Providers mostly use loadFilesFromDir(...), which currently:
- defaults to non-recursive matching (
*.md) - uses native glob with
gitignore: true,hidden: false,fileType: File - reads matching files in parallel and transforms them into
SlashCommanditems
So hidden files/directories are not loaded, ignored paths are skipped, and file order follows native glob result order unless a provider adds its own ordering.
2) Provider-specific source paths and local precedence
native provider (builtin.ts)
Search roots come from .omp directories:
- project:
<cwd>/.omp/commands/*.md - user:
~/.omp/agent/commands/*.md
getConfigDirs() returns project first, then user, so project native commands beat user native commands when names collide.
claude provider (claude.ts)
Loads, subject to commands.enableClaudeUser and commands.enableClaudeProject settings:
- user:
~/.claude/commands/**/*.md(recursive) - project:
<cwd>/.claude/commands/**/*.md(recursive)
Commands in subdirectories additionally get a namespaced alias: foo/bar.md is registered under both bar and foo:bar (addClaudeCommandNamespaceAliases).
The provider pushes user items before project items, so user Claude commands beat project Claude commands on same-name collisions inside this provider.
codex provider (codex.ts)
Loads:
- user:
~/.codex/commands/*.md - project:
<cwd>/.codex/commands/*.md
Both sides are loaded then flattened in user-first order, so user Codex commands beat project Codex commands on collisions.
Codex command content is parsed with frontmatter stripping (parseFrontmatter), and command name can be overridden by frontmatter name; otherwise filename is used.
opencode provider (opencode.ts)
Loads, subject to commands.enableOpencodeUser and commands.enableOpencodeProject settings:
- user:
~/.config/opencode/commands/*.md - project:
<cwd>/.opencode/commands/*.md
Both sides are loaded then flattened in user-first order, so user OpenCode commands beat project OpenCode commands on collisions. OpenCode command content is parsed with frontmatter stripping, and command name can be overridden by frontmatter name; otherwise filename is used.
claude-plugins provider (claude-plugins.ts)
Loads plugin command roots via listClaudePluginRoots(...), which reads ~/.claude/plugins/installed_plugins.json, ~/.omp/plugins/installed_plugins.json, and the nearest project-scoped registry resolved from cwd. For each root it scans <pluginRoot>/commands/*.md (the directory can be remapped by plugin config keys commands/slash-commands), and command names are prefixed with the plugin name: <plugin>:<command>.
Across the three registries, roots are merged by precedence rather than sorted: --plugin-dir injected roots come first, then project-scoped entries (which shadow user entries for the same plugin id), then user entries, with the OMP registry authoritative over Claude’s for the same plugin id. Within each registry, per-plugin entry order from the JSON data is preserved; there is no additional sort step.
3) Materialization to runtime FileSlashCommand
loadSlashCommands() in src/extensibility/slash-commands.ts converts capability items into FileSlashCommand objects used at prompt time.
For each command:
- parse frontmatter/body (
parseFrontmatter) - description source:
frontmatter.descriptionif present- else first non-empty body line (max 60 chars with
...)
- keep parsed body as executable template content
- compute a display source string like
via Claude Code Project
Frontmatter parse severity is source-dependent:
nativelevel -> parse errors arefataluser/projectlevels -> parse errors arewarnwith fallback parsing
Bundled fallback commands
After filesystem/provider commands, embedded command templates are appended (EMBEDDED_COMMAND_TEMPLATES) if their names are not already present.
Current embedded set comes from src/task/commands.ts and is used as a fallback (source: "bundled").
4) Interactive mode: where command lists come from
Interactive mode combines multiple command sources for autocomplete and command routing.
At construction time it builds a pending command list from:
- built-ins (
BUILTIN_SLASH_COMMANDS, includes argument completion and inline hints for selected commands) - extension-registered slash commands (
extensionRunner.getRegisteredCommands(...)) - TypeScript custom commands (
session.customCommands), mapped to slash command labels - optional skill commands (
/skill:<name>) whenskills.enableSkillCommandsis enabled
Then init() calls refreshSlashCommandState(...) to load file-based commands and install one autocomplete provider (createPromptActionAutocompleteProvider, a PromptActionAutocompleteProvider wrapping a CombinedAutocompleteProvider) containing:
- pending commands above
- discovered file-based commands
- discovered prompt-template commands whose names aren’t already taken by a built-in/hook/custom/skill/file command
refreshSlashCommandState(...) also updates session.setSlashCommands(...) so prompt expansion uses the same discovered file command set.
Refresh lifecycle
Slash command state is refreshed:
- during interactive init
- after
/movechanges working directory (handleMoveCommand->applyCwdChange, which callsresetCapabilities()thenrefreshSlashCommandState(newCwd)) - when the editor component is swapped (
setEditorComponentre-runsrefreshSlashCommandState())
There is no continuous file watcher for command directories.
Other surfacing
The Extensions dashboard also loads slash-commands capability and displays active/shadowed command entries, including _shadowed duplicates.
5) Prompt pipeline placement
AgentSession.prompt(...) slash handling order (when expandPromptTemplates !== false):
- Extension commands (
#tryExecuteExtensionCommand)
If/namematches extension-registered command, handler executes immediately and prompt returns. - TypeScript custom commands and MCP prompt commands (
#tryExecuteCustomCommand) Boundary only: if matched, it executes and may return:string-> replace prompt text with that stringvoid/undefined-> treated as handled; no LLM prompt
- File-based slash commands (
expandSlashCommand)
If text still starts with/, attempt markdown command expansion. - Prompt templates (
expandPromptTemplate)
Applied after slash/custom processing. - Delivery
- idle: prompt is sent immediately to agent
- streaming: prompt is queued as steer/follow-up depending on
streamingBehavior
This is why slash command expansion sits before prompt-template expansion, and why custom commands can transform away the leading slash before file-command matching.
6) Expansion semantics for file-based slash commands
expandSlashCommand(text, fileCommands) behavior:
- only runs when text begins with
/ - parses command name from first token after
/ - parses args from remaining text via
parseCommandArgs - finds exact name match in loaded
fileCommands - if matched, applies:
- positional replacement:
$1,$2, … - slice replacement:
$@[start]/$@[start:length]using 1-based positions - aggregate replacement:
$ARGUMENTSand$@ - template rendering via
prompt.renderwith{ args, ARGUMENTS, arguments } - inline-argument fallback append when the template did not use an inline argument placeholder
- positional replacement:
parseCommandArgs caveats
The parser is simple quote-aware splitting:
- supports
'single'and"double"quoting to keep spaces - strips quote delimiters
- does not implement backslash escaping rules
- unmatched quote is not an error; parser consumes until end
7) Unknown /... behavior
Unknown slash input is not rejected by core slash logic.
If command is not handled by extension/custom/file layers, expandSlashCommand returns original text, and the literal /... prompt proceeds through normal prompt-template expansion and LLM delivery.
Interactive mode separately hard-handles many built-ins in InputController (for example /settings, /model, /mcp, /move, /exit). Those are consumed before session.prompt(...) and therefore never reach file-command expansion in that path.
8) Streaming-time differences vs idle
Idle path
session.prompt("/x ...")runs command pipeline and either executes command immediately or sends expanded text directly.
Streaming path (session.isStreaming === true)
prompt(...)still runs extension/custom/file/template transforms first- then requires
streamingBehavior:"steer"-> queue interrupt message (agent.steer)"followUp"-> queue post-turn message (agent.followUp)
- if
streamingBehavioris omitted, prompt throws an error
Important command-specific streaming behavior
- Extension commands are executed immediately even during streaming (not queued as text).
steer(...)/followUp(...)helper methods reject extension commands (#throwIfExtensionCommand) to avoid queuing command text for handlers that must run synchronously.- Compaction queue replay uses
isKnownSlashCommand(...)to decide whether queued entries should be replayed viasession.prompt(...)(for known slash commands) vs raw steer/follow-up methods.
9) Error handling and failure surfaces
- Provider load failures are isolated; registry collects warnings and continues with other providers.
- Invalid slash command items (missing name/path/content or invalid level) are dropped by capability validation.
- Frontmatter parse failures:
- native commands: fatal parse error bubbles
- non-native commands: warning + fallback key/value parse
- Extension/custom command handler exceptions are caught and reported via extension error channel (or logger fallback for custom commands without extension runner), and treated as handled (no unintended fallback execution).
Run /hotkeys inside an omp session to see the active chords for your current build. The list reflects any remaps loaded from disk and any bindings added by extensions.
Customize keybindings
User remaps live in ~/.omp/agent/keybindings.yml. The file is a YAML mapping whose keys are keybinding action IDs and whose values are either one chord string or an array of chord strings. It is not read from ~/.omp/agent/config.yml, and there is no nested keybindings object.
app.model.cycleForward: Ctrl+P
app.model.selectTemporary: Alt+P
app.plan.toggle: Alt+Shift+PChord names are case-insensitive and use the same notation shown in the UI, such as Ctrl+P, Alt+Shift+P, Shift+Enter, and Ctrl+Backspace.
Set an action to an empty array to disable it:
app.history.search: []Common action IDs
| Action ID | Default | Meaning |
|---|---|---|
app.model.cycleForward | Ctrl+P | Cycle role models forward |
app.model.cycleBackward | Shift+Ctrl+P | Cycle role models backward |
app.model.selectTemporary | Alt+P | Pick a model temporarily for this session |
app.model.select | Alt+M | Open the model selector and set roles |
app.plan.toggle | Alt+Shift+P | Toggle plan mode |
app.history.search | Ctrl+R | Search prompt history |
app.tools.expand | Ctrl+O | Toggle tool-output expansion |
app.thinking.toggle | Ctrl+T | Toggle thinking-block visibility |
app.thinking.cycle | Shift+Tab | Cycle thinking level |
app.editor.external | Ctrl+G | Edit the draft in $VISUAL / $EDITOR |
app.message.followUp | Ctrl+Q, Ctrl+Enter | Queue a follow-up message |
app.message.dequeue | Alt+Up | Dequeue a queued message back into the editor |
app.retry | Alt+R | Retry the last failed assistant turn |
app.display.reset | Ctrl+L | Reset terminal display |
app.clipboard.copyLine | Alt+Shift+L | Copy the current line |
app.clipboard.copyPrompt | Alt+Shift+C | Copy the whole prompt |
app.clipboard.pasteImage | Ctrl+V (Alt+V fallback on Windows) | Paste from the clipboard (image preferred, text fallback) |
app.stt.toggle | Unbound (hold Space) | Toggle speech-to-text. By default there is no key chord — hold the space bar to record (push-to-talk) and release to transcribe; bind a chord here for a press-to-toggle alternative |
On Windows Terminal, Ctrl+V may be handled by the terminal paste command before omp sees it; use the Alt+V fallback when clipboard image paste appears to do nothing. When the clipboard holds no image, app.clipboard.pasteImage pastes the clipboard text instead, so hosts that deliver only this chord (VS Code’s integrated terminal when configured to forward Ctrl+V, Windows clipboard history via Win+V) work for both payload kinds. Windows Terminal also swallows Ctrl+Enter, so the app.message.followUp chord also binds Ctrl+Q — the same chord GitHub Copilot CLI uses — and the same chord submits the agent dashboard’s new-agent description and hook-editor prompts. If your existing keybindings.yml already assigns Ctrl+Q to another action, that user remap wins and follow-up keeps Ctrl+Enter unless you explicitly bind app.message.followUp.
Terminals that implement OSC 5522 enhanced paste can send clipboard MIME data directly to omp; image pastes are attached as [Image #N], while text/plain paste events keep normal paste behavior. When OSC 5522 is unavailable, bracketed paste still handles text, and a pasted single image-file path is loaded as an image when the file is readable from the omp host.
Older unqualified action names are migrated when keybindings.yml is loaded, but new docs and new configs should use the namespaced action IDs above. Existing keybindings.json files are still accepted and migrated to keybindings.yml; keybindings.yaml is also accepted.
omp resolves settings from built-in defaults, a persistent global config file, optional project-local config, one-shot CLI overlays, and in-memory runtime overrides. Reach for project settings when one repository needs a different provider set, model role, tool policy, memory backend, or UI behavior than your global defaults — without touching your machine-wide configuration.
Settings are stored as plain YAML mappings. Every key, its type, default, and enum values come from the settings schema, and you can inspect or change any of them with omp config or the interactive /settings panel.
- For model/provider credentials,
.envfiles, and the env-var table that resolves API keys, see Providers. - For custom model definitions in
models.yml, see Models. - For instruction files discovered into the agent context (
AGENTS.md,.omp/, etc.), see Context files. - For the full catalog of environment variables, see Environment variables.
Where settings live
| Scope | Path | Read behavior | Write behavior |
|---|---|---|---|
| Global | ~/.omp/agent/config.yml | The main persistent settings file. Always loaded. | /settings, omp config set, and omp config reset write here. |
| Global legacy | ~/.omp/agent/settings.json | Migrated into config.yml once, only when config.yml does not yet exist. | Not written after migration; the original is renamed to settings.json.bak. |
| Project | <cwd>/.omp/config.yml (plus .omp/settings.json) | Loaded when the process working directory has a non-empty .omp/. | Read-only from settings commands; edit the file by hand. |
| Project legacy | <cwd>/.omp/settings.json | Still read; project config.yml is merged on top of it. | Not written by settings commands. |
| CLI overlay | Any file passed with --config <file> | Loaded after global and project settings, for that one process. Repeatable. | Never persisted. |
| Runtime overrides | In-memory only | Set by dedicated CLI flags (--model, --approval-mode, …) and feature env vars. | Never persisted. |
PI_CODING_AGENT_DIR relocates the ~/.omp/agent base directory. When it is set, the global config.yml, the auth store (agent.db), and everything else under the agent directory move with it. Use omp config path to print the active agent directory.
Native project settings are intentionally scoped to the process working directory’s .omp/ folder — settings discovery does not walk ancestor directories looking for the nearest .omp/. Other discovery providers (Claude, Codex, Gemini, Cursor, OpenCode) can also contribute project-level settings from their own files; those are read-only from omp settings commands and can be turned off by provider id (see Provider and source disabling).
Config file formats
The global config.yml is always YAML. The generic config loader used for other files (for example models.yml) accepts .yml, .yaml, .json, and .jsonc:
- When a
.yml/.yamlpath is requested and only a sibling.jsonexists, it is migrated to YAML automatically (idempotent, once per process). .jsonand.jsoncconfigs are read as-is, with no migration.- A file whose top level is not a mapping (a bare array or scalar) is treated as empty for persistent settings, and is a hard error for
--configoverlays.
Reading and writing settings
Use the interactive /settings panel inside a session, or the omp config command from a shell. Both operate on the merged effective settings, but every persistent write lands in the global file only.
omp config list # all settings with current effective values
omp config list --json # same, machine-readable
omp config get theme.dark # one value
omp config get theme.dark --json
omp config set compaction.enabled false
omp config set defaultThinkingLevel medium
omp config reset steeringMode # restore a key to its schema default
omp config path # print the active agent directoryFor users who want the full first-run animation on normal launches, set startup.showSplash:
omp config set startup.showSplash trueThis only controls the startup splash animation. It does not rerun setup or change setup state, and startup.quiet: true still suppresses all startup chrome including the splash.
Subcommands
| Command | Effect |
|---|---|
omp config list | Print every setting grouped by tab, with its current value and type. --json emits an object keyed by setting path with { value, type, description }. |
omp config get <key> | Print the effective value of one key. Unknown keys exit non-zero. --json emits { key, value, type, description }. |
omp config set <key> <value> | Parse <value> against the key’s schema type and write it to the global config.yml. |
omp config reset <key> | Write the key’s schema default back to the global config (this persists the default, it does not delete the key). |
omp config path | Print the active agent directory (honors PI_CODING_AGENT_DIR). |
omp config with no subcommand, or --help, prints the help and lists settings. The --json flag is accepted by list, get, set, and reset.
Value parsing
omp config set parses the value string according to the target key’s schema type. The string is trimmed first.
| Type | Accepted input | Notes |
|---|---|---|
| boolean | true, false, yes, no, on, off, 1, 0 | Case-insensitive. Anything else is rejected. |
| number | Any finite JavaScript number | Infinity/NaN are rejected. |
| enum | One of the key’s allowed values | Must match exactly; the error lists the valid values. |
| array | A JSON array | e.g. '["anthropic","openai"]'. Must parse and be an array. |
| record | A JSON object | e.g. '{"bash":"prompt"}'. Must parse and be a non-array object. |
| string | Stored as given (trimmed) | Multi-word values are joined with spaces. |
Keys must match a real schema path exactly. There is no shorthand — set theme.dark, not theme.
Where writes go
omp config set, omp config reset, /settings, and any runtime settings change all write to the global config.yml under the active agent directory. They never write to <cwd>/.omp/config.yml. To create a project-local override, edit that file directly (see Project-local config). Saves are debounced and re-read the file under a lock, so external edits made while a session is open are preserved.
Precedence
From lowest to highest priority, the effective value of a setting is built as:
built-in defaults <- global config <- project config <- CLI overlays <- runtime overridesFrom highest to lowest:
- Runtime overrides — dedicated CLI flags and feature env vars applied in memory for the current process:
--model,--smol,--slow,--plan,--approval-mode,--auto-approve/--yolo,--hide-thinking,--advisor,--no-pty,--api-key, and protocol-mode defaults. Never persisted. - CLI config overlays — each
--config <file>; later overlay files override earlier ones. - Project settings —
<cwd>/.omp/settings.jsonthen<cwd>/.omp/config.yml(and contributions from other discovery providers at project level). - Global settings —
~/.omp/agent/config.yml. - Built-in defaults — from the settings schema.
A key that is unset at every layer resolves to its schema default at read time.
Environment overrides
Environment variables are not a single settings layer. Each is read by the feature that owns the value, usually as a per-machine override or fallback, and is never written back to config.yml. The ones that map directly onto a setting:
| Env var | Overrides setting | Notes |
|---|---|---|
PI_SMOL_MODEL | modelRoles.smol | Also exposed as --smol. |
PI_SLOW_MODEL | modelRoles.slow | Also exposed as --slow. |
PI_PLAN_MODEL | modelRoles.plan | Also exposed as --plan. |
PI_NO_PTY=1 | (disables PTY bash) | Equivalent to --no-pty for the process. |
PI_PY | eval.py | PI_PY=0 disables the Python eval backend. |
PI_JS | eval.js | PI_JS=0 disables the JavaScript eval backend. |
PI_TINY_DEVICE | providers.tinyModelDevice | ONNX execution provider for local tiny models. |
PI_TINY_DTYPE | providers.tinyModelDtype | ONNX precision for local tiny models. |
OMP_AUTH_BROKER_URL | auth.broker.url | Env value takes precedence over config. |
OMP_AUTH_BROKER_TOKEN | auth.broker.token | Env value takes precedence over config. |
PI_CODING_AGENT_DIR | (relocates agent dir) | Moves config.yml, agent.db, and the whole agent base. |
Provider API keys are resolved separately (stored auth, OAuth, models.yml, environment, and .env files); see Providers and the full Environment variables reference.
Merge rules
Layers are combined with a deep merge:
- Objects are deep-merged — keys present only in a lower layer are kept; keys present in a higher layer override.
- Scalars and arrays are replaced wholesale by the higher-precedence layer. A higher layer’s array does not append to a lower layer’s array.
Use nested YAML mappings for dotted setting paths:
theme:
dark: titanium
light: light
tools:
approvalMode: write
approval:
bash: prompt
read: allowWorked example: global vs. project
# ~/.omp/agent/config.yml
tools:
approvalMode: write
approval:
bash: prompt
read: allow
disabledProviders:
- anthropic
- openai
- gemini
# <repo>/.omp/config.yml
tools:
approval:
bash: allow
disabledProviders:
- groqEffective settings inside <repo>:
tools:
approvalMode: write # kept from global (object deep-merge)
approval:
bash: allow # overridden by project
read: allow # kept from global
disabledProviders:
- groq # project array REPLACES the global arrayArray replacement is the most common surprise: the project’s disabledProviders does not extend the global list — it becomes the entire list for that project. The same applies to enabledModels, cycleOrder, extensions, and every other array-typed setting.
Project-local config
Create <repo>/.omp/config.yml when a repository needs its own settings:
# <repo>/.omp/config.yml
modelRoles:
default: anthropic/claude-sonnet-4-5
smol: openai/gpt-4.1-mini
slow: anthropic/claude-opus-4-5:high
tools:
approvalMode: write
approval:
bash: prompt
compaction:
strategy: snapcompact
thresholdPercent: 80
theme:
dark: titaniumKeep secrets out of committed project config unless your repository policy allows it. Prefer environment variables, stored auth, an auth broker, or an untracked --config overlay for credentials.
One-shot overlays
Use --config for a temporary layer that should not persist:
omp --config ./local/ci-settings.yml "check this failure"
omp --config ./base.yml --config ./experiment.yml "try this model"Overlay paths are resolved relative to the process working directory (and ~ is expanded). Each overlay must parse as a YAML mapping; a missing file, invalid YAML, or a top-level array/scalar is a hard error — it does not silently fall back to lower-precedence settings.
Path-scoped arrays
Two array settings — enabledModels and disabledProviders — accept path-scoped entries in addition to bare strings, so a single global config can behave differently per directory:
enabledModels:
- claude-sonnet-4-5 # applies everywhere
- path: ~/work/high-context
models:
- anthropic/claude-opus-4-5
disabledProviders:
- ollama # applies everywhere
- paths:
- ~/projects/sensitive
- ~/clients/acme
providers:
- anthropic
- openaiBare string entries apply everywhere. A scoped entry applies when the current working directory is the configured path or is under it. ~ expands to your home directory and relative paths are resolved before matching.
Accepted path keys (any of them, combined): path, paths, pathPrefix, pathPrefixes.
Accepted value keys:
models(forenabledModels) orproviders(fordisabledProviders)valuesoritems(for either setting)
Only string values are kept; malformed scoped entries are ignored. Path scoping is resolved after the layer merge, so it reads the final effective array.
Provider and source disabling
disabledProviders is a single shared id namespace that gates two different subsystems, before any credential check:
| Entry kind | Example ids | Effect |
|---|---|---|
| Model providers | anthropic, openai, gemini, groq, ollama, openrouter | Removes those backends from model selection, even when credentials are available. See Providers. |
| Discovery sources | native, claude, codex, gemini, github, opencode, cursor, agents-md | Stops that source from contributing context files, MCP servers, commands, skills, hooks, tools, prompts, or settings. See Context files. |
Most provider-control use cases list model provider ids. Disabling the claude discovery source is different from disabling the anthropic model provider — one stops Claude-format config discovery, the other stops the Anthropic model backend.
Because arrays replace rather than append, a project that sets disabledProviders must list the complete desired set:
# ~/.omp/agent/config.yml
disabledProviders:
- anthropic
- openai
# <repo>/.omp/config.yml — inside this repo ONLY groq is disabled
disabledProviders:
- groqThe default is an empty array (nothing disabled). For the two subsystems’ provider ids and ordering, see Providers and Context files.
Settings catalog
Every key below is defined in the settings schema; omp config list shows the full set with current values. Defaults and enum values are taken from the schema. Settings that accept an env or flag override are noted; those overrides are process-local and not persisted.
Models
modelRoles, modelTags, and cycleOrder work together to define the models you can switch between. Role values may carry a thinking suffix (:minimal, :low, :medium, :high, :xhigh).
modelRoles:
default: anthropic/claude-sonnet-4-5
smol: openai/gpt-4.1-mini
slow: anthropic/claude-opus-4-5:high
vision: gemini/gemini-3-pro-preview
plan: anthropic/claude-opus-4-5
advisor: anthropic/claude-sonnet-4-5:medium
cycleOrder:
- smol
- default
- slow
modelProviderOrder:
- anthropic
- openai
enabledModels:
- claude-sonnet-4-5| Key | Type | Default | Notes |
|---|---|---|---|
modelRoles | record | {} | Map of role name -> model id. Built-in roles: default, smol, slow, vision, plan, designer, commit, title, task, advisor. Per-role env/flags exist only for --model/--smol/--slow/--plan; configure the advisor with modelRoles.advisor. |
modelTags | record | {} | Custom role/tag metadata; can introduce additional roles. |
modelProviderOrder | array | [] | Preferred provider order when a model id is ambiguous. |
cycleOrder | array | ["smol","default","slow"] | Roles cycled by the model switcher. |
enabledModels | array | [] | Allow-list of models; supports path-scoped entries. Empty means all available models. |
disabledProviders | array | [] | Disabled model/discovery providers; supports path-scoped entries. See above. |
includeModelInPrompt | boolean | true | Include the active model name in the system prompt. |
See Models for the models.yml schema and custom-provider definitions.
Advisor
The advisor is a second model that reviews each completed turn and can inject advice into the primary session. Assign a model with modelRoles.advisor, then enable it with advisor.enabled, /advisor on, or by launching with the --advisor flag.
See Advisor and WATCHDOG.md for runtime behavior, WATCHDOG.md discovery, and bounded catch-up semantics.
| Key | Type | Default | Notes |
|---|---|---|---|
advisor.enabled | boolean | false | Enable the advisor runtime when modelRoles.advisor resolves to an available model. |
advisor.subagents | boolean | false | Also enable advisor runtimes for spawned task/eval subagents. |
advisor.syncBacklog | enum | off | Bounded advisor catch-up delay: off, 1, 3, or 5. The primary waits up to 30 seconds only while advisor backlog is at or above the threshold. |
advisor.immuneTurns | number | 3 | After a concern/blocker interrupts, route further concerns/blockers as non-interrupting asides for this many completed primary turns. |
Thinking
defaultThinkingLevel: high
hideThinkingBlock: false
thinkingBudgets:
minimal: 1024
low: 2048
medium: 8192
high: 16384
xhigh: 32768| Key | Type | Default | Values |
|---|---|---|---|
defaultThinkingLevel | enum | high | minimal, low, medium, high, xhigh, auto. Override per run with --thinking. |
hideThinkingBlock | boolean | false | Hide thinking blocks in output. --hide-thinking sets it for the run (display only). |
thinkingBudgets.minimal | number | 1024 | Token budget for the minimal level. |
thinkingBudgets.low | number | 2048 | Token budget for low. |
thinkingBudgets.medium | number | 8192 | Token budget for medium. |
thinkingBudgets.high | number | 16384 | Token budget for high. |
thinkingBudgets.xhigh | number | 32768 | Token budget for xhigh. |
Sampling
A value of -1 means “use the provider/model default” — omp does not send that parameter.
| Key | Type | Default | Notes |
|---|---|---|---|
temperature | number | -1 | Sampling temperature. |
topP | number | -1 | Nucleus sampling. |
topK | number | -1 | Top-K sampling. |
minP | number | -1 | Minimum-probability cutoff. |
presencePenalty | number | -1 | Presence penalty. |
repetitionPenalty | number | -1 | Repetition penalty. |
serviceTier | enum | none | none, auto, default, flex, scale, priority, openai-only, claude-only. |
personality | enum | default | default, friendly, pragmatic, none. |
Retry and fallback
retry:
enabled: true
maxRetries: 10
baseDelayMs: 500
maxDelayMs: 300000
modelFallback: true
fallbackRevertPolicy: cooldown-expiry| Key | Type | Default | Notes |
|---|---|---|---|
retry.enabled | boolean | true | Retry transient provider errors. |
retry.maxRetries | number | 10 | Max retries per request. |
retry.baseDelayMs | number | 500 | Initial backoff. |
retry.maxDelayMs | number | 300000 | Backoff ceiling (5 min). |
retry.modelFallback | boolean | true | Fall back to another model when one is unavailable. |
retry.fallbackChains | record | {} | Per-model fallback chains. |
retry.fallbackRevertPolicy | enum | cooldown-expiry | cooldown-expiry, never. |
Tools and approvals
tools:
approvalMode: yolo # default
approval:
bash: prompt
edit: allow
discoveryMode: auto
maxTimeout: 0
intentTracing: true| Key | Type | Default | Notes |
|---|---|---|---|
tools.approvalMode | enum | yolo | always-ask (auto-approve read-only), write (auto-approve read + workspace-write), yolo (auto-approve all tiers). --approval-mode and --auto-approve/--yolo override per run. |
tools.approval | record | {} | Per-tool policy keyed by tool name; each value is allow, deny, or prompt. e.g. omp config set tools.approval '{"bash":"prompt"}'. |
tools.discoveryMode | enum | auto | auto, off, mcp-only, all. Controls dynamic tool discovery. |
tools.essentialOverride | array | [] | Tool names kept available even when tools are narrowed. |
tools.maxTimeout | number | 0 | Max tool runtime in seconds; 0 = no cap. |
tools.intentTracing | boolean | true | Record per-call intent strings. |
tools.outputMaxColumns | number | 768 | Per-line byte cap for streaming output; 0 disables. |
tools.artifactSpillThreshold | number | 50 | KB of tool output above which output spills to an artifact. |
tools.artifactHeadBytes | number | 20 | KB of head kept inline on spill; 0 = tail-only. |
tools.artifactTailBytes | number | 20 | KB of tail kept inline on spill. |
tools.artifactTailLines | number | 500 | Max tail lines kept inline on spill. |
Individual built-in tools are toggled by their own keys, e.g. bash.enabled, eval.py, eval.js, find.enabled, search.enabled, fetch.enabled, browser.enabled, astEdit.enabled, astGrep.enabled, web_search.enabled, inspect_image.enabled.
Shell, eval, and LSP
bash:
enabled: true
stripTrailingHeadTail: true
autoBackground:
enabled: false
thresholdMs: 60000
eval:
py: true
js: true
python:
kernelMode: session # session, per-call
interpreter: ""
lsp:
enabled: true
lazy: true
diagnosticsOnWrite: true
diagnosticsOnEdit: false
formatOnWrite: false| Key | Type | Default | Notes |
|---|---|---|---|
bash.enabled | boolean | true | Enable the bash tool. |
bash.stripTrailingHeadTail | boolean | true | Strip trailing head/tail noise from output. |
bash.autoBackground.enabled | boolean | false | Auto-background long-running commands. |
bash.autoBackground.thresholdMs | number | 60000 | Threshold before auto-backgrounding. |
eval.py | boolean | true | Python eval backend. PI_PY=0 disables for the process. |
eval.js | boolean | true | JavaScript eval backend. PI_JS=0 disables for the process. |
python.kernelMode | enum | session | session (persistent kernel) or per-call. |
python.interpreter | string | "" | Path to a Python interpreter; empty = auto-detect. |
lsp.enabled | boolean | true | Language-server integration. --no-lsp disables for the run. |
lsp.lazy | boolean | true | Start servers on demand. |
lsp.diagnosticsOnWrite | boolean | true | Run diagnostics after a write. |
lsp.diagnosticsOnEdit | boolean | false | Run diagnostics after an edit. |
lsp.formatOnWrite | boolean | false | Format files on write. |
lsp.diagnosticsDeduplicate | boolean | true | Collapse duplicate diagnostics. |
shellPath | string | (unset) | Override the shell binary used by bash. |
Files: editing and reading
edit:
mode: hashline # apply_patch, hashline, patch, replace
fuzzyMatch: true
fuzzyThreshold: 0.95
blockAutoGenerated: true
read:
defaultLimit: 300
toolResultPreview: false
summarize:
enabled: true
prose: false| Key | Type | Default | Notes |
|---|---|---|---|
edit.mode | enum | hashline | apply_patch, hashline, patch, replace. |
edit.fuzzyMatch | boolean | true | Allow fuzzy anchor matching. |
edit.fuzzyThreshold | number | 0.95 | Similarity threshold for fuzzy matching. |
edit.blockAutoGenerated | boolean | true | Refuse to edit generated/lockfile-like files. |
edit.streamingAbort | boolean | false | Abort on streaming edit mismatch. |
read.defaultLimit | number | 300 | Default line count for read without a selector. |
read.summarize.enabled | boolean | true | Structural summaries for code reads. |
read.summarize.prose | boolean | false | Summarize prose files too. |
read.toolResultPreview | boolean | false | Inline preview of tool results. |
readLineNumbers | boolean | false | Show plain line numbers. |
Context, compaction, and memory
contextPromotion:
enabled: true
compaction:
enabled: true
strategy: snapcompact # context-full, handoff, shake, snapcompact, off
thresholdPercent: -1 # -1 = default reserve-based behavior
thresholdTokens: -1 # fixed token limit when > 0
remoteEnabled: true
memory:
backend: off # off, local, hindsight, mnemopi| Key | Type | Default | Notes |
|---|---|---|---|
contextPromotion.enabled | boolean | true | Promote relevant earlier context. |
compaction.enabled | boolean | true | Automatic conversation compaction. |
compaction.strategy | enum | snapcompact | context-full, handoff, shake, snapcompact, off. |
compaction.thresholdPercent | number | -1 | Percent-of-context trigger; -1 = reserve-based default. |
compaction.thresholdTokens | number | -1 | Fixed token trigger when > 0. |
compaction.reserveTokens | number | 16384 | Tokens reserved for the next turn. |
compaction.keepRecentTokens | number | 20000 | Recent tokens always preserved. |
compaction.remoteEnabled | boolean | true | Allow remote compaction service. |
compaction.autoContinue | boolean | true | Continue automatically after compaction. |
memory.backend | enum | off | off, local, hindsight, mnemopi. Each backend has its own hindsight.* / mnemopi.* / memories.* tuning keys. |
autolearn.enabled | boolean | false | Experimental: after the agent stops, nudge it to capture lessons to memory and create/enhance isolated managed skills under ~/.omp/agent/managed-skills. Enables the manage_skill tool (and learn when a memory backend is active). |
autolearn.autoContinue | boolean | false | When autolearn.enabled, auto-run one capture turn at stop (uses extra tokens). Off = a passive reminder rides your next turn. |
autolearn.minToolCalls | number | 5 | Only nudge after a turn that used at least this many tools. |
compaction has additional tuning keys (idle compaction, supersede/drop heuristics) visible in omp config list. See Compaction for the full strategy reference.
Appearance and terminal
theme:
dark: titanium
light: light
symbolPreset: unicode # unicode, nerd, ascii
colorBlindMode: false
statusLine:
preset: default # default, minimal, compact, full, nerd, ascii, custom
separator: powerline-thin
transparent: false
showHookStatus: true
terminal:
showImages: true
images:
autoResize: true
blockImages: false
tui:
hyperlinks: auto # off, auto, always| Key | Type | Default | Values |
|---|---|---|---|
theme.dark | string | titanium | Theme used on a dark terminal background. |
theme.light | string | light | Theme used on a light terminal background. |
symbolPreset | enum | unicode | unicode, nerd, ascii. |
colorBlindMode | boolean | false | Use blue instead of green for diff additions. |
showHardwareCursor | boolean | true | Show the terminal hardware cursor. |
statusLine.preset | enum | default | default, minimal, compact, full, nerd, ascii, custom. |
statusLine.separator | enum | powerline-thin | powerline, powerline-thin, slash, pipe, block, none, ascii. |
statusLine.sessionAccent | boolean | true | Tint the editor border with the session color. |
statusLine.transparent | boolean | false | Use the terminal background for the status line. |
statusLine.showHookStatus | boolean | true | Show hook status messages. |
terminal.showImages | boolean | true | Render images inline (when the terminal supports it). |
images.autoResize | boolean | true | Resize large images for model compatibility. |
images.blockImages | boolean | false | Never send images to providers. |
tui.hyperlinks | enum | auto | off, auto, always. |
For a custom status line, set statusLine.preset: custom and configure statusLine.leftSegments, statusLine.rightSegments, and statusLine.segmentOptions.
Interaction
| Key | Type | Default | Values |
|---|---|---|---|
steeringMode | enum | one-at-a-time | all, one-at-a-time. How queued steering messages are delivered. |
followUpMode | enum | one-at-a-time | all, one-at-a-time. |
interruptMode | enum | immediate | immediate, wait. |
doubleEscapeAction | enum | tree | branch, tree, none. |
autoResume | boolean | false | Auto-resume the most recent session in the cwd. |
ask.timeout | number | 0 | Seconds before an ask prompt times out; 0 = no timeout. (Legacy ms values are migrated to seconds.) |
ask.notify | enum | on | on, off. |
Providers and services
providers:
webSearch: auto
image: auto
fetch: auto
tinyModel: online
tinyModelDevice: default
tinyModelDtype: default
openaiWebsockets: auto
openrouterVariant: default
kimiApiFormat: anthropic
provider:
appendOnlyContext: auto # auto, on, off
exa:
enabled: true
enableSearch: true
enableResearcher: false
enableWebsets: false
searxng:
endpoint: https://search.example.com
token: SEARXNG_TOKEN| Key | Type | Default | Values / notes |
|---|---|---|---|
providers.webSearch | enum | auto | auto plus the configured search providers (perplexity, gemini, anthropic, codex, zai, exa, jina, kagi, tavily, brave, kimi, parallel, synthetic, searxng). |
providers.image | enum | auto | auto, openai, antigravity, xai, gemini, openrouter. |
providers.fetch | enum | auto | auto, native, trafilatura, lynx, parallel, jina. |
providers.tinyModel | enum | online | online or a local model (lfm2-350m, qwen3-0.6b, gemma-270m, qwen2.5-0.5b, lfm2-700m). |
providers.tinyModelDevice | enum | default | ONNX execution provider for local tiny models. Overridden by PI_TINY_DEVICE. |
providers.tinyModelDtype | enum | default | ONNX precision for local tiny models. Overridden by PI_TINY_DTYPE. |
providers.openaiWebsockets | enum | auto | auto, off, on. |
providers.openrouterVariant | enum | default | default, nitro, floor, online, exacto. |
providers.kimiApiFormat | enum | anthropic | openai, anthropic. |
provider.appendOnlyContext | enum | auto | auto, on, off. |
exa.enabled | boolean | true | Enable Exa integration. |
exa.enableSearch | boolean | true | Exa search. |
exa.enableResearcher | boolean | false | Exa researcher. |
exa.enableWebsets | boolean | false | Exa websets. |
searxng.endpoint | string | (unset) | SearXNG instance URL. |
searxng.token | string | (unset) | SearXNG token; also searxng.basicUsername/searxng.basicPassword/searxng.categories/searxng.language. |
auth.broker.url | string | (unset) | Auth-broker URL. Overridden by OMP_AUTH_BROKER_URL. |
auth.broker.token | string | (unset) | Auth-broker token. Overridden by OMP_AUTH_BROKER_TOKEN. |
Provider credentials and custom model definitions are configured separately — see Providers and Models.
Other groups
omp config list exposes many more grouped settings, including: task.* (subagent concurrency, isolation, model overrides), skills.* and commands.* (discovery toggles), mcp.*, github.*, async.*, goal.*, loop.*, todo.*, magicKeywords.*, ttsr.* (time-traveling stream rules), display.*, startup.*, share.*, collab.*, stt.*/tts.*, memories.*/hindsight.*/mnemopi.* (memory backends), and bashInterceptor.*. Each follows the same type/default rules shown above.
Legacy migration
omp migrates older config shapes automatically. None of these require action; they are listed so you know what changes you may see in config.yml.
Startup migration to config.yml
When ~/.omp/agent/config.yml does not exist, startup builds it once from legacy sources, then writes the result:
~/.omp/agent/settings.json(renamed tosettings.json.bakafter a successful migration).- Settings persisted in
agent.db.
After config.yml exists, these legacy sources are no longer consulted. The generic config loader also performs .json -> .yml migration for other config files when only the .json form is present.
Field-level migrations
Applied whenever raw settings are loaded (global, project, overlays, and runtime overrides):
| Old | New |
|---|---|
queueMode | steeringMode |
ask.timeout in milliseconds (value > 1000) | seconds (divided by 1000) |
flat theme: "<name>" string | theme.dark / theme.light (slot chosen by luminance; built-in light/dark are dropped to use defaults) |
task.isolation.enabled: true/false | task.isolation.mode: auto/none |
task.simple | removed |
legacy task.isolation.mode (worktree, fuse-overlay, fuse-projfs) | rcopy, overlayfs, projfs |
lastChangelogVersion | moved to a marker file and stripped from config.yml |
Troubleshooting
A project setting is not taking effect
- Start
ompfrom the directory that contains.omp/config.yml. Settings discovery only checks the current working directory’s.omp/, not ancestor directories. - Ensure
.omp/is non-empty; empty config directories are ignored. - Confirm the file is valid YAML and its top level is a mapping.
- Run
omp config get <key>from that directory to see the effective value. - Remember that
--configoverlays and runtime flags override project config.
A global array disappeared in a project
Arrays replace; they do not append. If a project sets disabledProviders, enabledModels, cycleOrder, extensions, or any other array, include the complete desired value in the project layer — the global array is fully replaced.
A provider is still available after editing config
- Check whether you disabled the model provider id (e.g.
anthropic) or a discovery source id (e.g.claude) — they are different namespaces with different effects. - Check for a project (or overlay)
disabledProvidersarray replacing your global one. - Credentials can still come from environment variables,
.env, OAuth, stored auth, ormodels.yml; disabling a provider blocks selection regardless, but verify you edited the right layer. See Providers. - Restart the session if the model list was already initialized.
omp config set changed the wrong file
omp config set and omp config reset always write the global config.yml under the active agent directory. Run omp config path to print it. For project-local settings, edit <repo>/.omp/config.yml directly.
omp config reset did not remove my key
reset writes the schema default value into the global config — it persists the default rather than deleting the key. To stop overriding a project value from global config, delete the key from ~/.omp/agent/config.yml by hand.
A --config overlay fails at startup
--config files are process-local YAML mappings. A missing file, invalid YAML, or a top-level array/scalar is a hard error — it does not silently fall back to lower-precedence settings. Fix the path or contents.
An environment variable beats my config
Some settings (model roles, eval backends, tiny-model device/precision, auth broker, PTY) are overridable by env vars or CLI flags for per-machine convenience, and those take precedence over config.yml. Unset the variable or drop the flag to let the persisted value win. See Environment overrides and Environment variables.
omp config set <key> says “Unknown setting”
Keys must match a schema path exactly, with no shorthand. Use theme.dark, not theme. Run omp config list to see every valid key.
This document covers the current TUI contract used by packages/coding-agent and packages/tui for extension UI, custom tool UI, and custom renderers.
What this subsystem is
The runtime has two layers:
- Rendering engine (
packages/tui): differential terminal renderer, input dispatch, focus, overlays, cursor placement. - Integration layer (
packages/coding-agent): mounts extension/custom-tool components, wires keybindings/theme, and restores editor state.
Runtime behavior by mode
| Mode | ctx.ui.custom(...) availability | Notes |
|---|---|---|
| Interactive TUI | Supported | Component is mounted in the editor area or overlay, focused, and must call done(result) to resolve. |
| Background/headless | Not interactive | UI context is no-op (hasUI === false). |
| RPC mode | Not mounted | custom() is implemented as unsupported UI and returns undefined as never; do not depend on interactive UI in RPC handlers. |
If your extension/tool can run in non-interactive mode, guard with ctx.hasUI / pi.hasUI.
Core component contract (@oh-my-pi/pi-tui)
packages/tui/src/tui.ts defines:
export interface Component {
render(width: number): readonly string[];
handleInput?(data: string): void;
wantsKeyRelease?: boolean;
invalidate?(): void;
dispose?(): void;
}Render results are component-owned and immutable to callers; a component that did not change should return the same array reference it returned last time (reference equality is what enables the renderer’s memoization and row virtualization), and must return a new array whenever its content changed.
Focusable is separate:
export interface Focusable {
focused: boolean;
setUseTerminalCursor?(useTerminalCursor: boolean): void;
}Cursor behavior uses CURSOR_MARKER (not getCursorPosition). Focused components emit the marker in rendered text; TUI extracts it and positions the hardware cursor.
Rendering constraints (terminal safety)
Your render(width) output must be terminal-safe:
- Do not intentionally exceed
widthon any line. The renderer truncates overwide non-image lines as a last-resort guard, but components should still return width-safe output. - Measure visual width, not string length: use
visibleWidth(). - Truncate/wrap ANSI-aware text with
truncateToWidth()/wrapTextWithAnsi(). - Sanitize tabs/content from external sources using
replaceTabs()(and higher-level sanitizers in coding-agent render paths).
Minimal pattern:
import { replaceTabs, truncateToWidth } from "@oh-my-pi/pi-tui";
render(width: number): readonly string[] {
return this.lines.map(line => truncateToWidth(replaceTabs(line), width));
}Input handling and keybindings
Raw key matching
Use matchesKey(data, "...") for navigation keys and combos.
Match app keybinding actions
Extension UI factories receive a KeybindingsManager (interactive mode; an in-memory instance carrying the default bindings, not the user’s keybindings.yml) so you can match action ids instead of hardcoding keys:
if (keybindings.matches(data, "app.interrupt")) {
done(undefined);
return;
}Key release/repeat events
Key release events are filtered unless your component sets:
wantsKeyRelease = true;Then use isKeyRelease() / isKeyRepeat() if needed.
Focus, overlays, and cursor
TUI.setFocus(component)routes input to that component.- Overlay APIs exist in
TUI(showOverlay,OverlayHandle). In interactive extension/custom UI,custom(..., { overlay: true })mounts your component throughTUI.showOverlay(...); withoutoverlay, it replaces the editor component area directly. - Overlay custom UI is anchored at
bottom-centerwith full terminal width/max height and is removed through the returned overlay handle whendone(...)closes the flow.
Mount points and return contracts
1) Extension UI (ExtensionUIContext)
Current signature (extensibility/extensions/types.ts):
custom<T>(
factory: (
tui: TUI,
theme: Theme,
keybindings: KeybindingsManager,
done: (result: T) => void,
) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
options?: { overlay?: boolean },
): Promise<T>Behavior in interactive mode (extension-ui-controller.ts):
- Saves editor text.
- Without
options.overlay, replaces the editor component with your component. - With
options.overlay, mounts your component as a bottom-centered overlay instead of replacing the editor. - Focuses your component.
- On
done(result): callscomponent.dispose?.(), hides the overlay if present, restores editor + text for non-overlay flows, focuses editor, resolves promise. Sodone(...)is mandatory for completion.
2) Hook/custom-tool UI context (legacy typing)
HookUIContext.custom is typed as (tui, theme, done) in hook/custom-tool types.
Underlying interactive implementation calls factories with (tui, theme, keybindings, done). JS consumers can use the extra arg; type-level compatibility still reflects the 3-arg legacy signature.
Custom tools typically use the same UI entrypoint via the factory-scoped pi.ui object, then return the selected value in normal tool content:
async execute(toolCallId, params, onUpdate, ctx, signal) {
if (!pi.hasUI) {
return { content: [{ type: "text", text: "UI unavailable" }] };
}
const picked = await pi.ui.custom<string | undefined>((tui, theme, done) => {
const component = new MyPickerComponent(done, signal);
return component;
});
return { content: [{ type: "text", text: picked ? `Picked: ${picked}` : "Cancelled" }] };
}3) Custom tool call/result renderers
Custom tools and extension tools can return components from:
renderCall(args, options, theme)renderResult(result, options, theme, args?)
options currently includes:
expanded: booleanisPartial: booleanspinnerFrame?: number
These renderers are mounted by ToolExecutionComponent.
Lifecycle and cancellation
dispose()is optional at type level but should be implemented when you own timers, subprocesses, watchers, sockets, or overlays.done(...)should be called exactly once from your component flow.- For cancellable long-running UI, pair
CancellableLoaderwithAbortSignaland calldone(...)fromonAbort.
Example cancellation pattern:
const loader = new CancellableLoader(
tui,
theme.fg("accent"),
theme.fg("muted"),
"Working...",
);
loader.onAbort = () => done(undefined);
void doWork(loader.signal).then((result) => done(result));
return loader;Realistic custom component example (extension command)
import type { Component } from "@oh-my-pi/pi-tui";
import {
SelectList,
matchesKey,
replaceTabs,
truncateToWidth,
} from "@oh-my-pi/pi-tui";
import {
getSelectListTheme,
type ExtensionAPI,
} from "@oh-my-pi/pi-coding-agent";
class Picker implements Component {
list: SelectList;
keybindings: any;
done: (value: string | undefined) => void;
constructor(
items: Array<{ value: string; label: string }>,
keybindings: any,
done: (value: string | undefined) => void,
) {
this.list = new SelectList(items, 8, getSelectListTheme());
this.keybindings = keybindings;
this.done = done;
this.list.onSelect = (item) => this.done(item.value);
this.list.onCancel = () => this.done(undefined);
}
handleInput(data: string): void {
if (this.keybindings.matches(data, "app.interrupt")) {
this.done(undefined);
return;
}
this.list.handleInput(data);
}
render(width: number): readonly string[] {
return this.list
.render(width)
.map((line) => truncateToWidth(replaceTabs(line), width));
}
invalidate(): void {
this.list.invalidate();
}
}
export default function extension(pi: ExtensionAPI): void {
pi.registerCommand("pick-model", {
description: "Pick a model profile",
handler: async (_args, ctx) => {
if (!ctx.hasUI) return;
const selected = await ctx.ui.custom<string | undefined>(
(tui, theme, keybindings, done) => {
const items = [
{ value: "fast", label: theme.fg("accent", "Fast") },
{ value: "balanced", label: "Balanced" },
{ value: "quality", label: "Quality" },
];
return new Picker(items, keybindings, done);
},
);
if (selected) ctx.ui.notify(`Selected profile: ${selected}`, "info");
},
});
}Key implementation files
packages/tui/src/tui.ts—Component,Focusable, cursor marker, focus, overlay, input dispatch.packages/tui/src/utils.ts— width/truncation/sanitization primitives.packages/tui/src/keys.ts/keybindings.ts— key parsing and configurable action mapping.packages/coding-agent/src/modes/controllers/extension-ui-controller.ts— interactive mounting/unmounting for extension/hook/custom-tool UI.packages/coding-agent/src/extensibility/extensions/types.ts— extension UI and renderer contracts.packages/coding-agent/src/extensibility/hooks/types.ts— hook UI contract (legacy custom signature).packages/coding-agent/src/extensibility/custom-tools/types.ts— custom tool execute/render contracts.packages/coding-agent/src/modes/components/tool-execution.ts— mountingrenderCall/renderResultcomponents and partial-state options.packages/coding-agent/src/tools/context.ts— tool UI context propagation (hasUI,ui).
What you are dealing with before you touch the rendering engine. This is the
companion to tui-runtime-internals.md: that doc
maps the flow (input → component tree → render); this doc explains the
render contract, why it is shaped this way, and the invariants you must not
violate. Scope is the core engine only:
packages/tui/src/tui.ts— frame pipeline, commit ledger, window math, emitters, cursor placement.packages/tui/src/terminal.ts—ProcessTerminal, capability probes, private-CSI reassembly.packages/tui/src/terminal-capabilities.ts—TERMINALprofile, sync-output / DECCARA / image detection.packages/tui/src/stdin-buffer.ts— escape-sequence reassembly.packages/tui/src/utils.ts— width/slice/wrap (the width model).packages/tui/src/kitty-graphics.ts+components/image.ts— inline images.packages/tui/src/deccara.ts— rectangular-fill optimizer.
Application-layer renderers (transcript, tool calls, session tree, editor,
widgets) are out of scope — they live in packages/coding-agent. The one
app-layer file that is load-bearing for this contract is
transcript-container.ts,
which implements the commit-boundary seam described below.
1. The one thing to understand first
The renderer cannot observe the terminal’s scroll position (ConPTY’s probe lies; POSIX has no API at all). The previous engine tried to guess when it was safe to rewrite native scrollback, and every policy choice over that unobservable variable traded one failure family for another (yank ↔ flash ↔ corruption ↔ invisible-until-resize — see the git history of this file for the full war journal). The current engine removes the guess entirely: native scrollback is append-only.
We keep the transcript on the normal screen (native scrollback, native selection, transcript persists after exit). The engine maintains one ledger:
committedRows(C) — frame rows[0, C)have been physically scrolled into terminal history. They are immutable: the engine never rewrites them, and components must never change them.windowTopRow(W) — the frame row mapped to grid row 0. The visible window is frame rows[W, W + height), repainted in place with relative cursor moves.- commit boundary — reported by the component tree per frame
(
NativeScrollbackLiveRegion) as two nested ends:- byte-stable end (B) —
commitSafeEnd ?? liveRegionStart ?? frame.length. Rows below B are asserted never to re-layout and stay under the committed-prefix audit. - durable end (D) —
max(B, snapshotSafeEnd ?? B). Rows in[B, D)may still drift bytes later (a streaming markdown table re-aligning columns) but are durable — their current snapshot is permanent content, so dropping them when they scroll off is forbidden. They commit audit-exempt: later drift becomes a frozen stale row in history, never a re-anchor.
- byte-stable end (B) —
Per ordinary frame: W = max(C, L − height), C' = max(C, min(D, W)), and the
only bytes that ever touch history are the chunk frame[C, C') written at
the scrollback seam. The engine also tracks auditRows (A ≤ C) — the
byte-stable leading prefix [0, A); the committed-prefix audit (§2) samples only
that prefix, so the durable suffix [A, C) drifting never triggers a re-anchor.
Scrollback therefore equals frame[0..C) — every row exactly once, in order,
with its content at commit time. There is nothing to guess, nothing to defer,
and nothing to reconcile: the scroll position is irrelevant because ordinary
updates never rewrite anything a scrolled reader could be looking at.
What this costs (the accepted tradeoffs)
- A block that has scrolled past the window top cannot reflow in place. A byte-stable block stays in the live region (below B) until final; a durable block (below D) commits its scroll-off snapshot, so a late layout change of an already-committed row is a frozen stale row in history (duplication never loss), not a dropped row.
- A component tree that reports no seam gets shell semantics: whatever scrolls off is final. Shrinking such a frame into its committed prefix re-anchors the window and leaves the stale copy in history (§3).
- Inside multiplexers, a resize leaves the pane history wrapped at the old width (same as any shell output).
2. The frame pipeline (what you are editing)
#doRender per frame:
- Compose the frame (
render(width)), collectingliveRegionStart/commitSafeEndfrom the root children (absolute row indices). - Audit the committed prefix (
findCommittedPrefixResync, skipped on geometry frames). Components must never re-layout rows below C, but real flows violate it (a TTSR rewind truncating a streamed block, an image-cap demotion shrinking a committed image) and the violation must not become content loss. The detector samples the prefix tail (up to 8 non-blank rows in the last 24, SGR-stripped): an in-place edit or restyle disturbs only the touched rows (≤1 mismatch ⇒ aligned ⇒ ignored — stale styling in history is the accepted artifact), while any insertion/deletion shifts every row below it including the tail (⇒ re-anchor C at the first changed row and recommit from there: history keeps the stale copy and gains a fresh one — duplication, never loss). - Classify: fullPaint (first paint,
clearScrollbacksession replace, or geometry change outside a multiplexer — all user gestures) or update. - Window math as in §1. Two special rules:
- Overlays freeze commits (
C' = C): composited rows must never enter history; the hidden gap backfills via the chunk after the overlay closes. - Shrink into the committed prefix (
L ≤ C): re-anchorW = max(0, L − height), resetC = min(B, W), keep the stale history above (no gesture, no erase).
- Overlays freeze commits (
- Extract the cursor marker (strip-first: markers never reach the terminal, the prefix ledger, or the audit), prepare lines (width fitting), slice the window, composite overlays into the window slice only (screen coordinates — an overlay never touches the frame or the ledger).
- Emit:
| Emitter | Bytes | When |
|---|---|---|
#emitFullPaint | clears + frame[0, C') + window rows | gestures only. clearScrollback ⇒ \x1b[2J\x1b[H\x1b[3J; otherwise ED22 (when supported) + \x1b[2J\x1b[H |
#emitUpdate scroll-append | \r\n + new bottom rows + changed-row range | the rows leaving the screen are exactly the chunk, content untouched since painted |
#emitUpdate in-window diff | relative move + changed-row range rewrite | nothing scrolls, nothing commits (cursor-only when nothing changed) |
#emitUpdate seam rewrite | chunk rows + full window rewrite | commit advance, window re-anchor, hidden-gap backfill, mux resize |
ED3 (CSI 3 J) is emitted in exactly one place — #emitFullPaint with
clearScrollback: true — and is reached only by user gestures: session
replace/branch/resume (requestRender(true, { clearScrollback: true })),
resize outside a multiplexer, resetDisplay() (Ctrl+L). A gesture pins the
user to the tail, so the snap is acceptable; multiplexers never get ED3 (it is
a no-op there and a replay would duplicate pane history).
The ordinary update path never emits ED2/ED3 or an absolute cursor home — several terminal families snap a scrolled reader to the bottom on those.
The commit-boundary seam (the load-bearing app contract)
NativeScrollbackLiveRegion (tui.ts) is how a component keeps mutable rows out
of history:
getNativeScrollbackLiveRegionStart()— first row that may still mutate (everything below it, including root chrome rendered after it, stays in the window).getNativeScrollbackCommitSafeEnd()— optional byte-stable deeper boundary (B): the append-only prefix of the live region (a streaming assistant message’s settled rows), asserted never to re-layout, so it stays under the audit.getNativeScrollbackSnapshotSafeEnd()— optional durable deeper boundary (D ≥ B): rows whose current snapshot is permanent but may still drift bytes (a streaming markdown table whose columns keep re-aligning). They commit on scroll-off (never dropped) but audit-exempt — drift after commit freezes a stale row in history rather than re-anchoring the audit and spraying duplicate snapshots. Without it, a commit-stable block that perpetually re-lays-out an interior row (a table taller than the window) had no byte-stable prefix past the table head, so its scrolled-off rows were committed nowhere and repainted nowhere — silent content loss as the reply streamed.
TranscriptContainer implements this for the coding agent: finalized blocks
freeze (their render is snapshotted, so their content can never drift after
the engine may have committed it), still-mutating blocks
(isTranscriptBlockFinalized?.() === false) anchor the live region, and
deriveLiveCommitState derives the byte-stable commit-safe end of the first
live block from two independent signals:
- append-only detection — a block observed growing without visibly
rewriting an interior row commits its full body; a rewrite suspends this
for
VOLATILE_REARM_FRAMESclean frames. - stable-prefix ratchet — rows that stayed visibly identical for a full
STABLE_PREFIX_COMMIT_FRAMESwindow commit even while the block’s tail keeps rewriting (a task tool’s static prompt above a ticking progress tree). Without it, one perpetually animating row holds the whole block out of history, so a block taller than the window reads as cut off (head neither committed nor on screen) for the entire run. The ratchet tracks the window-minimum common prefix; a rewrite above the promoted run retreats it to the divergence, and rows that already committed are the engine audit’s problem (recommit → duplication, never loss). That retreat also arms a permanent rewrite floor at the divergence: a row that mutates after surviving a full promotion window is a slow ticker (an agent row’s tool/cost counter updating every few seconds), not settling content — without the floor, every quiet stretch re-promoted it and every later tick forced an audit recommit, spraying stale snapshots of the block into scrollback for the whole run. Rows at/after the floor never re-promote while the block lives (the floor index travels with append-shaped insertions above it); one-off re-layouts before any promotion never arm it, and the append-only path commits the full block regardless.
The byte-stable end gates audited commits; the durable snapshot end is the
separate floor that guarantees no loss. TranscriptContainer reports the whole
body of a still-live commit-stable block (isTranscriptBlockCommitStable?.() !== false) as the snapshot-safe end, so its scrolled-off rows always reach
history even while its interior re-lays-out. Provisional blocks
(isTranscriptBlockCommitStable?.() === false: a collapsing tool/edit preview
whose head is a throwaway tail window) report no snapshot-safe end, so their
head is correctly dropped rather than stranded as stale history.
Freezing is unconditional — it is the engine’s required guarantee, not a per-terminal optimization.
3. Invariants — MUST / NEVER
- NEVER add a new
CSI 3 J(ED3) callsite. ED3 flows only through#emitFullPaint({ clearScrollback: true }), only for gestures, never inside multiplexers. - NEVER rewrite a committed row. No emitter may touch frame rows
< C, andW ≥ Calways (re-showing a committed row on the grid duplicates it for a scrolling reader — the historical corruption family). When a component violates immutability, the audit (§2) degrades to duplication — never silently skip rows, never erase history. - Commits are exactly the chunk. Any byte shape that scrolls the screen
must scroll only rows accounted for by
C' − C— that is what makes scrollback provablyframe[0..C). - NEVER probe the viewport position or fork on platform in the update path. win32 behaves like POSIX. The probe APIs are gone; do not reintroduce them.
- Mutable content stays below the commit boundary. App-layer renderers must finalize-before-commit; the engine trusts B and clamps, it does not verify content.
- Park the hardware cursor at real content bottom, not the padded window bottom, or height shrinks scroll live rows into history and duplicate them per resize step.
- Cursor writes live inside the synchronized-output frame, before ESU — never as a second frame after it.
- NEVER throw in the render hot path. Clamp over-wide lines
(
truncateToWidth); a width mismatch is cosmetic, not fatal. - Multiplexers get no destructive clear and no history rewrap on resize — repaint the window in place; pane history keeps its old wrap.
- Any change to the ledger math, the emitters, or the seam must be validated by the stress harness (§6) across its full scenario matrix, not by a single-terminal smoke test.
4. Terminal capability detection
TERMINAL (terminal-capabilities.ts) is resolved once at import from
TERMINAL_ID plus environment sniffing; detection helpers are pure over
(env, platform) and unit-testable.
shouldEnableSynchronizedOutputByDefault(env, id)→ DEC 2026 default. Precedence: user opt-out (PI_NO_SYNC_OUTPUT/PI_TUI_SYNC_OUTPUT=0) → user force-on (PI_FORCE_SYNC_OUTPUT=1/PI_TUI_SYNC_OUTPUT=1) →TERM_FEATURESadvertisesSy→WT_SESSION→ known direct terminals → off for risky multiplexers and unknowns. Reconciled at runtime by the DECRQM mode-2026 report; a user override still wins.detectRectangularSgrSupport(id, env)→ DECCARA fills: kitty only, off in multiplexers and underPI_NO_DECCARA.supportsScreenToScrollback→ kitty’s ED22 (used once, on the initial paint, to preserve the pre-existing shell screen).
The old ED3-risk classifier (eagerEraseScrollbackRisk, PI_TUI_ED3_SAFE,
submitPinsViewportToTail) is gone: behavior no longer depends on which
terminal is rendering, so there is no risk class to detect. Env sniffing now
only selects optimizations (sync output, DECCARA, images), where a miss is
cosmetic, not corrupting.
5. Width model
visibleWidth / truncateToWidth / sliceByColumn / wrapTextWithAnsi
(utils.ts) all agree on one UAX#11 width model. Slicing, truncation,
wrapping, and segment extraction run on the native engine
(@oh-my-pi/pi-natives, Rust unicode-width); visibleWidth measures with
Bun.stringWidth pinned to that same model (STRING_WIDTH_OPTS:
countAnsiEscapeCodes: false, ambiguousIsNarrow: true) — a JSC builtin that
shares the native width tables without the per-call N-API box the native
scanner traps on under Bun 1.3.x. The two must never disagree; mixing unpinned
width models in measure-vs-slice produced crashes.
- Fast path: printable ASCII is one cell per code unit.
- Anything past the ASCII prefix measures through
Bun.stringWidth(CSI/OSC stripped to zero); tabs are added back at the fixedDEFAULT_TAB_WIDTHcolumns. - OSC 66 sized spans are added back as
scale × (explicit w ?? payload width)—Bun.stringWidthwould otherwise strip the whole span to zero.
Rule: any new measuring code routes through these helpers, and the hot
path clamps instead of throwing. Known residual: combining-heavy scripts
(Arabic harakat) survive painting verbatim, but ghostty-web’s cell readback can
migrate non-spacing marks across cells — the stress harness compares those rows
with marks stripped (sameLinesAllowingMarkDrift).
6. The fidelity gate (use it)
packages/tui/test/render-stress-harness.ts drives the renderer’s real
emitted ANSI into a ghostty-web VirtualTerminal across randomized op
sequences and parameterized terminal shapes, and validates the contract with a
shadow commit ledger: an independent reimplementation of §1’s math, fed
only by observed frames (a render wrap) and observed bytes (a write wrap).
Per op it asserts:
- the whole tape (scrollback + grid) equals
shadowTape + window slice, row for row, including across resizes; - scrolled readers stay pinned and visible history rows are never rewritten;
- multiplexer pane history grows by exactly the committed chunk;
- sync-output/autowrap bracket discipline, cursor parking, background columns, duplicate accounting.
Run it — plus render-regressions.test.ts,
streaming-scrollback-defer.test.ts, and the issue-*-repro.test.ts files —
before changing ledger math, emitters, or the seam. A change that passes one
terminal and one seed is not verified.
7. Capability probes & stdin reassembly
ProcessTerminal fuses capability queries with a bare DA1 (CSI c) sentinel so
a non-answering terminal is detected when DA1 returns first. Replies can arrive
split across a stdin flush, so:
#privateCsiResponseBufferaccumulates\x1b[?…partials while a sentinel is outstanding, rejoins on the terminator byte, then runs the handlers on the complete reply. A new\x1bmid-reassembly or >256 bytes abandons the partial so real keys still reach input.#da1SentinelOwnersis a typed FIFO discriminated bykindso a keyboard DA1 cannot be mistaken for an OSC 11 / DECRQM / graphics-probe sentinel.- DECRQM probes (2026/2048/2031) drive runtime feature gating.
Rule: any new probe must own a typed sentinel and survive a split reply (feed the reply byte-by-byte in a test and assert nothing leaks to input).
8. Inline images & memory
Kitty images are transmit-once, place-many (kitty-graphics.ts).
ImageBudget keeps only the most-recent N images live; when the cap is
exceeded the demoted image’s pixels are deleted by id (a=d,d=I) and its
visible rows re-render as the text fallback through the ordinary window diff —
no destructive replay. A demoted placement already committed to history
simply loses its pixels (committed rows are immutable), and the text fallback
is height-preserving once a graphic has rendered (reserved rows + fallback
line), so demotion never shrinks the block and never shifts committed content
below it.
Rule: never re-emit full base64 per frame. Kitty Unicode placeholders are
default-on only for kitty/ghostty (PI_NO_KITTY_PLACEHOLDERS /
PI_KITTY_PLACEHOLDERS).
9. Escape hatches (env vars)
| Var | Effect |
|---|---|
PI_NO_SYNC_OUTPUT=1 | Disable DEC 2026 BSU/ESU wrappers (autowrap discipline stays on). |
PI_TUI_SYNC_OUTPUT=0|1 / PI_FORCE_SYNC_OUTPUT=1 | Force sync output off / on. |
PI_NO_DECCARA | Disable Kitty DECCARA rectangular-fill optimization. |
PI_FORCE_IMAGE_PROTOCOL=kitty|iterm2|sixel|off | Override image protocol detection. |
PI_NO_KITTY_PLACEHOLDERS=1 / PI_KITTY_PLACEHOLDERS=1 | Force Kitty Unicode placeholders off / on. |
PI_HARDWARE_CURSOR=1 | Show the real hardware cursor instead of a rendered one. |
PI_NOTIFICATIONS=off|0|false | Suppress terminal notifications. |
PI_DEBUG_REDRAW=1 | Log the chosen render intent + ledger state per frame to the debug log. |
PI_TUI_RESIZE_IN_PLACE=1|0 | Force resize to repaint in place (no alt-screen borrow, no ED3 rewrap) on / off. Default-on for terminals that re-report size on alt-screen toggles (Warp). |
Removed with the old engine: PI_TUI_ED3_SAFE (no ED3-risk lever exists),
PI_CLEAR_ON_SHRINK (shrinks always clear exactly), PI_TUI_DEBUG (per-render
dump superseded by PI_DEBUG_REDRAW ledger logging and the stress harness
replay/reduce tooling).
10. Before you touch the render core — checklist
- Are you about to emit
CSI 3 Janywhere other than the gesture-drivenclearScrollbackfull paint? Stop. - Could any code path rewrite, or re-show on the grid, a frame row below
committedRows? Stop. - Does your byte shape scroll rows that are not the commit chunk? That
breaks
scrollback == frame[0..C). - Are you adding a viewport probe, a platform fork, or a terminal-brand branch to the update path? The contract exists so none are needed.
- New mutable UI above the editor? It must report (or live inside) the live-region seam, or it will freeze at first commit.
- Did you run the stress harness and the repro suite across the full scenario matrix — not just one terminal and one seed?
- New probe? Typed sentinel owner + split-reply test.
- New width path? Routed through the shared native engine, clamped (never thrown) in the hot path.
State & Sessions
This document describes operator-visible behavior for session export/share/fork/resume operations as currently implemented.
Implementation files
../src/modes/controllers/command-controller.ts../src/session/agent-session.ts../src/session/session-manager.ts../src/export/html/index.ts../src/export/custom-share.ts../src/main.ts
Operation matrix
| Operation | Entry path | Session mutation | Session file creation/switch | Output artifact |
|---|---|---|---|---|
/dump | Interactive slash command | No | No | Clipboard text |
/export [path] | Interactive slash command | No | No | HTML file |
--export <session.jsonl> [outputPath] | CLI startup fast-path | No runtime session mutation | No active session; reads target file | HTML file |
/share | Interactive slash command | No | No | Encrypted share link (gist or share server); temp HTML only for custom handlers |
/fresh | Interactive slash command | Yes (provider-facing in-memory id/state only) | No; keeps current session file/header | None |
/fork | Interactive slash command | Yes (active session identity changes) | Creates new session file and switches current session to it (persistent mode only) | Copies artifact directory to new session namespace when present |
--fork <id|path> | CLI startup | Yes after session creation | Creates a new session fork from the selected source into current cwd/session dir | None |
/resume | Interactive slash command | Yes (active in-memory state replaced) | Switches to selected existing session file | None |
--resume | CLI startup picker | Yes after session creation | Opens selected existing session file | None |
--resume <id|path> | CLI startup | Yes after session creation | Opens existing session; global cross-project match re-roots (moved dir) or forks into current project | None |
--continue | CLI startup | Yes after session creation | Opens terminal breadcrumb (re-roots it if its dir was moved) or most-recent session; creates new one if none exists | None |
Export and dump
/export [outputPath] (interactive)
Flow:
- The builtin slash-command registry (
src/slash-commands/builtin-registry.ts) routes/export...toCommandController.handleExportCommandin the TUI. - The command splits on whitespace and uses only the first argument after
/exportasoutputPath. AgentSession.exportToHtml()callsexportSessionToHtml(sessionManager, state, { outputPath, themeName }).- On success, UI shows path and opens the file in browser.
Behavior details:
--copy,clipboard, andcopyarguments are explicitly rejected with a warning to use/dump.- Export embeds session header/entries/leaf plus current
systemPromptand tool descriptions from agent state. - Subagent transcripts stored next to the session file (
<session>/<AgentId>.jsonl, recursively for nested spawns) are embedded assubSessions(collectSubSessionsinsrc/export/html/index.ts; disable withincludeSubSessions: falseinExportOptions). In the page, agent ids in task tool cards open a breadcrumbed sub-session overlay. - Tool calls render through the
<omp-tool-view>web component — the React per-tool renderers shared with collab-web (packages/collab-web/src/tool-render/), prebuilt intosrc/export/html/tool-views.generated.jsbybun --cwd=packages/collab-web run build:tool-views. - No session entries are appended during export.
Caveat:
- Argument parsing is whitespace-based (
text.split(/\s+/)), so quoted paths with spaces are not preserved as a single path by this command path.
--export <inputSessionFile> [outputPath] (CLI)
Flow in main.ts:
- Handled early (before interactive/session startup).
- Calls
exportFromFile(inputPath, outputPath?). SessionManager.open(inputPath)loads entries, then HTML is generated and written.- Process prints
Exported to: ...and exits.
Behavior details:
- Missing input file surfaces as
File not found: <path>. - This path does not create an
AgentSessionand does not mutate any running session.
/dump (interactive clipboard export)
Flow:
CommandController.handleDumpCommand()callssession.formatSessionAsText().- If empty string, reports
No messages to dump yet. - Otherwise copies to clipboard via native
copyToClipboard.
Dump content includes:
- System prompt
- Active model/thinking level
- Tool definitions + parameters
- User/assistant messages
- Thinking blocks and tool calls
- Tool results and execution blocks (except
excludeFromContextbash/python entries) - Custom/hook/file mention/branch summary/compaction summary entries
No session persistence changes are made by dumping.
Share
/share publishes an end-to-end encrypted snapshot of the session and prints
a viewer link. Implementation: ../packages/coding-agent/src/export/share.ts.
Phase 1: custom share handler (if present)
loadCustomShare() checks ~/.omp/agent for first existing candidate:
share.tsshare.jsshare.mjs
Requirements:
- Module must default-export a function
(htmlPath) => Promise<CustomShareResult | string | undefined>.
If present and valid, the legacy contract is preserved: the session is
exported to a temp HTML file (${os.tmpdir()}/${Snowflake.next()}.html),
the handler receives its path, and the temp file is removed afterwards.
Handler result interpretation:
- string => treated as URL, shown and opened
- object =>
urland/ormessageshown;urlopened undefined/falsy => genericSession shared
Critical fallback behavior:
- If custom handler exists but loading fails, command errors and returns.
- If custom handler executes and throws, command errors and returns.
- In both failure cases, it does not fall back to the default flow.
- The default flow runs only when no custom share script exists.
Phase 2: default encrypted share
Only when no custom share handler is found (shareSession()):
- Builds the session snapshot (
header,entries,leafId, plus currentsystemPromptand tool descriptions from agent state). - If
share.redactSecretsis enabled (default) and secrets are configured (secrets.*), the secret obfuscator deep-walks every string in the snapshot, replacing configured/discovered secrets with placeholders. - The JSON is gzipped and sealed with a fresh AES-256-GCM key
(
[12B IV][ciphertext+tag]). - Upload target is chosen by
share.store:- Share server (default,
store: "blob") —POST <share.serverUrl>(defaulthttps://my.omp.sh/s) with the raw blob, capped at 1 MB. Oversized snapshots are trimmed until they fit: inline images first, then long strings (32 KB → 8 KB → 2 KB → 512 B caps), then oldest entries. - Secret gist (
store: "gist") — whenghis installed and authenticated, the sealed blob is pushed base64-encoded assession.ompshare.txt(budget 5 MB sealed; gist raw fetches cap at 10 MB), falling back to the share server whenghis unusable.
- Share server (default,
- The link is
<share.serverUrl>/<id>#<base64url key>in both cases. The viewer page served there fetches the blob (hex ids via the GitHub gist API, anything else from the server’s blob store) and decrypts it client-side; the key lives only in the URL fragment and never appears in any HTTP request.
The UI reports the share URL (plus the underlying gist URL and a truncation
note when applicable). Headless /share prints the same lines. Unlike
/export, /share works for in-memory (--no-session) sessions: the
snapshot is built from live entries, no session file required.
Cancellation/abort semantics in share:
- Loader has
onAborthook that restores editor UI and reportsShare cancelled. - The upload itself is not aborted mid-flight; cancellation is UI-level and checked after the upload returns.
Fork
Interactive /fork creates a new session from the current one and switches the active session identity.
Preconditions and immediate guards
- If agent is streaming,
/forkis rejected with warning. - UI status/loading indicators are cleared before operation.
Session-level flow
AgentSession.fork():
- Emits
session_before_switchwithreason: "fork"(cancellable). - Flushes pending writes.
- Calls
SessionManager.fork(). - Copies artifacts directory from old session namespace to new namespace (best-effort; non-ENOENT copy failures are logged, not fatal).
- Updates
agent.sessionId. - Emits
session_switchwithreason: "fork".
SessionManager.fork() behavior:
- Requires persistent mode and existing session file.
- Creates new session id and new JSONL file path.
- Rewrites header with:
- new
id - new timestamp
cwdunchangedparentSessionset to previous session id
- new
- Keeps all non-header entries unchanged in the new file.
Non-persistent behavior
- In-memory session manager returns
undefinedfromfork(). AgentSession.fork()returnsfalse.- UI reports
Fork failed (session not persisted or cancelled).
CLI --fork <id|path>
Startup --fork is resolved before normal session creation:
--forkis rejected with--no-session.- Path-like values (
/,\, or.jsonl) callSessionManager.forkFrom(path, cwd, sessionDir). - Other values resolve via
resolveResumableSession(...): local sessions first, then global search whensessionDiris not forced. Matching accepts lowercased session id prefixes, full JSONL filename prefixes, and timestamp-stripped filename id suffixes. - The forked file is created in the current cwd/session-dir scope and becomes the active session manager for startup.
Resume and continue
Interactive /resume
Flow:
- Opens session selector populated via
SessionManager.list(currentCwd, currentSessionDir). If the current folder has no sessions,SessionManager.listAll()is preloaded and the picker opens directly in all-projects scope. - On selection,
SelectorController.handleResumeSession(sessionPath)callssession.switchSession(sessionPath). - UI clears/rebuilds chat and todos, then reports
Resumed session(orResumed session in <dir>when the resumed session belongs to another project, in which case the process cwd and cwd-derived caches are re-pointed viaapplyCwdChange).
Notes:
- The picker starts in current-folder scope; Tab toggles to all-projects scope (lazily loading
SessionManager.listAll()on first toggle, cached afterwards).
CLI --resume
--resume (no value)
main.tslists sessions for current cwd/sessionDir and opens picker. When the current folder is empty, it falls back toSessionManager.listAll()and opens the picker in all-projects scope;No sessions foundis printed only when the global list is also empty.- Selected path is opened with
SessionManager.open(selectedPath)before session creation. Selecting a session from another project first switches the process into that project’s directory and reloads cwd-scoped settings/caches.
--resume <value>
createSessionManager() resolution order:
- If value looks like path (
/,\, or.jsonl), open directly. - Else
resolveResumableSession(...)searches:- current scope (
SessionManager.list(cwd, sessionDir)) - global sessions (
SessionManager.listAll()) only when no explicitsessionDirwas provided
- current scope (
- Matching accepts case-insensitive session id prefixes, full JSONL filename prefixes, and the id suffix after the timestamp in
<timestamp>_<sessionId>.jsonl.
Cross-project id match behavior:
- If matched session cwd differs from current cwd, behavior depends on whether the matched session’s recorded directory still exists:
- Directory gone (moved/renamed, e.g.
git worktree move): CLI asksSession's directory no longer exists (...). Move (re-root) it into the current directory? [Y/n].- On yes (default):
SessionManager.open(match.path)thenmanager.moveTo(cwd)re-roots the existing session into the current directory (no duplicate file). - On no: command cancels (returns no session). On non-TTY: command errors.
- On yes (default):
- Directory still exists (genuinely different project): CLI asks
Session found in different project ... Fork into current directory? [y/N].- On yes:
SessionManager.forkFrom(match.path, cwd, sessionDir)creates a new local forked file. - On no: command cancels. On non-TTY: command errors.
- On yes:
- Directory gone (moved/renamed, e.g.
CLI --continue
SessionManager.continueRecent(cwd, sessionDir):
- Resolves session dir for current cwd.
- Reads the terminal-scoped breadcrumb.
- If the breadcrumb points at a session recorded under a different cwd whose directory no longer exists (moved/renamed) and the current directory has no sessions of its own, re-roots that session into the current directory via
moveToinstead of starting fresh. - Otherwise, if the breadcrumb’s cwd matches the current cwd, uses the breadcrumb session; else falls back to the most recently modified session file.
- Opens the found session; if none exists, creates a new session.
This is startup-only behavior; there is no interactive /continue slash command.
How session switching actually mutates runtime state
AgentSession.switchSession(sessionPath) does the runtime transition used by resume-like operations:
- Emit
session_before_switchwithreason: "resume"andtargetSessionFile(cancellable). - Disconnect agent event subscription and abort in-flight work.
- Flush current session manager writes.
- Capture rollback state for the current session, agent messages, queued steering/follow-up/next-turn messages, model/thinking/service-tier, MCP selections, tools, and system prompt.
- Clear queued steering/follow-up/next-turn messages.
sessionManager.setSessionFile(sessionPath)and updateagent.sessionId.- Build session context from loaded entries.
- Restore MCP selections/tools/system prompt for the target session.
- Emit
session_switchwithreason: "resume". - Replace agent messages from context and sync todos.
- Close provider sessions when switching files, or when same-file reload changed replay messages.
- Restore model (if available in current registry).
- Restore or initialize thinking level and service tier.
- Reconnect agent event subscription.
- Run the registered session-switch reconciler, if any (interactive mode registers
#reconcileModeFromSession()viasetSessionSwitchReconcilerto re-enter persisted modes such as plan); reconciler errors are logged, not fatal.
If any step after the capture fails, switchSession() restores the captured state and reconnects the previous agent subscription before rethrowing.
No new session file is created by switchSession() itself.
Event emissions and cancellation points
Switch/fork lifecycle hooks
For newSession, fork, and switchSession:
- Before event:
session_before_switch- reasons:
new,fork,resume - cancellable by returning
{ cancel: true }
- reasons:
- After event:
session_switch- same reason set
- includes
previousSessionFile
ExtensionRunner.emit() returns early on the first cancelling before-event result.
Custom tool onSession behavior
SDK bridges extension session events to custom tool onSession callbacks:
session_switch->onSession({ reason: "switch", previousSessionFile })session_branch->reason: "branch"session_start->reason: "start"session_tree->reason: "tree"session_shutdown->reason: "shutdown"
These callbacks are observational; they do not cancel switch/fork.
Other cancellation surfaces relevant to this doc
/forkis blocked while streaming (user must wait/abort current response first)./resumeselector can be cancelled by user closing selector.- Cross-project
--resume <id>can be cancelled by declining fork prompt. /sharehas a UI abort path (Share cancelled); the upload itself is not killed mid-flight.
Non-persistent (in-memory) session behavior
When session manager is created with SessionManager.inMemory() (--no-session):
- Session file path is absent.
/exportfails withCannot export in-memory session to HTML(propagated to command error UI)./sharestill works: the snapshot is built from live entries./forkfails becauseSessionManager.fork()requires persistence./dumpstill works because it serializes in-memory agent state.- CLI resume/continue semantics are bypassed if
--no-sessionis set, because manager creation returns in-memory immediately.
Known implementation caveats (as of current code)
SelectorController.handleResumeSession()does not check the boolean result fromsession.switchSession(...); a hook-cancelled switch can still proceed through UI “Resumed session” repaint/status path./sharecustom-share failures do not degrade to the default encrypted share flow; they terminate the command with error./exportargument tokenization is simplistic and does not preserve quoted paths with spaces.
Reference: session.md
This document describes how session tree navigation works today: in-memory tree model, leaf movement rules, branching behavior, and extension/event integration.
What this subsystem is
The session is stored as an append-only entry log, but runtime behavior is tree-based:
- Every non-header entry has
idandparentId. - The active position is
leafIdinSessionManager. - Appending an entry always creates a child of the current leaf.
- Branching does not rewrite history; it only changes where the leaf points before the next append.
Key files:
src/session/session-manager.ts— tree data model, traversal, leaf movement, branch/session extractionsrc/session/session-context.ts—buildSessionContextcontext reconstruction (resolved root→leaf LLM context, compaction/branch-summary replay)src/session/agent-session.ts—/treenavigation flow, summarization, hook/event emissionsrc/modes/components/tree-selector.ts— interactive tree UI behavior and filteringsrc/modes/controllers/selector-controller.ts— selector orchestration for/treeand/branchsrc/slash-commands/builtin-registry.ts— command routing (/tree,/branch)src/modes/controllers/input-controller.ts— double-escape behavior andapp.session.tree/app.session.forkkeybinding wiringsrc/session/messages.ts— conversion ofbranch_summary,compaction, andcustom_messageentries into LLM context messages
Tree data model in SessionManager
Runtime indices live in a SessionEntryIndex helper, held as #index on SessionManager and kept in lockstep with the journal array #entries:
#entriesById: Map<string, SessionEntry>— fast lookup for any entry#children: Map<string | null, SessionEntry[]>— parent→children adjacency#labels: Map<string, string>— resolved labels by target entry id#leaf: string | null— current position in the tree#usage— running usage totals
Tree APIs:
getBranch(fromId?)walks parent links to root and returns root→node pathgetTree()returnsSessionTreeNode[](entry,children,label)- parent links become children arrays
- entries with missing parents are treated as roots
- children are sorted oldest→newest by timestamp
getChildren(parentId)returns direct childrengetLabel(id)resolves current label from the index’s#labelsmap
getTree() is a runtime projection; persistence remains append-only JSONL entries.
Leaf movement semantics
There are three leaf movement primitives:
-
branch(entryId)- Validates entry exists
- Sets
leafId = entryId - No new entry is written
-
resetLeaf()- Sets
leafId = null - Next append creates a new root entry (
parentId = null)
- Sets
-
branchWithSummary(branchFromId, summary, details?, fromExtension?)- Accepts
branchFromId: string | null - Sets
leafId = branchFromId - Appends a
branch_summaryentry as child of that leaf - When
branchFromIdisnull,fromIdis persisted as"root"
- Accepts
/tree navigation behavior (same session file)
AgentSession.navigateTree() is navigation, not file forking.
Flow:
- Validate target and compute abandoned path (
collectEntriesForBranchSummary) - Emit
session_before_treewithTreePreparation - Optionally summarize abandoned entries (hook-provided summary or built-in summarizer)
- Compute new leaf target:
- selecting a user message: leaf moves to its parent, and message text is returned for editor prefill
- selecting a custom_message: same rule as user message (leaf = parent, text prefills editor)
- selecting any other entry: leaf = selected entry id
- Apply leaf move:
- with summary:
branchWithSummary(newLeafId, ...) - without summary and
newLeafId === null:resetLeaf() - otherwise:
branch(newLeafId)
- with summary:
- Rebuild agent context from new leaf and emit
session_tree
Important: summary entries are attached at the new navigation position, not on the abandoned branch tail.
/branch behavior (new session file)
/branch and /tree are intentionally different:
/treenavigates within the current session file./branchcreates a new session branch file (or in-memory replacement for non-persistent mode).
User-facing /branch flow (SelectorController.showUserMessageSelector → AgentSession.branch):
- Branch source must be a user message.
- Selected user text is extracted for editor prefill.
- If selected user message is root (
parentId === null): start a new session vianewSession({ parentSession: previousSessionFile }). - Otherwise:
createBranchedSession(selectedEntry.parentId)to fork history up to the selected prompt boundary.
SessionManager.createBranchedSession(leafId) specifics:
- Builds root→leaf path via
getBranch(leafId); throws if missing. - Excludes existing
labelentries from copied path. - Rebuilds fresh label entries from the resolved label map (
labelsInEffect()) for entries that remain in path. - Persistent mode: writes new JSONL file and switches manager to it; returns new file path.
- In-memory mode: replaces in-memory entries; returns
undefined.
Context reconstruction and summary/custom integration
buildSessionContext() (in session-context.ts, exposed via SessionManager.buildSessionContext()) resolves the active root→leaf path and builds effective LLM context state:
- Tracks latest thinking/model/service-tier/mode/TTSR/MCP-selection state on path.
- Handles latest compaction on path:
- emits compaction summary first
- replays kept messages from
firstKeptEntryIdto compaction point - then replays post-compaction messages
- Includes
branch_summaryandcustom_messageentries asAgentMessageobjects.
session/messages.ts then maps these message types for model input:
branchSummaryandcompactionSummarybecome user-role templated context messagescustom/hookMessagebecome developer-role content messages (via agent-core’sconvertMessageToLlm)
So tree movement changes context by changing the active leaf path, not by mutating old entries.
Labels and tree UI behavior
Label persistence:
appendLabelChange(targetId, label?)writeslabelentries on the current leaf chain.#labels(inSessionEntryIndex) is updated immediately (set or delete).getTree()resolves current label onto each returned node.
Tree selector behavior (tree-selector.ts):
- Flattens tree for navigation, keeps active-path highlighting, and prioritizes displaying the active branch first.
- Supports filter modes:
default,no-tools,user-only,labeled-only,all.defaultsuppresseslabel,custom,model_change, andthinking_level_change; it is not a complete “hide all internal entries” filter.
- Supports free-text search over rendered semantic content.
Shift+Lopens inline label editing and writes viaappendLabelChange.
Command routing:
/treealways opens tree selector./branchopens user-message selector unlessdoubleEscapeAction=tree, in which case it also uses tree selector UX.
Extension and hook touchpoints for tree operations
Command-time extension API (ExtensionCommandContext):
branch(entryId)— create branched session filenavigateTree(targetId, { summarize? })— move within current tree/file
Events around tree navigation:
session_before_tree- receives
TreePreparation:targetIdoldLeafIdcommonAncestorIdentriesToSummarizeuserWantsSummary
- may cancel navigation
- may provide summary payload used instead of built-in summarizer
- receives abort
signal(Escape cancellation path)
- receives
session_tree- emits
newLeafId,oldLeafId - includes
summaryEntrywhen a summary was created fromExtensionindicates summary origin
- emits
Adjacent but related lifecycle hooks:
session_before_branch/session_branchfor/branchflowsession_before_compact,session.compacting,session_compactfor compaction entries that later affect tree-context reconstruction
Real constraints and edge conditions
branch()cannot targetnull; useresetLeaf()for root-before-first-entry state.branchWithSummary()supportsnulltarget and recordsfromId: "root".- Selecting current leaf in tree selector is a no-op.
- Summarization requires an active model; if absent, summarize navigation fails fast.
- If summarization is aborted, navigation is cancelled and leaf is unchanged.
- In-memory sessions never return a branch file path from
createBranchedSession. - Tree context reconstruction includes service-tier and MCP tool-selection state, but those entries do not become LLM messages.
Plan approval session naming
When a user approves a plan from plan mode (InteractiveMode.#approvePlan), the approval handler seeds the session name from the plan’s title so the resulting (fresh or compacted) session does not stay unnamed.
Trigger:
- Plan approval reaches
#approvePlan(...)withoptions.titlepopulated from the plan-approval details. - This runs for every approval choice (
Approve and execute,Approve and compact context,Approve and keep context); the syntheticplan-approvedprompt is what otherwise bypasses the input-controller’s title-generation path.
Naming source:
- The normalized plan title is humanized via
humanizePlanTitle(title)(packages/coding-agent/src/plan-mode/approved-plan.ts):- replaces runs of
-/_with a single space - trims whitespace
- capitalizes the first character
- returns
""for whitespace-only / separator-only input
- replaces runs of
- The humanized name is applied only when the current session has no name (
!sessionManager.getSessionName()). It then callssessionManager.setSessionName(name, "auto"), which also refuses to overwrite user-named sessions. - On successful apply, the terminal title (
setSessionTerminalTitle) and the editor border color are refreshed to reflect the new name.
Examples (from humanizePlanTitle):
migrate-mcp-loader→Migrate mcp loaderfix_session_naming→Fix session namingfoo--bar__baz→Foo bar bazRefactorRouter→RefactorRouter(no separators to expand)""/"---"→""(no name applied)
Legacy compatibility still present
Session migrations still run on load:
- v1→v2 adds
id/parentIdand converts compaction index anchor to id anchor - v2→v3 migrates legacy
hookMessagerole tocustom
Current runtime behavior is version-3 tree semantics after migration.
/tree opens the interactive Session Tree navigator. It lets you jump to any entry in the current session file and continue from that point.
This is an in-file leaf move, not a new session export.
What /tree does
- Builds a tree from current session entries (
SessionManager.getTree()) - Opens
TreeSelectorComponentwith keyboard navigation, filters, and search - On selection, calls
AgentSession.navigateTree(targetId, { summarize, customInstructions }) - Rebuilds visible chat from the new leaf path
- Optionally prefills editor text when selecting a user/custom message
Primary implementation:
src/slash-commands/builtin-registry.ts(/tree,/branchcommand routing)src/modes/controllers/input-controller.ts(keybinding wiring, double-escape behavior)src/modes/controllers/selector-controller.ts(tree UI launch + summary prompt flow)src/modes/components/tree-selector.ts(navigation, filters, search, labels, rendering)src/session/agent-session.ts(navigateTreeleaf switching + optional summary)src/session/session-manager.ts(getTree,branch,branchWithSummary,resetLeaf, label persistence)
How to open it
Any of the following opens the same selector:
/tree- configured keybinding for the
app.session.treeaction - double-escape on empty editor when
doubleEscapeAction = "tree"(default) /branchwhendoubleEscapeAction = "tree"(routes to tree selector instead of user-only branch picker)
Tree UI model
The tree is rendered from session entry parent pointers (id / parentId).
- Children are sorted by timestamp ascending (older first, newer lower)
- Active branch (path from root to current leaf) is marked with a bullet
- Labels (if present) render as
[label]before node text - If multiple roots exist (orphaned/broken parent chains), they are shown under a virtual branching root
Example tree view (active path marked with •):
├─ user: "Start task"
│ └─ assistant: "Plan"
│ ├─ • user: "Try approach A"
│ │ └─ • assistant: "A result"
│ │ └─ • [milestone] user: "Continue A"
│ └─ user: "Try approach B"
│ └─ assistant: "B result"The selector recenters around current selection and shows up to:
max(5, floor(terminalHeight / 2))rows
Keybindings inside tree selector
Up/Down: move selection (wraps)Left/Right: page up / page downEnter: select nodeEsc: clear search if active; otherwise close selectorCtrl+C: close selectorType: append to search queryBackspace: delete search characterShift+L: edit/clear label on selected entryCtrl+O: cycle filter forwardShift+Ctrl+O: cycle filter backwardAlt+D/T/U/L/A: jump directly to specific filter mode
Filters and search semantics
Filter modes (TreeList):
defaultno-toolsuser-onlylabeled-onlyall
default
Shows conversational nodes plus any entry types not explicitly suppressed. It hides these setting/bookkeeping entry types:
labelcustommodel_changethinking_level_change
Other internal entry types that are not rendered specially may appear as blank rows in current code.
no-tools
Same as default, plus hides toolResult messages.
user-only
Only message entries where role is user.
labeled-only
Only entries that currently resolve to a label.
all
Everything in the session tree, including bookkeeping/custom entries.
Tool-only assistant node behavior
Assistant messages that contain only tool calls (no text) are hidden by default in all filtered views unless:
- message is error/aborted (
stopReasonnotstop/toolUse), or - it is the current leaf (always kept visible)
Search behavior
- Query is tokenized by spaces
- Matching is fuzzy (subsequence) and case-insensitive (
fuzzyMatch) - All tokens must match (AND semantics)
- Searchable text includes label, role, and type-specific content (message text, branch summary text, custom type, tool command snippets, etc.)
Selection outcomes (important)
navigateTree computes new leaf behavior from selected entry type:
Selecting user message
- New leaf becomes selected entry’s
parentId - If parent is
null(root user message), leaf resets to root (resetLeaf()) - Selected message text is copied to editor for editing/resubmit
Selecting custom_message
- Same leaf rule as user messages (
parentId) - Text content is extracted and copied to editor
Selecting non-user node (assistant/tool/summary/compaction/custom bookkeeping/etc.)
- New leaf becomes selected node id
- Editor is not prefilled
Selecting current leaf
- No-op; selector closes with “Already at this point”
Selection decision (simplified):
selected node
│
├─ is current leaf? ── yes ──> close selector (no-op)
│
├─ is user/custom_message? ── yes ──> leaf := parentId (or resetLeaf for root)
│ + prefill editor text
│
└─ otherwise ──> leaf := selected node id
+ no editor prefillSummary-on-switch flow
Summary prompt is controlled by branchSummary.enabled (default: false).
When enabled, after picking a node the UI asks:
No summarySummarizeSummarize with custom prompt
Flow details:
- Escape in summary prompt reopens tree selector
- Custom prompt cancellation returns to summary choice loop
- During summarization, UI shows loader and binds
EsctoabortBranchSummary() - If summarization aborts, tree selector reopens and no move is applied
navigateTree internals:
- Collects abandoned-branch entries from old leaf to common ancestor
- Emits
session_before_tree(extensions can cancel or inject summary) - Uses default summarizer only if requested and needed
- Applies move with:
branchWithSummary(...)when summary existsbranch(newLeafId)for non-root move without summaryresetLeaf()for root move without summary
- Replaces agent conversation with rebuilt session context
- Emits
session_tree
Note: if user requests summary but there is nothing to summarize, navigation proceeds without creating a summary entry.
Labels
Label edits in tree UI call appendLabelChange(targetId, label).
- non-empty label sets/updates resolved label
- empty label clears it
- labels are stored as append-only
labelentries - tree nodes display resolved label state, not raw label-entry history
/tree vs adjacent operations
| Operation | Scope | Result |
|---|---|---|
/tree | Current session file | Moves leaf to selected point (same file) |
/branch | Usually current session file -> new session file | By default branches from selected user message into a new session file; if doubleEscapeAction = "tree", /branch opens tree navigation UI instead |
/fork | Whole current session | Duplicates session into a new persisted session file |
/resume | Session list | Switches to another session file |
Key distinction: /tree is a navigation/repositioning tool inside one session file. /branch, /fork, and /resume all change session-file context.
Operator workflows
Re-run from an earlier user prompt without losing current branch
/tree- search/select earlier user message
- choose
No summary(or summarize if needed) - edit prefilled text in editor
- submit
Effect: new branch grows from selected point within same session file.
Leave current branch with context breadcrumb
- enable
branchSummary.enabled /treeand select target node- choose
Summarize(or custom prompt)
Effect: a branch_summary entry is appended at the target position before continuing.
Investigate hidden bookkeeping entries
/tree- press
Alt+A(all) - search for
model,thinking,custom, or labels
Effect: inspect full internal timeline, not just conversational nodes.
Bookmark pivot points for later jumps
/tree- move to entry
Shift+Land set label- later use
Alt+L(labeled-only) to jump quickly
Effect: fast navigation among durable branch landmarks.
Compaction and branch summaries are the two mechanisms that keep long sessions usable without losing prior work context.
- Compaction rewrites old history into a summary on the current branch.
- Branch summary captures abandoned branch context during
/treenavigation.
Both are persisted as session entries and converted back into user-context messages when rebuilding LLM input.
Key implementation files
packages/agent/src/compaction/compaction.ts(context-full summarization and handoff generation)packages/snapcompact/src/snapcompact.ts(snapcompact strategy: history archived as dense bitmap images)packages/agent/src/compaction/branch-summarization.tspackages/agent/src/compaction/pruning.tspackages/agent/src/compaction/utils.tspackages/agent/src/compaction/openai.tspackages/coding-agent/src/session/session-manager.tspackages/coding-agent/src/session/agent-session.tspackages/coding-agent/src/session/messages.tspackages/coding-agent/src/extensibility/hooks/types.tspackages/coding-agent/src/config/settings-schema.ts
Session entry model
Compaction and branch summaries are first-class session entries, not plain assistant/user messages.
CompactionEntrytype: "compaction"summary, optionalshortSummaryfirstKeptEntryId(compaction boundary)tokensBefore- optional
details,preserveData,fromExtension
BranchSummaryEntrytype: "branch_summary"fromId,summary- optional
details,fromExtension
When context is rebuilt (buildSessionContext):
- Latest compaction on the active path is converted to one
compactionSummarymessage. - Kept entries from
firstKeptEntryIdto the compaction point are re-included. - Later entries on the path are appended.
branch_summaryentries are converted tobranchSummarymessages.custom_messageentries are converted tocustommessages.
Those custom roles are then transformed into LLM-facing messages in convertToLlm(): compactionSummary and branchSummary become user messages rendered through the static templates
packages/agent/src/compaction/prompts/compaction-summary-context.mdpackages/agent/src/compaction/prompts/branch-summary-context.md
while custom messages pass through as developer messages with their raw content (no template).
Compaction pipeline
Triggers
Compaction/context maintenance can run in five ways:
- Manual context compaction:
/compact [instructions]callsAgentSession.compact(...). - Automatic overflow recovery: after a same-model assistant error that matches context overflow.
- Automatic incomplete-output recovery: after a same-model assistant message ends with
stopReason === "length"(OpenAI/Codexresponse.incomplete). - Automatic threshold maintenance: after a successful turn when context exceeds the resolved threshold.
- Idle maintenance:
runIdleCompaction()can invoke the same auto-maintenance path with reason"idle".
Compaction shape (visual)
Before compaction:
entry: 0 1 2 3 4 5 6 7 8 9
┌─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬──────┐
│ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool │
└─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴──────┘
└────────┬───────┘ └──────────────┬──────────────┘
messagesToSummarize kept messages
↑
firstKeptEntryId (entry 4)
After compaction (new entry appended):
entry: 0 1 2 3 4 5 6 7 8 9 10
┌─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬──────┬─────┐
│ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool │ cmp │
└─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴──────┴─────┘
└──────────┬──────┘ └──────────────────────┬───────────────────┘
not sent to LLM sent to LLM
↑
starts from firstKeptEntryId
What the LLM sees:
┌────────┬─────────┬─────┬─────┬──────┬──────┬─────┬──────┐
│ system │ summary │ usr │ ass │ tool │ tool │ ass │ tool │
└────────┴─────────┴─────┴─────┴──────┴──────┴─────┴──────┘
↑ ↑ └─────────────────┬────────────────┘
prompt from cmp messages from firstKeptEntryIdOverflow/incomplete recovery vs threshold/idle maintenance
The automatic paths are intentionally different:
-
Overflow recovery
- Trigger: current-model assistant error is detected as context overflow and the error is not older than the latest compaction.
- The failing assistant error message is removed from active agent state before retry.
- Context promotion is tried first; if a configured larger model is available, the agent switches model and retries without compacting.
- If promotion is unavailable and compaction is enabled, context-full compaction runs with
reason: "overflow"andwillRetry: true; handoff strategy is not used for overflow because the handoff request would reuse the overflowing input. - On success,
agent.continue()is scheduled to retry the turn.
-
Incomplete-output recovery
- Trigger: same-model assistant message ends with
stopReason === "length"and the message is not older than the latest compaction. - The incomplete assistant message is removed from active agent state before recovery.
- Context promotion is tried first.
- If promotion is unavailable and compaction is enabled, auto maintenance runs with
reason: "incomplete"andwillRetry: true. - Unlike overflow,
compaction.strategy: "handoff"is allowed for incomplete-output recovery because the input context is still usable. - On context-full success,
agent.continue()is scheduled to retry the turn.
- Trigger: same-model assistant message ends with
-
Threshold maintenance
- Trigger: successful, non-error assistant message whose adjusted context tokens exceed
resolveThresholdTokens(...). - Tool-output pruning can reduce the measured token count before threshold comparison.
- Context promotion is tried before compaction.
- If promotion is unavailable, auto maintenance runs with
reason: "threshold"andwillRetry: false. - With
compaction.strategy: "handoff", threshold maintenance normally schedules a post-prompt auto-handoff task instead of writing a compaction entry; pre-prompt checks run it inline to avoid racing the next turn. If handoff returns no document without aborting, it falls back to context-full compaction. - On success, if
compaction.autoContinue !== false, schedules an agent-authored developer auto-continue prompt fromprompts/system/auto-continue.md.
- Trigger: successful, non-error assistant message whose adjusted context tokens exceed
-
Idle maintenance
- Trigger:
runIdleCompaction()when not streaming or already compacting. - Uses
reason: "idle"and does not auto-continue afterward.
- Trigger:
Snapcompact strategy
compaction.strategy: "snapcompact" replaces the LLM summarization call with a local, deterministic archival pass (compact from @oh-my-pi/snapcompact):
- The discarded history is serialized, whitespace-collapsed, and printed onto model-aware PNG frames (frame width fixed per shape; frame height hugs the rows actually printed) using bundled public-domain pixel fonts. The shape — and frame size — resolve from the model id when the model line was measured: Claude reads X.org
8x13glyphs on an 11px advance (extra letter-spacing, black ink —11on16-bw; high-res lines — Opus 4.7+, Fable, Mythos — get 1932px frames under Anthropic’s 4,784 visual-token cap, older lines stay at 1568px), Gemini reads8x13glyphs on a 22px pitch (extra leading, black ink —8on22-bwat 2048px, since Gemini 3.x bills a fixed 1,120-token budget per image at any pixel size), GPT/Codex read the same8on22-bwshape at 1568px (patch billing is area-proportional, so larger frames cannot improve chars per token), and Kimi/GLM read8x13glyphs on a 16px pitch (8on16-bwat 1568px — kimi’s processor downscales past 1792px). A Claude routed through Vertex or OpenRouter keeps its Claude shape. Unmeasured models fall back to their wire API family (Anthropic-family/unknown →11on16-bw, Google →8on22-bw, OpenAI-compatible →8on22-bw); billing (per-family patch/budget formulas, OpenAI’sdetail: "original"hint) always follows the API carrying the request, computed for the resolved frame size. Thesnapcompact.shapesetting (defaultauto) forces one of the research-eval variants instead: square grids (8x8r/8x8u/6x6u/5x8× sentence-hue/black ink) or the per-model eval winners (6x12-dim,8x13-bw,8on16-bw,8on22-bw,11on16-bw, and the two-column word-wrappeddoc-8on16-bw/-sent/-sent-dim, wheredimprints stopwords in gray). A forced variant keeps its geometry but is re-priced for the target provider’s image billing. The same setting governs inline system-prompt/tool-result imaging (snapcompact.systemPrompt,snapcompact.toolResults). - Serialization keeps the archive conversation-dense: tool results are truncated head+tail (default 2,000 chars at a 0.6 head ratio), tool-call argument values are capped per value (500) and per call (2,000), and tool output is printed in dim gray ink so conversation reads louder than tool noise. All budgets and the dimming are configurable via
SerializeOptions(toolResultMaxChars,toolArgMaxChars,toolCallMaxChars,truncateHeadRatio,dimToolResults). - The snapcompact archive persists under
CompactionEntry.preserveData.snapcompactas bounded source text plus rendered frames. On each context rebuild it is reconstructed into ordered compaction blocks: plain text at the oldest edge, an imaged middle, then plain text at the newest edge. The entry’ssummaryis just the short resume lead-in plus the usual file-operation list. - Later compactions re-render from that bounded source text (
Archive.text), not by carrying old PNGs forward blindly.maxFramesnow defaults toMAX_FRAMES_DEFAULT(80) and acts only as an upper limit; when the imaged middle is large it foveates internally (HQ/LQ/HQ), while both chronological edges stay verbatim text. - No model, API key, or network is involved, so snapcompact is also safe for overflow recovery. It requires a vision-capable current model (
model.inputincludes"image"); otherwise the run falls back to context-full and emits a warning notice (auto and manual paths). Manual/compacthonors the strategy unless custom instructions are given (those imply a directed LLM summary). - Rationale: the shape table comes from the snapcompact 200k-token evals in
packages/snapcompact, where bitmap frames preserved QA recall at lower billed-token cost than raw text for vision-capable models.
Display transcript
Compaction no longer visually restarts the conversation. The TUI renders the display transcript (buildSessionContext({ transcript: true }) / AgentSession.buildTranscriptSessionContext()): every path entry in chronological order, with each compaction shown inline as a slim divider — ── 📷 compacted · ctrl+o ── — at the point it fired. Expanding (ctrl+o) reveals the summary. Only the LLM context resets at the compaction boundary; the scrollback above the divider stays intact, including across session resume.
Pre-compaction pruning
Before compaction checks, tool-result pruning may run (pruneToolOutputs).
Default prune policy:
- Protect newest
40_000tool-output tokens. - Require at least
20_000total estimated savings. - Never blank a result below
50tokens (MIN_PRUNE_TOKENS): the[Output truncated - N tokens]placeholder costs ~8 tokens, so pruning a sub-floor result would grow the context and churn the prompt cache for nothing. (Superseded and useless results keep their own rules — the useless collector already drops no-savings candidates; superseded reads prune for correctness regardless of size.) - Never prune
skilltool results,readresults ofskill://paths, or reads of the active plan reference file (added viaAgentSession’s plan protection).
Pruned tool results are replaced with:
[Output truncated - N tokens]
If pruning changes entries, session storage is rewritten and agent message state is refreshed before compaction decisions.
Useless-result elision
Tools can flag a finished result as contextually useless — a search with zero matches, a job poll that timed out with everything still running, an empty irc inbox drain. The flag originates on the tool result (AgentToolResult.useless, set via ToolResultBuilder.useless() or directly on the returned object), is copied by the agent loop onto the persisted ToolResultMessage (never together with isError — errors always win), and is consumed in three places:
- Per-turn stale-result pass (
pruneSupersededToolResults, gated bycompaction.dropUseless, default on): flagged results are blanked to the exact placeholder[Uneventful result elided](USELESS_NOTICE) with the same cache-aware timing as superseded reads — only when the suffix after the candidate is small (≤ ~8k tokens) or the session has idled past the provider prompt-cache lifetime. Results smaller than the notice itself are never blanked (no savings), and protected tools are exempt. - Threshold prune (
pruneToolOutputs): flagged results bypass the protect-recent window, same as superseded reads, and receiveUSELESS_NOTICEinstead of the token-count placeholder. - Summary serialization:
serializeConversation(agent and snapcompact) drops the whole tool call/result pair from summarizer/archive input — the source region is discarded after summarization anyway, so the exclusion costs no cache.
The flag never reaches provider wire formats, and flagged pairs are never removed from history (only blanked in place), so tool-call/result pairing and provider-native history replay stay intact.
Boundary and cut-point logic
prepareCompaction() only considers entries since the last compaction entry (if any).
- Find previous compaction index.
- Compute
boundaryStart = prevCompactionIndex + 1. - Adapt
keepRecentTokensusing measured usage ratio when available. - Run
findCutPoint()over the boundary window.
Valid cut points include:
- message entries with roles:
user,assistant,bashExecution,hookMessage,branchSummary,compactionSummary custom_messageentriesbranch_summaryentries
Hard rule: never cut at toolResult.
If there are non-message metadata entries immediately before the cut point (model_change, thinking_level_change, labels, etc.), they are pulled into the kept region by moving cut index backward until a message or compaction boundary is hit.
Split-turn handling
If cut point is not at a user-turn start, compaction treats it as a split turn.
Turn start detection treats these as user-turn boundaries:
message.role === "user"message.role === "bashExecution"custom_messageentrybranch_summaryentry
Split-turn compaction generates two summaries:
- History summary (
messagesToSummarize) - Turn-prefix summary (
turnPrefixMessages)
Final stored summary is merged as:
<history summary>
---
**Turn Context (split turn):**
<turn prefix summary>Summary generation
compact(...) builds summaries from serialized conversation text:
- Convert messages via
convertToLlm(). - Serialize with
serializeConversation(). - Wrap in
<conversation>...</conversation>. - Optionally include
<previous-summary>...</previous-summary>. - Optionally inject extension hook context and active memory-backend compaction context as
<additional-context>entries. - Execute summarization prompt with
SUMMARIZATION_SYSTEM_PROMPT.
Prompt selection:
- first compaction:
compaction-summary.md - iterative compaction with prior summary:
compaction-update-summary.md - split-turn second pass:
compaction-turn-prefix.md - short UI summary:
compaction-short-summary.md - handoff document:
handoff-document.md(used bygenerateHandoff(...), not serialized compaction)
Remote summarization modes:
- If
compaction.remoteEndpointis set and remote compaction is enabled, local summary generation POSTs:{ systemPrompt, prompt }
- Expects JSON containing at least
{ summary }. - For OpenAI/OpenAI Codex models, compaction first tries the provider-native
/responses/compactendpoint when remote compaction is enabled. It preserves provider replacement history inpreserveData.openaiRemoteCompactionand falls back to local summarization if that native request fails.
Handoff generation
packages/agent/src/compaction/compaction.ts also exports generateHandoff(...). Handoff generation uses the same completeSimple(...) oneshot style as summarization, but it preserves the live agent cache prefix by sending the active system prompt, tool array, and real LLM message history, then appending one agent-attributed user message containing the handoff prompt. It forces toolChoice: "none" and returns joined text blocks directly.
Handoff does not write a CompactionEntry. AgentSession.handoff() owns the session transition: it starts a new session, injects the generated document as a visible custom_message with customType: "handoff", and rebuilds agent messages from that new session.
File-operation context in summaries
Compaction tracks cumulative file activity using assistant tool calls:
read(path)→ read setwrite(path)→ modified setedit(path)→ modified set
Cumulative behavior:
- Includes prior compaction details only when prior entry is pi-generated (
fromExtension !== true). - In split turns, includes turn-prefix file ops too.
details.readFilesexcludes files also modified;details.modifiedFilescarries the rest (persisted shape is unchanged).
The file list is a grouped, prefix-folded directory tree (find-tool shape) with a per-file access marker — (Read) for read-only files, (Write) for modified files never read, (RW) for modified files also present in the cumulative read set. Capped at 20 files with an […N files elided…] line. LLM-summary strategies append it as a <files> tag (via upsertFileOperations); snapcompact renders it inside its summary template as a FILES section instead.
<files>
# packages/agent/src/compaction/
compaction.ts (Read)
utils.ts (RW)
## prompts/
file-operations.md (Write)
</files>Legacy <read-files>/<modified-files> tags from summaries written by earlier versions are stripped (alongside <files>) before re-appending, so old summaries self-heal on the next compaction.
Persist and reload
After summary generation (or hook-provided summary), agent session:
- Appends
CompactionEntrywithappendCompaction(...)for context-full maintenance; handoff strategy creates a new session and injects a handoffcustom_messageinstead. - Rebuilds display context from the active leaf via
buildDisplaySessionContext(). - Replaces live agent messages with rebuilt context.
- Synchronizes active todo phases from the rebuilt branch and closes provider sessions whose history was rewritten.
- Emits
session_compacthook event.
Branch summarization pipeline
Branch summarization is tied to tree navigation, not token overflow.
Trigger
During navigateTree(...):
- Compute abandoned entries from old leaf to common ancestor using
collectEntriesForBranchSummary(...). - If caller requested summary (
options.summarize), generate summary before switching leaf. - If summary exists, attach it at the navigation target using
branchWithSummary(...).
Operationally this is commonly driven by /tree flow when branchSummary.enabled is enabled.
Branch switch shape (visual)
Tree before navigation:
┌─ B ─ C ─ D (old leaf, being abandoned)
A ───┤
└─ E ─ F (target)
Common ancestor: A
Entries to summarize: B, C, D
After navigation with summary:
┌─ B ─ C ─ D ─ [summary of B,C,D]
A ───┤
└─ E ─ F (new leaf)Preparation and token budget
generateBranchSummary(...) computes budget as:
tokenBudget = model.contextWindow - branchSummary.reserveTokens
prepareBranchEntries(...) then:
- First pass: collect cumulative file ops from all summarized entries, including prior pi-generated
branch_summarydetails. - Second pass: walk newest → oldest, adding messages until token budget is reached.
- Prefer preserving recent context.
- May still include large summary entries near budget edge for continuity.
Compaction entries are included as messages (compactionSummary) during branch summarization input.
Summary generation and persistence
Branch summarization:
- Converts and serializes selected messages.
- Wraps in
<conversation>. - Uses custom instructions if supplied, otherwise
branch-summary.md. - Calls summarization model with
SUMMARIZATION_SYSTEM_PROMPT. - Prepends
branch-summary-preamble.md. - Appends file-operation tags.
Result is stored as BranchSummaryEntry with optional details (readFiles, modifiedFiles).
Extension and hook touchpoints
session_before_compact
Pre-compaction hook.
Can:
- cancel compaction (
{ cancel: true }) - provide full custom compaction payload (
{ compaction: CompactionResult })
session.compacting
Prompt/context customization hook for default compaction.
Can return:
prompt(override base summary prompt)context(extra context lines injected into<additional-context>)preserveData(stored on compaction entry)
session_compact
Post-compaction notification with saved compactionEntry and fromExtension flag.
session_before_tree
Runs on tree navigation before default branch summary generation.
Can:
- cancel navigation
- provide custom
{ summary: { summary, details } }used when user requested summarization
session_tree
Post-navigation event exposing new/old leaf and optional summary entry.
Runtime behavior and failure semantics
- Manual compaction aborts current agent operation first.
abortCompaction()cancels manual compaction, auto-compaction, and handoff generation controllers.- Auto compaction emits start/end session events for UI/state updates.
- Auto compaction can try multiple model candidates and retry transient failures; long retry delays prefer the next candidate when one is available.
- Overflow errors are excluded from generic retry path because they are handled by context promotion/compaction.
- If auto-compaction fails:
- overflow path emits
Context overflow recovery failed: ... - incomplete-output path emits
Incomplete response recovery failed: ... - threshold/idle paths emit
Auto-compaction failed: ...
- overflow path emits
- Branch summarization can be cancelled via abort signal (e.g., Escape), returning canceled/aborted navigation result.
Settings and defaults
From settings-schema.ts:
compaction.enabled=truecompaction.strategy="snapcompact"("context-full","handoff","shake", and"off"are also supported)compaction.reserveTokens=16384compaction.keepRecentTokens=20000compaction.autoContinue=truecompaction.remoteEnabled=truecompaction.remoteEndpoint=undefinedcompaction.thresholdPercent=-1andcompaction.thresholdTokens=-1; when no positive override is set, the threshold iscontextWindow - max(15% of contextWindow, reserveTokens)compaction.idleEnabled=falsecompaction.idleThresholdTokens=200000compaction.idleTimeoutSeconds=300branchSummary.enabled=falsebranchSummary.reserveTokens=16384
These values are consumed at runtime by AgentSession and compaction/branch summarization modules.
Workflows & Modes
/collab shares your running session with other omp instances in real time. Guests render the same session natively in their own TUI — streaming assistant text, tool-call cards, footer state (cwd, model, context %, cost), ctrl+o expansion, /dump — no terminal mirroring. Guests can prompt and interrupt the agent; the host machine runs the agent and all tools.
Quick start
Host:
/collab
prints
Collab session started!
• Join from another terminal: omp join "mgAYTZwEnpRQtca0CTgn-Q.gdJUbTovD94ofDaa8YvhY0-ty16w4fn8PgB6PLnoA30"
• or any web browser: my.omp.sh/#mgAYTZwEnpRQtca0CTgn-Q.gdJUbTovD94ofDaa8YvhY0-ty16w4fn8PgB6PLnoA30
The browser line is click-to-join (an OSC 8 hyperlink to the full https:// deep link): the relay serves the web guest client at /, and the room id + key ride in the URL fragment. From another omp (any directory, any machine), either form works:
Running /collab or /collab view starts or displays the active hosting session, rendering both the terminal/browser join links and their corresponding QR codes.
/join my.omp.sh/#mgAYTZwEnpRQtca0CTgn-Q.gdJU…
The guest’s previous session is restored on /leave (or when the host stops).
Commands
| Command | Effect |
|---|---|
/collab | Start sharing full-control (or re-print the link/QR when already hosting) |
/collab <relay> | Start sharing through a specific relay (relay.example.com, ws://localhost:7475) |
/collab view | Start sharing read-only (or re-print the link/QR when already hosting) |
/collab status | Show link + participants |
/collab stop | Stop sharing |
/join <link> | Join a shared session as a guest |
/leave | Leave (guest) or stop sharing (host) |
Link format
Accepted by /join <link> and omp join "<link>":
<roomId>.<key> → default relay (wss://my.omp.sh)
<roomId>#<key> → legacy bare form
host[:port]/r/<roomId>.<key> → custom relay, wss:// inferred
host[:port]/r/<roomId>#<key> → legacy direct relay form
https://host[:port]/r/<roomId>.<key> → direct relay URL, normalized to wss://
wss://host[:port]/r/<roomId>.<key> → direct websocket relay URL
ws://localhost:7475/r/<roomId>.<key> → direct plain ws, localhost only
https://host[:port]/#<link> → browser deep link when web UI and relay share a host
https://web-host[:port][/<path>]/#<relay-link> → browser UI wrapper with relay link in the fragment
https://web.example/collab/#relay.example.com/r/<roomId>.<key> → web UI and relay on different hosts
<link> / <relay-link> are parsed recursively as any accepted link above. For http(s) browser wrappers with a parseable fragment, the fragment wins before the HTTP host/path are treated as a relay. This lets https://web.example/collab/#relay.example.com/r/<roomId>.<key> open the web UI at web.example while joining wss://relay.example.com/r/<roomId>. If the fragment is not a complete collab link, parsing falls back to the legacy direct relay form, so https://relay.example.com/r/<roomId>#<key> still means relay relay.example.com.
The trailing .<key> or #<key> part is the room secret, base64url-encoded, in one of two strengths:
- Full link — 48 bytes: the 32-byte AES-256-GCM room key followed by a 16-byte write token. Grants prompting, interrupting, and subagent control.
- View-only link — the bare 32-byte key, no write token. Grants live read access only. Pre-token links parse as view-only.
The room secret is dot-joined in newly generated links because RFC 3986 forbids a raw # inside a URL fragment; parsers still accept legacy # forms and %23-mangled legacy deep links.
End-to-end encryption
Every session payload (entries, events, state, prompts) is sealed with AES-256-GCM before it touches the socket. The relay sees only:
- room ids and connection counts,
- opaque ciphertext frames and their sizes,
- a 4-byte routing prefix (which guest a frame targets).
Possession of the link is the trust boundary: a full link reads and steers the session, a view-only link reads it. Share both like secrets.
Guest permission model
Two trust levels, enforced by the link itself — the host verifies the 16-byte write token at join and rejects writes from peers without it (they appear as read-only in the participants list, and the join notice says so).
Guests with a full link can:
- read the entire session (including the back-transcript at join time),
- prompt the agent (rendered with their name badge on every participant’s transcript; the LLM sees the prompt text verbatim — names are display-only),
- interrupt the agent (Esc),
- use the Agent Hub against the host’s subagents: live table and progress, chat (steers the host’s subagent), kill, revive, and transcript viewing (fetched from the host on demand).
Guests with a view-only link can read everything live — back-transcript, streaming text, tool cards, subagent transcripts — but the host rejects prompting, interrupting, and agent control from them.
Everything that mutates the host session or machine is host-only: /model, /compact, /resume, /branch, bash (!), python ($), skills, etc. Guests keep a small local allowlist (/dump, /export, /copy, /help, /hotkeys, /theme, /settings, /leave, /collab, /exit, /quit).
Known v1 limit for guests: a turn already streaming when you join becomes visible from its next message boundary.
Web client
packages/collab-web is a standalone browser client for the same links — no omp install needed on the guest side. The relay serves it at /, which is what makes the /collab deep link click-to-join: https://<relay>/#<link> loads the client and auto-connects from the fragment. It renders the live transcript (streaming text, thinking, tool cards), a subagent panel with on-demand transcripts, and a composer with the same guest powers (prompt, interrupt, hub actions). Run bun run dev in the package for a local instance, bun run mock-host for an offline scripted host to develop against, and bun run build to emit a static dist/ deployable anywhere (HTTPS required for WebCrypto). The client never talks to anything but the relay, and the key stays in the URL fragment.
Set collab.webUrl when the browser UI is hosted separately from the websocket relay. When empty, /collab derives http(s)://host[:port] from collab.relayUrl; explicit web UI URLs must use https:// except for http://localhost development origins. The generated browser URL still carries the relay-specific collab link in the fragment.
Settings
| Setting | Default | Meaning |
|---|---|---|
collab.relayUrl | wss://my.omp.sh | Relay used by /collab when no relay is passed inline |
collab.webUrl | empty | Browser UI URL for /collab links; empty derives from relay; explicit http:// is allowed only for localhost |
collab.displayName | OS username | Name shown to other participants |
share.serverUrl | https://my.omp.sh/s | Share viewer/upload base used by /share (links are <base>/<id>#<key>) |
share.redactSecrets | true | Run the secret obfuscator over /share snapshots before upload |
Self-hosting the relay
The relay is a small content-blind Go service. It keeps no state beyond live connections and exposes:
GET /— the static collab-web guest client (target of the/collabdeep link),GET /r/<roomId>?role=host|guest— WebSocket upgrade,POST /s/GET /s/<id>/GET /s/<id>/raw—/shareblob upload, viewer page, and blob fetch,GET /healthz— liveness.
Architecture notes
Hub topology — the host is authoritative, guests never peer:
entryframes — durable session entries, broadcast pre-blob-externalization so images stay inline (guests cannot resolve host blob refs). Guests append them verbatim (ids preserved) to a replica session file under~/.omp/collab/<roomId>.jsonland into the agent’s message array, which is why/dumpand context estimates work.eventframes — live agent events, fed straight into the guest’s normal event controller; rendering is events-only to prevent double-render.stateframes — debounced footer snapshots: streaming flag, the host’s full model object and thinking level (applied to the guest’s replica agent state, so model display and context-window math are native), host context numbers, and participants.busframes — mirrored task-subagent lifecycle/progress EventBus traffic, republished on the guest’s local bus so the subagent HUD and status-line count work natively.agentsframes — agent-registry snapshots feeding a guest-local registry, so the Agent Hub table renders host subagents.
Guest→host: hello, prompt, abort, agent-cmd (hub chat/kill/revive), and fetch-transcript (incremental subagent-transcript reads answered by targeted transcript frames). The replica loads through the regular /resume machinery, so theming, ctrl+o, and transcript behavior are native by construction; the guest process never chdirs to host paths.
This document describes how the coding-agent implements /handoff: trigger path, oneshot generation, session switch, context reinjection, persistence, and UI behavior.
Scope
Covers:
- Interactive
/handoffcommand dispatch AgentSession.handoff()lifecycle and state transitionsgenerateHandoff(...)request shape- How old/new sessions persist handoff data differently
- UI behavior for success, cancel, and failure
Does not cover:
- Generic tree navigation/branch internals
- Non-handoff session commands (
/new,/fork,/resume)
Implementation files
../src/modes/controllers/input-controller.ts../src/modes/controllers/command-controller.ts../src/session/agent-session.tspackages/agent/src/compaction/compaction.ts../src/session/session-manager.ts../src/slash-commands/builtin-registry.ts
Trigger path
/handoffis declared in builtin slash command metadata (slash-commands/builtin-registry.ts) with optional inline hint:[focus instructions].- In interactive input handling (
InputController), submit text matching/handoffor/handoff ...is intercepted before normal prompt submission. - The editor is cleared and
handleHandoffCommand(customInstructions?)is called. CommandController.handleHandoffCommandperforms a preflight guard using current entries:- Counts
type === "message"entries. - If
< 2, it warns:Nothing to hand off (no messages yet)and returns.
- Counts
The same minimum-content guard exists again inside AgentSession.handoff() and throws if violated. This duplicates safety at both UI and session layers.
End-to-end lifecycle
1) Start handoff generation
AgentSession.handoff(customInstructions?):
- Reads current branch entries (
sessionManager.getBranch()). - Validates minimum message count (
>= 2). - Refuses if a response is still streaming (the TUI
/handoffand RPChandoffcommand guard onisStreamingbefore calling this; the auto-handoff path runs only after the turn settles). Resetting the agent mid-stream would let the live turn keep emitting into the torn-down session. - Creates
#handoffAbortControllerand links any caller-provided abort signal to it. - Resolves the current model API key through
ModelRegistry. - Builds the handoff request through the same pipeline a live turn uses — the cache-preserving side-request path shared with
runEphemeralTurn(/btw,/omfg):- Renders the handoff prompt (
renderHandoffPrompt(...)with optionaladditionalFocus, after obfuscating any focus instructions) and appends it as a trailing agent-attributedusermessage to a snapshot ofagent.state.messages. - Converts the snapshot with
convertMessagesToLlm(...)(applies the sessiontransformContext— extension context + steering wrap — thenconvertToLlm+ obfuscation), exactly as the loop does. - Builds the provider
Contextwithagent.buildSideRequestContext(llmMessages, #baseSystemPrompt)— normalized tools andtransformProviderContext(obfuscation + inline snapcompact) matching the loop. The base system prompt is pinned here, not a per-turnbefore_agent_starthook override, so the new session does not inherit prompt-specific hook state. - Builds stream options with
prepareSimpleStreamOptions(...): a stablepromptCacheKey(= the live session id) so the oneshot reads the cache the turn populated, a unique sidesessionId(<sid>:side:<snowflake>) so OpenAI/Codex append-only state never mixes with the live turn,serviceTier/payload hooks mirrored from the session, andpreferWebsockets: false.
- Renders the handoff prompt (
- Calls
generateHandoffFromContext(context, model, { streamOptions, telemetry, thinkingLevel }).
2) Generate and capture output
generateHandoffFromContext(...) lives in packages/agent/src/compaction/compaction.ts next to summarization. It is the handoff request contract: it issues one instrumentedCompleteSimple(...) (the OTEL-instrumented completeSimple oneshot wrapper) against the caller-built Context, forcing toolChoice: "none" and reasoning: resolveCompactionEffort(model, thinkingLevel) over whatever the caller’s streamOptions carried:
await instrumentedCompleteSimple(
model,
context, // system prompt + normalized tools + transformed history + trailing handoff prompt
{
...streamOptions, // apiKey, signal, sessionId, promptCacheKey, serviceTier, hooks
reasoning: resolveCompactionEffort(model, options.thinkingLevel),
toolChoice: "none",
},
{ telemetry, oneshotKind: "handoff" },
);(generateHandoff(messages, …) remains exported for downstream callers and now builds a basic Context from systemPrompt/tools/convertToLlm and delegates to generateHandoffFromContext. AgentSession no longer uses it because it cannot apply the host’s transform pipeline or cache routing.)
Important generation properties:
- The request shares the live provider cache prefix because the
Contextis built by the identical transform + normalization pipeline the loop uses, and routed with the samepromptCacheKeythe turn used. - The handoff instruction is a trailing
usermessage, not a developer message, so the cached prefix remains aligned with the prior turn (the trailing message is the only divergence point). toolChoice: "none"prevents intentional tool dispatch.- The returned assistant content is filtered to text blocks and joined with
\n; stray tool-call blocks are ignored if a provider does not honortoolChoice: "none". stopReason === "error"throws a generation error.
No agent-loop events are used for capture. The handoff path no longer waits for agent_end and no longer scans the latest assistant message.
3) Cancellation checks
Cancellation throws Error("Handoff cancelled"); a completed generation with no text returns undefined.
- caller signal aborts
#handoffAbortController completeSimple(...)receives the abort signal- aborted handoff signal or provider
AbortErroris normalized toError("Handoff cancelled") - empty generated text returns
undefined
AgentSession.handoff() always clears #handoffAbortController in finally.
4) New session creation
If text was generated and not aborted:
- Flush current session writer (
sessionManager.flush()). - Cancel session-owned async jobs.
- Start a brand-new session with
parentSessionpointing at the previous session file when one exists. - Reset in-memory agent state (
agent.reset()). - Rebind
agent.sessionIdto the new session id. - Rekey/reset Hindsight and Mnemopi memory session tracking for the new session.
- Clear the queued next-turn context array (
#pendingNextTurnMessages) and the scheduled hidden next-turn generation (#scheduledHiddenNextTurnGeneration). The agent’s steering and follow-up queues are already cleared byagent.reset()in step 4. - Reset todo reminder counter.
5) Handoff-context injection
The generated handoff document is wrapped by coding-agent session glue and appended to the new session as a custom_message entry:
<handoff-context>
...handoff text...
</handoff-context>
The above is a handoff document from a previous session. Use this context to continue the work seamlessly.Insertion call:
this.sessionManager.appendCustomMessageEntry(
"handoff",
handoffContent,
true,
undefined,
"agent",
);Semantics:
customType:"handoff"display:true(visible in TUI rebuild)- attribution:
"agent" - Entry type:
custom_message(participates in LLM context)
6) Rebuild active agent context
After injection:
buildDisplaySessionContext()resolves message list for current leaf.agent.replaceMessages(sessionContext.messages)makes the injected handoff message active context.- Todo phases are synchronized from the new branch.
- Method returns
{ document: handoffText, savedPath? }.
At this point, the active LLM context in the new session contains the injected handoff message, not the old transcript.
Persistence model: old session vs new session
Old session
Handoff generation is a oneshot request, not a visible agent turn. The generated handoff text is not appended to the old session as an assistant message.
Result: the original session keeps its prior transcript unchanged except for data already persisted before handoff began.
New session
After session reset, handoff is persisted as custom_message with customType: "handoff".
buildSessionContext() converts this entry into a runtime custom/user-context message via createCustomMessage(...), so it is included in future prompts from the new session.
Auto-triggered handoffs can additionally write a timestamped handoff-*.md artifact under the session artifacts directory when compaction.handoffSaveToDisk is enabled. Manual /handoff does not write that artifact.
Controller/UI behavior
CommandController.handleHandoffCommand behavior:
- Refuses with a warning when
session.isStreaming(matches/forkand/move) — the user must finish or abort the response before handing off. - Shows a status loader:
Generating handoff… (esc to cancel). - Calls
await session.handoff(customInstructions). - If result is
undefined:showError("Handoff cancelled"). - On success:
rebuildChatFromMessages()(loads new session context, including injected handoff)- invalidates status line and editor top border
- reloads todos
- appends success chat line:
New session started with handoff context
- On exception:
- if message is
"Handoff cancelled"or error name isAbortError:showError("Handoff cancelled") - otherwise:
showError("Handoff failed: <message>")
- if message is
- Stops the loader, clears the status container, and requests render at end.
Manual /handoff no longer streams the generated document into chat. A cancellable loader remains visible while the oneshot request runs, and the chat is rebuilt after generation completes.
Cancellation semantics
Session-level cancellation primitive
AgentSession exposes:
abortHandoff()→ aborts#handoffAbortControllerisGeneratingHandoff→ true while controller exists
When this abort path is used, the abort signal is passed to completeSimple(...); handoff() normalizes the cancellation to Error("Handoff cancelled"), and command controller maps it to cancellation UI.
Interactive /handoff path
InputController’s global editor.onEscape handler dispatches on live session state instead of swapping handlers: while isGeneratingHandoff is true, pressing Escape calls session.abortHandoff(), which aborts the completeSimple(...) request through #handoffAbortController.
Aborted vs failed handoff
Current UI classification:
- Aborted/cancelled
abortHandoff()path triggers"Handoff cancelled", or- thrown
AbortError - UI shows
Handoff cancelled
- Failed
- any other thrown error from
handoff()/generateHandoff()/ provider request path - UI shows
Handoff failed: ...
- any other thrown error from
Additional nuance: if generation completes but no text is returned, handoff() returns undefined and controller currently reports cancelled, not failed.
Short-session and minimum-content guardrails
Two guards prevent low-signal handoffs:
- UI layer (
handleHandoffCommand): warns and returns early for< 2message entries - Session layer (
handoff()): throws the same condition as an error
This avoids creating a new session with empty/near-empty handoff context.
State transition summary
High-level state flow:
- Interactive slash command intercepted.
- Preflight message-count guard.
#handoffAbortControllercreated (isGeneratingHandoff = true).generateHandoff(...)issues oneinstrumentedCompleteSimple(...)request with live system prompt, tools, message history, current thinking level, and trailing handoff prompt.- Assistant response text blocks are joined; tool-call blocks are discarded.
- If missing text → return
undefined; if aborted → cancellation error path. - If present:
- flush old session
- cancel async jobs
- create new empty session with previous session as parent
- reset runtime queues/counters
- append
custom_message(handoff) - optionally save an auto-triggered handoff document under the session artifacts directory when
compaction.handoffSaveToDiskis enabled
- Controller rebuilds chat UI and announces success.
#handoffAbortControllercleared (isGeneratingHandoff = false).
Known assumptions and limitations
- No structural validation checks that generated markdown follows the requested section format.
- Missing generated text is reported as cancellation in controller UX.
- Manual handoff has no streaming visibility; a cancellable loader is shown until the UI updates after generation completes.
- Auto-triggered handoffs can write a timestamped
handoff-*.mdartifact whencompaction.handoffSaveToDiskis enabled; write failure is logged and does not fail the handoff.
Tool approval has two independent inputs:
- Tool declaration — every tool may declare an
approvaltier:read: reads data or updates UI-only session metadata.write: mutates workspace/session state but does not execute arbitrary code.exec: executes code, shells out, drives a browser, spawns agents, or performs similarly broad actions.
- User policy —
tools.approval.<toolName>: allow | deny | promptoverrides the mode for that tool unless a non-yolo safety override forces a prompt.
Tools without an approval declaration are treated as exec. This is the safe default for unknown custom tools. MCP server tools declare write.
Modes
Configure with tools.approvalMode:
| Mode | Auto-approves | Prompts for |
|---|---|---|
always-ask | read | write, exec |
write | read, write | exec |
yolo (default) | read, write, exec | none |
--auto-approve and --yolo force tools.approvalMode: yolo for the session.
User overrides
tools.approval is honored in every mode:
tools:
approvalMode: write
approval:
bash: prompt
read: allow
mcp__filesystem__delete: denyResolution per tool call:
- Compute the tool’s approval decision from
tool.approval(args); omitted meansexec. - Normalize
tools.approval.<tool>if present; invalid values are ignored. - In
yolomode, the user policy is used when present; otherwise the call is allowed. Safetyoverridereasons do not force a prompt inyolo. - In non-yolo modes, if the tool sets
override: true,denyis blocked and all other cases prompt, even if user policy saysallow. - Otherwise, a valid user policy wins.
- Otherwise, the active mode auto-approves or prompts by tier.
Safety overrides
A tool can force a prompt with object-form approval:
approval: { tier: "exec", override: true, reason: "Critical pattern detected" }bash uses this for critical destructive patterns such as rm -rf /, fork bombs, remote-fetch-then-execute, writes to /etc/passwd, and host shutdown commands. These surface as reason in the approval prompt, but in yolo mode they are auto-approved unless a user policy for the tool is set to prompt or deny.
Per-tool prompt details
Tools can add approval-prompt body lines with formatApprovalDetails(args). The standard prompt includes:
Allow tool: <name>Origin: MCP server toolfor unannotatedmcp__...toolsReason: <reason>when the tool decision supplies one- tool-specific details such as command, path, code, browser action, or subagent assignment
Defining approval on tools
Built-in and custom tools share the same shape:
export type ToolTier = "read" | "write" | "exec";
export type ToolApprovalDecision = ToolTier | { tier: ToolTier; reason?: string; override?: boolean };
export type ToolApproval = ToolApprovalDecision | ((args: unknown) => ToolApprovalDecision);
approval?: ToolApproval;
formatApprovalDetails?: (args: unknown) => string | string[] | undefined;Examples:
approval: "read";
approval: (args) => (LSP_READONLY_ACTIONS.has(args.action) ? "read" : "write");
approval: (args) =>
isCritical(args.command)
? { tier: "exec", override: true, reason: "Critical pattern detected" }
: "exec";ACP sessions
ACP (omp acp) uses the same settings resolver as normal OMP launches. Global ~/.omp/agent/config.yml applies, project config for the ACP session cwd applies, and any --config <file> overlays passed to the ACP server process apply to sessions created by that process.
To auto-approve ACP tool calls, set the mode in global or project config:
tools:
approvalMode: yoloOr launch the ACP server with a runtime override or a one-process config overlay:
omp acp --yolo
omp acp --auto-approve
omp acp --approval-mode yolo
omp acp --config ./acp-yolo.yml # file contains tools.approvalMode: yoloPrecedence is the normal settings precedence: runtime flags (--approval-mode, --auto-approve, --yolo) override --config overlays, which override project config, which overrides global config. ACP does not currently define a session/new, session/load, or session/resume approval-policy field, so ACP clients that need per-session yolo should launch a separate omp acp process with one of the flags above or with a session-specific --config overlay.
tools.approvalMode: yolo fully applies to ACP when it is explicitly configured or supplied by a runtime flag. It skips OMP’s approval prompts and also skips the ACP client permission gate for bash, edit, delete, and move unless tools.approval.<tool> is prompt or deny. The schema default is yolo, but default-config ACP sessions still keep the client permission gate; set tools.approvalMode: yolo explicitly when the client wants unattended execution.
When ACP approval is required, OMP routes it through the ACP client instead of the terminal TUI. Client-gated bash, edit, delete, and move calls use ACP session/request_permission; generic approval prompts use form elicitation when the client advertises elicitation.form. A rejected, cancelled, or unsupported prompt rejects/cancels the tool call; OMP does not silently allow it.
Subagents
Subagents run headless with tools.approvalMode: yolo so they do not stall waiting for UI. The parent task approval is the authorization boundary. User tools.approval.<tool> settings continue to control whether a tool is allowed, prompted, or blocked.
Extensibility & MCP
Primary guide for authoring runtime extensions in packages/coding-agent.
This document covers the current extension runtime in:
src/extensibility/extensions/types.tssrc/extensibility/extensions/runner.tssrc/extensibility/extensions/wrapper.tssrc/extensibility/extensions/index.tssrc/modes/controllers/extension-ui-controller.ts
For discovery paths and filesystem loading rules, see extension-loading.md.
What an extension is
An extension is a TS/JS module exporting a default factory:
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
export default function myExtension(pi: ExtensionAPI) {
// register handlers/tools/commands/renderers
}Extensions can combine all of the following in one module:
- event handlers (
pi.on(...)) - LLM-callable tools (
pi.registerTool(...)) - slash commands (
pi.registerCommand(...)) - keyboard shortcuts and flags
- custom message rendering
- session/message injection APIs (
sendMessage,sendUserMessage,appendEntry)
Runtime model
- Extensions are imported and their factory functions run.
- During that load phase, registration methods are valid; runtime action methods are not yet initialized.
ExtensionRunner.initialize(...)wires live actions/contexts for the active mode.- Session/agent/tool lifecycle events are emitted to handlers.
- Every tool execution is wrapped with extension interception (
tool_call/tool_result).
Extension lifecycle (simplified)
load paths
│
▼
import module + run factory (registration only)
│
▼
ExtensionRunner.initialize(mode/session/tool registry)
│
├─ emit session/agent events to handlers
├─ wrap tool execution (tool_call/tool_result)
└─ expose runtime actions (sendMessage, setActiveTools, ...)Important constraint from loader.ts:
- calling action methods like
pi.sendMessage()during extension load throwsExtensionRuntimeNotInitializedError - register first; perform runtime behavior from events/commands/tools
Quick start
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
export default function (pi: ExtensionAPI) {
const { z } = pi.zod;
pi.setLabel("Safety + Utilities");
pi.on("session_start", async (_event, ctx) => {
ctx.ui.notify(`Extension loaded in ${ctx.cwd}`, "info");
});
pi.on("tool_call", async (event) => {
if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
return { block: true, reason: "Blocked by extension policy" };
}
});
pi.registerTool({
name: "hello_extension",
label: "Hello Extension",
description: "Return a greeting",
parameters: z.object({ name: z.string() }),
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
return {
content: [{ type: "text", text: `Hello, ${params.name}` }],
details: { greeted: params.name },
};
},
});
pi.registerCommand("hello-ext", {
description: "Show queue state",
handler: async (_args, ctx) => {
ctx.ui.notify(`pending=${ctx.hasPendingMessages()}`, "info");
},
});
}Extension API surfaces
1) Registration and actions (ExtensionAPI)
Core methods:
on(event, handler)registerTool,registerCommand,registerShortcut,registerFlagregisterMessageRenderer,registerAssistantThinkingRenderersetLabel,getFlagsendMessage,sendUserMessage,appendEntry,execgetActiveTools,getAllTools,setActiveToolsgetCommandsgetSessionName,setSessionNamesetModel,getThinkingLevel,setThinkingLevelregisterProviderevents(shared event bus)
In interactive mode, input handlers run before the built-in first-message auto-title check. Extensions that call await pi.setSessionName(...) from input can set the persisted session name and prevent the default auto-generated title from running for that session.
Also exposed:
pi.loggerpi.typebox(zod-backed compatibility shim for legacy TypeBox-style schemas)pi.zod(injectedzod/v4module — canonical for tool parameter schemas)pi.pi(package exports)
Message delivery semantics
pi.sendMessage(message, options) supports:
deliverAs: "steer"(default) — interrupts current rundeliverAs: "followUp"— queued to run after current rundeliverAs: "nextTurn"— stored and injected on the next user prompttriggerTurn: true— starts a turn when idle (also honored withdeliverAs: "nextTurn": idle prompts immediately; while streaming the queued message schedules an internal continuation)
pi.sendUserMessage(content, { deliverAs }) always goes through prompt flow; while streaming it queues as steer/follow-up.
2) Handler context (ExtensionContext)
Handlers and tool execute receive ctx with:
uihasUIcwdsessionManager(read-only)modelRegistry,modelmodels(read-only model query — see below)getContextUsage()compact(...)isIdle(),hasPendingMessages(),abort()shutdown()getSystemPrompt()memory(optional structured memory runtime — status/search/save across the configured backend)
Model selection (ctx.models)
ctx.models is a read-only facade for picking and comparing models the same way core does:
list()— authenticated models available this session.current()— the live session model (read lazily, so it reflects/modelswitches).resolve(spec)— a model string (provider/id, bare id) or role alias (pi/slow, a configured role) →Model, honoring the same settings-backed aliases and match preferences as--model. Returnsundefinedwhen nothing matches.family(model)— an opaque lineage token for “same family?” checks (Claude point releases share a token; Claude and GPT differ). Compare it; don’t persist it (the vocabulary tracks new releases).
// Pick a model from a different family than the current one (e.g. a cross-family reviewer).
const current = ctx.models.current();
const contrasting = ctx.models
.list()
.find(m => current && ctx.models.family(m) !== ctx.models.family(current));3) Command context (ExtensionCommandContext)
Command handlers additionally get:
waitForIdle()newSession(...)switchSession(...)branch(entryId)navigateTree(targetId, { summarize })reload()
Use command context for session-control flows; these methods are intentionally separated from general event handlers.
Event surface (current names and behavior)
Canonical event unions and payload types are in types.ts.
Session lifecycle
session_startsession_before_switch/session_switchsession_before_branch/session_branchsession_before_compact/session.compacting/session_compactsession_before_tree/session_treesession_shutdown
Cancelable pre-events:
session_before_switch→{ cancel?: boolean }session_before_branch→{ cancel?: boolean; skipConversationRestore?: boolean }session_before_compact→{ cancel?: boolean; compaction?: CompactionResult }session_before_tree→{ cancel?: boolean; summary?: { summary: string; details?: unknown } }
Prompt and turn lifecycle
inputbefore_agent_startbefore_provider_request(may replace provider request payload)after_provider_responsecontextagent_start/agent_end— agent loop lifecycle notification;agent_endremains notification-onlysession_stop— main-session stop hook, awaited before settle; may continue with{ continue: true, additionalContext }or{ decision: "block", reason }; capped at 8 consecutive continuations and never fires for task/subagent sessionsturn_start/turn_endmessage_start/message_update/message_end
Tool lifecycle
tool_call(pre-exec, may block)tool_result(post-exec, may patch content/details/isError)tool_execution_start/tool_execution_update/tool_execution_end(observability)tool_approval_requested/tool_approval_resolved(observability; emitted bywrapper.tsonly when a tool requires approval and an approval handler is registered)
tool_result is middleware-style: handlers run in extension order and each sees prior modifications.
Reliability/runtime signals
auto_compaction_start/auto_compaction_endauto_retry_start/auto_retry_endttsr_triggeredtodo_remindergoal_updatedcredential_disabled
User command interception
user_bash(override with{ result })user_python(override with{ result })
resources_discover
resources_discover exists in extension types and ExtensionRunner.
Current runtime note: ExtensionRunner.emitResourcesDiscover(...) is implemented, but there are no AgentSession callsites invoking it in the current codebase.
Tool authoring details
registerTool uses ToolDefinition from types.ts.
Current execute signature:
execute(
toolCallId,
params,
signal,
onUpdate,
ctx,
): Promise<AgentToolResult>Template:
const { z } = pi.zod;
pi.registerTool({
name: "my_tool",
label: "My Tool",
description: "...",
parameters: z.object({}),
hidden: false,
defaultInactive: false,
deferrable: false,
async execute(_id, _params, signal, onUpdate, ctx) {
if (signal?.aborted) {
return { content: [{ type: "text", text: "Cancelled" }] };
}
onUpdate?.({ content: [{ type: "text", text: "Working..." }] });
return { content: [{ type: "text", text: "Done" }], details: {} };
},
onSession(event, ctx) {
// reason: start|switch|branch|tree|shutdown
},
renderCall(args, options, theme) {
// optional TUI render
},
renderResult(result, options, theme, args) {
// optional TUI render
},
});tool_call/tool_result intercept all tools once the registry is wrapped in sdk.ts, including built-ins and extension/custom tools. ToolDefinition also supports optional hidden, defaultInactive, deferrable, approval, mcpServerName, mcpToolName, renderCall, and renderResult fields.
UI integration points
ctx.ui implements the ExtensionUIContext interface. Support differs by mode.
Interactive mode (extension-ui-controller.ts)
Supported:
- dialogs:
select,confirm,input,editor - input editing:
setEditorText,getEditorText,pasteToEditor,editor - terminal title and working message (
setTitle,setWorkingMessage) - notifications/status/editor text/terminal input/custom overlays
- theme listing/loading by name (
setThemesupports string names) - tools expanded toggle
Current no-op methods in this controller:
setFootersetHeader
setEditorComponent is wired to the live editor (ctx.setEditorComponent(factory)). setWidget renders real widget components above or below the editor via setHookWidget(...) (placement: "aboveEditor" | "belowEditor"; string-array content capped at 10 lines).
RPC mode (rpc-mode.ts)
ctx.ui is backed by RPC extension_ui_request events:
- dialog methods (
select,confirm,input,editor) round-trip to client responses - fire-and-forget methods emit requests (
notify,setStatus,setWidgetfor string arrays,setEditorText;setTitleemits only whenPI_RPC_EMIT_TITLE=1)
Unsupported/no-op in RPC implementation:
onTerminalInputcustomsetFooter,setHeader,setEditorComponentsetWorkingMessage- theme switching/loading (
setThemereturns failure) - tool expansion controls are inert
Print/headless/subagent paths
When no UI context is supplied to runner init, ctx.hasUI is false and methods are no-op/default-returning.
ACP mode
ACP installs an elicitation-bridged UI context (createAcpExtensionUiContext in acp-agent.ts). ctx.hasUI is true while only select/confirm/input round-trip (as ACP elicitations; defaults are returned when the client lacks the elicitation.form capability). The non-elicitation surface (widgets, editor, theming, terminal input) is stubbed no-op.
Session and state patterns
For durable extension state:
- Persist with
pi.appendEntry(customType, data). - Rebuild state from
ctx.sessionManager.getBranch()onsession_start,session_branch,session_tree. - Keep tool result
detailsstructured when state should be visible/reconstructible from tool result history.
Example reconstruction pattern:
pi.on("session_start", async (_event, ctx) => {
let latest;
for (const entry of ctx.sessionManager.getBranch()) {
if (entry.type === "custom" && entry.customType === "my-state") {
latest = entry.data;
}
}
// restore from latest
});Rendering extension points
Custom message renderer
pi.registerMessageRenderer("my-type", (message, { expanded }, theme) => {
// return pi-tui Component
});Used by interactive rendering when custom messages are displayed.
Assistant thinking renderer
import { Container, Text } from "@oh-my-pi/pi-tui";
pi.registerAssistantThinkingRenderer((context, theme) => {
const container = new Container();
container.addChild(new Text(theme.fg("dim", `thinking chars: ${context.text.length}`), 1, 0));
return container;
});Used by interactive rendering to add display-only supplemental UI below each visible assistant thinking block. The renderer receives the already-visible thinking text, content/thinking indexes, theme, and a requestRender() callback for async renderers. All registered renderers that return a component are appended in registration order. Renderers must not mutate messages; the original thinking block remains the provider/session source of truth.
Tool call/result renderer
Provide renderCall / renderResult on registerTool definitions for custom tool visualization in TUI.
Constraints and pitfalls
- Runtime actions are unavailable during extension load.
tool_callerrors block execution (fail-closed).- Command name conflicts with built-ins are skipped with diagnostics.
- Reserved shortcuts are ignored (
ctrl+c,ctrl+d,ctrl+z,ctrl+k,ctrl+p,ctrl+l,ctrl+o,ctrl+t,ctrl+g,ctrl+q,alt+m,shift+tab,shift+ctrl+p,alt+enter,escape,enter). - Treat
ctx.reload()as terminal for the current command handler frame.
Extensions vs hooks vs custom-tools
Use the right surface:
- Extensions (
src/extensibility/extensions/*): unified system (events + tools + commands + renderers + provider registration). - Hooks (
src/extensibility/hooks/*): separate legacy event API. - Custom-tools (
src/extensibility/custom-tools/*): tool-focused modules; when loaded alongside extensions they are adapted and still pass through extension interception wrappers.
If you need one package that owns policy, tools, command UX, and rendering together, use extensions.
This document covers how the coding-agent discovers and parses Gemini-style manifest extensions (gemini-extension.json) into the extensions capability.
It does not cover TypeScript/JavaScript extension module loading (extensions/*.ts, index.ts, package.json omp.extensions), which is documented in extension-loading.md.
Implementation files
packages/coding-agent/src/discovery/gemini.tspackages/coding-agent/src/discovery/builtin.tspackages/coding-agent/src/discovery/helpers.tspackages/coding-agent/src/capability/extension.tspackages/coding-agent/src/capability/index.tspackages/coding-agent/src/extensibility/extensions/loader.ts
What gets discovered
The Gemini provider (id: gemini, priority 60) registers an extensions loader that scans two fixed roots:
- User:
~/.gemini/extensions - Project:
<cwd>/.gemini/extensions
Path resolution is direct from ctx.home and ctx.cwd via getUserPath() / getProjectPath().
Important scope rule: project lookup is cwd-only. It does not walk parent directories.
Directory scan rules
For each root (~/.gemini/extensions and <cwd>/.gemini/extensions), discovery does:
readDirEntries(root)- keep only direct child directories (
entry.isDirectory()) - for each child
<name>, attempt to read exactly:<root>/<name>/gemini-extension.json
There is no recursive scan beyond one directory level.
Hidden directories
Gemini manifest discovery does not filter out dot-prefixed directory names. If a hidden child directory exists and contains gemini-extension.json, it is considered.
Missing/unreadable files
If gemini-extension.json is missing or unreadable, that directory is skipped silently (no warning).
Manifest shape (as implemented)
The capability type defines this manifest shape:
interface ExtensionManifest {
name?: string;
description?: string;
mcpServers?: Record<string, Omit<MCPServer, "name" | "_source">>;
tools?: unknown[];
context?: unknown;
}Discovery-time behavior is intentionally loose:
- JSON parse success is required.
- There is no runtime schema validation for field types/content beyond JSON syntax.
- The parsed object is stored as
manifeston the capability item.
Name normalization
Extension.name is set to:
manifest.nameif it is notnull/undefined- otherwise the extension directory name
No string-type enforcement is applied here.
Materialization into capability items
A valid parsed manifest creates one Extension capability item:
{
name: manifest.name ?? <directory-name>,
path: <extension-directory>,
manifest: <parsed-json>,
level: "user" | "project",
_source: {
provider: "gemini",
providerName: "Gemini CLI" // attached by capability registry
path: <absolute-manifest-path>,
level: "user" | "project"
}
}Notes:
_source.pathis normalized to an absolute path bycreateSourceMeta().- Registry-level capability validation for
extensionsonly checks presence ofnameandpath. - Manifest internals (
mcpServers,tools,context) are not validated during discovery.
Error handling and warning semantics
Warned
- Invalid JSON in a manifest file:
- warning format:
Invalid JSON in <manifestPath>
- warning format:
Not warned (silent skip)
extensionsdirectory missing- child directory has no
gemini-extension.json - unreadable manifest file
- manifest JSON is syntactically valid but semantically odd/incomplete
This means partial validity is accepted: only syntactic JSON failure emits a warning.
Precedence and deduplication with other sources
extensions capability is aggregated across providers by the capability registry.
Current providers for this capability:
native(packages/coding-agent/src/discovery/builtin.ts) priority100gemini(packages/coding-agent/src/discovery/gemini.ts) priority60
Dedup key is ext.name (extensionCapability.key = ext => ext.name).
Cross-provider precedence
Higher-priority provider wins on duplicate extension names.
- If
nativeandgeminiboth emit extension namefoo, the native item is kept. - Lower-priority duplicate is retained only in
result.allwith_shadowed = true.
Intra-provider order effects
Because dedup is “first seen wins”, provider-local item order matters.
- Gemini loader appends user first, then project.
- Therefore, duplicate names between
~/.gemini/extensionsand<cwd>/.gemini/extensionskeep the user entry and shadow the project entry.
By contrast, native provider builds config dir order differently (project then user in getConfigDirs()), so native intra-provider shadowing is the opposite direction.
User vs project behavior summary
For Gemini manifests specifically:
- Both user and project roots are scanned every load.
- Project root is fixed to
<cwd>/.gemini/extensions(no ancestor walk). - Duplicate names inside Gemini source resolve to user-first.
- Duplicate names against higher-priority providers (notably native) lose by priority.
Boundary: discovery metadata vs runtime extension loading
gemini-extension.json discovery currently feeds capability metadata (Extension items). It does not directly load runnable TS/JS extension modules.
Runtime module loading (discoverAndLoadExtensions() / loadExtensions()) uses the extension-module capability and explicit paths, and currently filters auto-discovered modules to provider native only.
Practical implication:
- Gemini manifest extensions are discoverable as capability records.
- They are not, by themselves, executed as runtime extension modules by the extension loader pipeline.
This boundary is intentional in current implementation and explains why manifest discovery and executable module loading can diverge.
Extensions are the primary way to add capabilities to oh-my-pi. A single extension module can register tools the LLM can call, slash commands users can invoke, and event handlers that run throughout the session lifecycle — all from one TypeScript file.
Minimum viable extension
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
export default function (pi: ExtensionAPI) {
pi.on("session_start", async (_event, ctx) => {
ctx.ui.notify("My extension loaded!", "info");
});
}That is a working extension. Drop it into ~/.omp/agent/extensions/hello.ts and restart omp to see the notification.
Full example
The following extension registers a slash command, a tool, and a session-start hook:
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
export default function myExtension(pi: ExtensionAPI) {
const z = pi.zod;
// Runs once when the session loads
pi.on("session_start", async (_event, ctx) => {
ctx.ui.notify(`Session ready in ${ctx.cwd}`, "info");
});
// Slash command: /greet
pi.registerCommand("greet", {
description: "Send a greeting into the conversation",
handler: async (args, ctx) => {
const name = args.trim() || "world";
pi.sendMessage(
{
customType: "greeting",
content: `Hello, ${name}!`,
display: true,
attribution: "user",
},
{ triggerTurn: false }
);
ctx.ui.notify(`Greeted ${name}`, "info");
},
});
// LLM-callable tool
pi.registerTool({
name: "word_count",
label: "Word Count",
description: "Count the words in a string",
parameters: z.object({
text: z.string().describe("Text to count"),
}),
async execute(_id, params, _signal, _onUpdate, _ctx) {
const count = params.text.split(/\s+/).filter(Boolean).length;
return {
content: [{ type: "text", text: String(count) }],
details: { count },
};
},
});
}Discovery paths
omp loads extension modules from these sources:
- Native
.omplocations discovered through the capability system:<cwd>/.omp/extensions/~/.omp/agent/extensions/- legacy extension paths listed in
.omp/settings.json#extensionsor~/.omp/agent/settings.json#extensions
- Installed plugins under
~/.omp/plugins/node_modules(omp plugin installnpm/git specs, oromp plugin link) via theiromp.extensions/pi.extensionsmanifests. Marketplace cache installs do not feed extension modules — they surface skills/commands/hooks/tools/MCP only. - Explicit configured paths passed by the CLI (
omp --extension ./my-ext.ts, also-e;--hookis treated as an alias) and by theextensions:setting in config.
The runtime de-duplicates by resolved absolute path — first seen wins.
When a path points to a directory, omp resolves the entry point in this order:
package.jsonwithomp.extensions(or legacypi.extensions) fieldindex.tsindex.js
When scanning an extensions/ directory, omp also loads direct *.ts/*.js files and one-level subdirectories that have index.ts, index.js, or a manifest.
Extension packages can also bundle sibling capability directories. When a package is loaded through extensions: or --extension/-e, the omp-plugins provider discovers its skills/, hooks/pre|post/, tools/, commands/, rules/, prompts/, and .mcp.json.
package.json manifest
To package an extension as an installable plugin, add an omp field to package.json:
{
"name": "my-omp-extension",
"omp": {
"extensions": ["./src/main.ts"]
}
}The legacy pi key is also accepted for backwards compatibility:
{
"pi": {
"extensions": ["./index.ts"]
}
}Multiple entry points are supported:
{
"omp": {
"extensions": ["./src/safety.ts", "./src/tools.ts"]
}
}Registering commands
pi.registerCommand("my-cmd", {
description: "What the command does",
handler: async (args, ctx) => {
// args: everything the user typed after /my-cmd
// ctx: ExtensionCommandContext — includes ctx.ui, ctx.cwd, session controls
ctx.ui.notify("Running!", "info");
await ctx.waitForIdle();
await ctx.newSession();
},
});ExtensionCommandContext session-control methods (safe to call from commands only):
| Method | Effect |
|---|---|
waitForIdle() | Wait for the agent to finish streaming |
newSession(opts?) | Open a fresh session |
switchSession(path) | Switch to an existing session file |
branch(entryId) | Fork from a specific history entry |
navigateTree(id, opts?) | Jump to a different point in the session tree |
reload() | Reload the session runtime |
compact(opts?) | Compact the current context |
Registering tools
Tools are called by the LLM. Parameters use Zod schemas, available at pi.zod:
const z = pi.zod;
pi.registerTool({
name: "search_notes", // snake_case, unique
label: "Search Notes", // human-readable label for TUI
description: "Full-text search through project notes",
parameters: z.object({
query: z.string().describe("Search query"),
limit: z.number().default(10).describe("Max results").optional(),
}),
async execute(toolCallId, params, signal, onUpdate, ctx) {
if (signal?.aborted) {
return { content: [{ type: "text", text: "Cancelled" }] };
}
onUpdate?.({ content: [{ type: "text", text: "Searching..." }] });
// ... do work ...
return {
content: [{ type: "text", text: `Found N results for "${params.query}"` }],
details: { query: params.query, count: 0 },
};
},
});Subscribing to events
pi.on("tool_call", async (event, ctx) => {
// event.toolName, event.input, event.toolCallId
if (event.toolName !== "bash") return;
const command = String((event.input as { command?: unknown }).command ?? "");
if (command.includes("rm -rf /")) {
return { block: true, reason: "Blocked by safety policy" };
}
});
pi.on("turn_end", async (_event, ctx) => {
ctx.ui.setStatus("tokens", `~${ctx.getContextUsage()?.tokens ?? "?"} tokens`);
});
pi.on("session_stop", async (event) => {
if (event.stop_hook_active) return;
return { continue: true, additionalContext: `Review final status after turn ${event.turn_id}.` };
});Full event catalog: see extension authoring guide.
Extension vs hook — when to use which
| Need | Use |
|---|---|
| Tools + commands + events in one module | Extension (ExtensionAPI) |
| Pure event interception (policy, redaction) | Extension or Hook (both work; extension is preferred) |
| Legacy hook module already exists | Hook (HookAPI from @oh-my-pi/pi-coding-agent/extensibility/hooks) |
| Registering a provider, shortcut, or CLI flag | Extension only |
| Shipping as a marketplace plugin | Extension (use package.json manifest) |
Extensions are a strict superset of hooks. New authoring should use ExtensionAPI.
Debugging
omp writes structured logs to a rotating file under ~/.omp/logs/ (debug level is always on; nothing is written to the console, which would corrupt the TUI). Tail today’s log to see extension load diagnostics:
tail -f ~/.omp/logs/omp.$(date +%F).log
Failed extension loads are logged with their path and error. Loaded extensions may also emit their own debug logs via pi.logger.
To temporarily disable a specific extension module by name without removing the file:
# ~/.omp/agent/config.yml
disabledExtensions:
- extension-module:my-extThe derived name is the filename stem (or directory name for index.ts-style entries): /path/to/my-ext.ts → my-ext.
Important constraints
- Do not call runtime actions during load. Methods like
pi.sendMessage()throwExtensionRuntimeNotInitializedErrorif called synchronously during module evaluation (before a session is active). Register handlers/tools/commands during load; perform runtime actions only from event handlers, tools, or commands. tool_callerrors are fail-closed. If atool_callhandler throws, the tool is blocked.- Command names must not clash with built-ins. Conflicts are skipped with a diagnostic log.
- Reserved shortcuts are ignored (
ctrl+c,ctrl+d,ctrl+z,ctrl+k,ctrl+p,ctrl+l,ctrl+o,ctrl+t,ctrl+g,ctrl+q,alt+m,shift+tab,shift+ctrl+p,alt+enter,escape,enter).
Further reading
docs/extensions.md— runtime internals and full API surface referencedocs/extension-loading.md— detailed path resolution rulesdocs/hooks.md— hook subsystem internalsdocs/skills/examples/hello-extension/— complete working example
Hooks are event-driven interceptors that run alongside the agent loop. They are best used for cross-cutting concerns: safety policy, secret redaction, context pruning, audit logging. A hook module registers handlers via pi.on(event, handler) and can block tool execution, override tool output, or rewrite the message context before each LLM call.
Relationship to extensions: The hook subsystem (
HookAPI) is the legacy API. The extension runner now handles everything hooks can do plus more.ExtensionAPIsupports the hook event model plus extension-only events. UseExtensionAPIfor new work; useHookAPIonly if you are maintaining an existing hook module.
Factory signature
import type { HookAPI } from "@oh-my-pi/pi-coding-agent/extensibility/hooks";
export default function myHook(omp: HookAPI): void {
omp.on("tool_call", async (event, ctx) => {
// intercept every tool call
});
}The default export must be a plain function (not async, not a class). It receives a HookAPI instance and must register all handlers synchronously during execution.
Alternatively, using ExtensionAPI (preferred):
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
export default function myExtension(pi: ExtensionAPI): void {
pi.on("tool_call", async (event, ctx) => { /* ... */ });
}Event catalog
Tool lifecycle
| Event | Fires | Can return |
|---|---|---|
tool_call | Before every tool execution | { block?: boolean; reason?: string } |
tool_result | After every tool execution | { content?; details?; isError?: boolean } |
Session lifecycle
| Event | Fires | Can return |
|---|---|---|
session_start | On initial session load | — |
session_before_switch | Before session switch | { cancel?: boolean } |
session_switch | After session switch | — |
session_before_branch | Before session branch | { cancel?: boolean; skipConversationRestore?: boolean } |
session_branch | After session branch | — |
session_before_compact | Before compaction | { cancel?: boolean; compaction?: CompactionResult } |
session.compacting | During compaction (inject context) | { context?: string[]; prompt?: string; preserveData?: Record<string, unknown> } |
session_compact | After compaction | — |
session_before_tree | Before tree navigation | { cancel?: boolean; summary?: { summary: string; details?: unknown } } |
session_tree | After tree navigation | — |
session_shutdown | On session shutdown | — |
Agent/turn lifecycle
| Event | Fires | Can return |
|---|---|---|
before_agent_start | Before agent starts a turn | { message?: { customType; content; display; details; attribution? } } |
agent_start | Agent streaming starts | — |
agent_end | Agent streaming ends | — |
turn_start | Start of a user→agent turn | — |
turn_end | End of a user→agent turn | — |
context | Before each LLM API call | { messages?: Message[] } |
auto_compaction_start | Auto-compaction begins | — |
auto_compaction_end | Auto-compaction ends | — |
auto_retry_start | Auto-retry begins | — |
auto_retry_end | Auto-retry ends | — |
ttsr_triggered | TTSR (too-short response) triggered | — |
todo_reminder | Todo reminder fires | — |
Extension-only events such as tool_execution_start, tool_execution_update, tool_execution_end, input, user_bash, and user_python require ExtensionAPI.
Pre-tool blocking contract
Return { block: true, reason: "..." } from a tool_call handler to prevent execution:
omp.on("tool_call", async (event, ctx) => {
if (event.toolName === "bash") {
const cmd = String(event.input.command ?? "");
if (/\brm\s+-rf\s+\//.test(cmd)) {
return { block: true, reason: "Refusing to delete root filesystem" };
}
}
});Contract:
- If any handler returns
{ block: true }, execution stops immediately. reasonis returned to the LLM as the tool error text.- If a handler throws, the tool is also blocked (fail-closed).
- Last non-blocking return wins for non-blocking results; first
block: trueshort-circuits.
Post-tool override contract
Return { content, details, isError } from a tool_result handler to patch what the LLM sees:
omp.on("tool_result", async (event, ctx) => {
if (event.toolName === "read" && !event.isError) {
const redacted = event.content.map(chunk => {
if (chunk.type !== "text") return chunk;
return {
...chunk,
text: chunk.text.replace(/(?:sk|pk)-[a-zA-Z0-9]{20,}/g, "[REDACTED_API_KEY]"),
};
});
return { content: redacted };
}
});Contract:
- Handlers run in registration order. For
HookAPI, each handler receives the original tool result event, and the last returned override wins. contentreplaces the full content array for the LLM.detailsreplaces the structured details object.isErrorexists on the shared result type, butHookToolWrapperdoes not propagate it into a successful tool result; on a tool failure, the original error is rethrown after handlers complete.- On a tool failure,
tool_resultis still emitted withisError: true.
Context modification contract
Return { messages: [...] } from a context handler to rewrite the message list before each LLM API call:
omp.on("context", async (event, ctx) => {
// Remove debug-only custom messages from LLM context
const filtered = event.messages.filter(
msg => !(msg.role === "custom" && msg.customType === "debug-only")
);
return { messages: filtered };
});Contract:
event.messagesis the current accumulated list.- Handlers run in order; each receives the output of the previous handler.
- Return
undefined(or nothing) to pass messages through unmodified.
Three complete examples
1. rm-rf blocker
import type { HookAPI } from "@oh-my-pi/pi-coding-agent/extensibility/hooks";
export default function rmRfBlocker(omp: HookAPI): void {
omp.on("tool_call", async (event, ctx) => {
if (event.toolName !== "bash") return;
const cmd = String(event.input.command ?? "");
if (!/\brm\s+-rf\s+\//.test(cmd)) return;
// Allow if user explicitly confirms (interactive mode only)
if (ctx.hasUI) {
const allow = await ctx.ui.confirm(
"Dangerous command",
`This command deletes from root:\n${cmd}\n\nProceed?`
);
if (allow) return;
}
return { block: true, reason: "rm -rf / blocked by safety policy" };
});
}2. API-key redactor
import type { HookAPI } from "@oh-my-pi/pi-coding-agent/extensibility/hooks";
// Common API-key shapes. Not exhaustive — providers using bespoke formats
// (Anthropic `sk-ant-…`, JWT-style bearers, gateway-specific prefixes, etc.)
// need their own entries.
const SECRET_PATTERNS = [
/\b(sk|pk)-[a-zA-Z0-9]{20,}\b/g,
/\bAKIA[A-Z0-9]{16}\b/g,
/\bghp_[a-zA-Z0-9]{36}\b/g,
// Zhipu / GLM Coding Plan: `<id>.<secret>` (no `sk-` prefix).
/\b[a-zA-Z0-9]{16,}\.[a-zA-Z0-9]{16,}\b/g,
/\b[a-zA-Z0-9_-]{20,}\s*=\s*["']?[a-zA-Z0-9._/+=-]{20,}["']?/g,
];
export default function apiKeyRedactor(omp: HookAPI): void {
omp.on("tool_result", async (event) => {
if (event.isError) return;
let changed = false;
const redacted = event.content.map(chunk => {
if (chunk.type !== "text") return chunk;
let text = chunk.text;
for (const pattern of SECRET_PATTERNS) {
const next = text.replace(pattern, "[REDACTED]");
if (next !== text) { changed = true; text = next; }
}
return { ...chunk, text };
});
if (changed) return { content: redacted };
});
}3. Context filter
import type { HookAPI } from "@oh-my-pi/pi-coding-agent/extensibility/hooks";
export default function contextFilter(omp: HookAPI): void {
omp.on("context", async (event) => {
const MAX_TOOL_OUTPUT_CHARS = 8_000;
const trimmed = event.messages.map(msg => {
// Truncate very large tool results to keep context manageable
if (msg.role !== "toolResult") return msg;
const content = msg.content.map(chunk => {
if (chunk.type !== "text" || chunk.text.length <= MAX_TOOL_OUTPUT_CHARS) return chunk;
return {
...chunk,
text: chunk.text.slice(0, MAX_TOOL_OUTPUT_CHARS) + "\n[... truncated by context-filter hook]",
};
});
return { ...msg, content };
});
return { messages: trimmed };
});
}UI methods in hook context
ctx.ui is a HookUIContext. Available methods:
| Method | Description |
|---|---|
notify(message, type?) | Show an in-app notification |
setStatus(key, text) | Set footer status text (keyed, sorted by key) |
select(title, options) | Show a selection dialog |
confirm(title, message) | Show a yes/no dialog |
input(title, placeholder?) | Show a text input dialog |
editor(title, prefill?, { signal }?, { promptStyle }?) | Show a multi-line editor |
setEditorText(text) | Set the input editor content |
getEditorText() | Get current input editor content |
custom(factory) | Render a custom TUI component |
theme | Current theme object |
Pass { promptStyle: true } as the fourth argument when Enter should submit and Shift+Enter should insert a newline. The default hook editor behavior keeps Enter as newline and submits on the app.message.followUp chord (Ctrl+Q or Ctrl+Enter).
ctx.hasUI is false in headless/print/subagent mode — always guard interactive calls.
Further reading
docs/hooks.md— hook subsystem internals, ordering rules, error propagationdocs/extensions.md—ExtensionAPI(superset ofHookAPI)docs/skills/examples/safety-hook/— complete working example
A marketplace is a Git repository (or local directory) that contains a catalog file at either .omp-plugin/marketplace.json (preferred for omp-specific catalogs) or .claude-plugin/marketplace.json (Claude Code-compatible; used as the fallback). Anyone can author one. Users add it with /marketplace add owner/repo and then install individual plugins from it.
Minimum viable marketplace
my-marketplace/
.claude-plugin/
marketplace.json
plugins/
my-plugin/
skills/
my-skill/
SKILL.md
{
"name": "my-marketplace",
"owner": { "name": "Your Name" },
"plugins": [
{
"name": "my-plugin",
"description": "What it does",
"source": "./plugins/my-plugin"
}
]
}Push to GitHub. Users install with:
/marketplace add your-github-username/my-marketplace
/marketplace install my-plugin@my-marketplace
marketplace.json schema
The catalog file lives at either .omp-plugin/marketplace.json or .claude-plugin/marketplace.json in the repository root. omp prefers the .omp-plugin/ path and falls back to the Claude path; a repository may publish both to expose tool-specific catalogs from a single source tree.
Top-level fields
| Field | Required | Description |
|---|---|---|
name | yes | Marketplace name. Lowercase alphanumeric, hyphens, dots. Must start and end with alphanumeric. Max 64 chars. |
owner | yes | Object with at minimum owner.name (string) |
owner.name | yes | Marketplace owner name |
owner.email | no | Owner contact email |
plugins | yes | Array of plugin entries (see below) |
metadata.description | no | Short description of the marketplace |
metadata.version | no | Catalog metadata version string |
metadata.pluginRoot | no | String prepended to all relative plugin source paths |
| extra top-level fields | no | Preserved by the parser but not used by marketplace install/runtime logic |
Plugin entry fields
| Field | Required | Description |
|---|---|---|
name | yes | Plugin name (same naming rules as marketplace name) |
source | yes | Where to find the plugin — string or object (see source types below) |
description | no | Short plugin description |
version | no | Version string |
author | no | { name, email? } |
homepage | no | URL |
category | no | e.g. development, productivity, security |
tags / keywords | no | Arrays of string tags/keywords |
repository | no | Repository URL |
license | no | License string |
strict | no | Boolean plugin metadata flag |
commands, agents, hooks, mcpServers, lspServers | no | Capability metadata used by plugin tooling and selectors |
Full catalog example
{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "acme-plugins",
"owner": {
"name": "Acme Corp",
"email": "plugins@acme.example"
},
"metadata": {
"description": "Official Acme plugins for oh-my-pi"
},
"plugins": [
{
"name": "acme-linter",
"description": "Enforce Acme coding standards",
"category": "development",
"source": "./plugins/linter"
},
{
"name": "acme-deploy",
"description": "One-command deploy to Acme cloud",
"category": "devops",
"source": {
"source": "github",
"repo": "acme-corp/omp-deploy-plugin",
"ref": "main"
}
}
]
}Plugin source types
1. Relative path string
Points to a subdirectory inside the marketplace repository itself. Must start with ./.
"source": "./plugins/my-plugin"The path is resolved relative to the marketplace repository root. Path traversal outside the repo root is rejected.
Use metadata.pluginRoot to avoid repeating a common prefix:
{
"metadata": { "pluginRoot": "./plugins" },
"plugins": [
{ "name": "plugin-a", "source": "./plugin-a" },
{ "name": "plugin-b", "source": "./plugin-b" }
]
}2. Git URL
A full Git repository URL. Optionally pin to a branch/tag (ref) or exact commit (sha):
"source": {
"source": "url",
"url": "https://github.com/org/my-plugin.git",
"ref": "main",
"sha": "a1b2c3d4..."
}3. GitHub shorthand
Shorthand for GitHub repositories. Functionally equivalent to a Git URL but more concise:
"source": {
"source": "github",
"repo": "org/my-plugin",
"ref": "v2.1.0",
"sha": "a1b2c3d4..."
}4. Git subdirectory (monorepo)
For plugins living inside a subdirectory of a larger repository. url accepts a full HTTPS URL or a GitHub owner/repo shorthand:
"source": {
"source": "git-subdir",
"url": "https://github.com/org/monorepo.git",
"path": "packages/my-plugin",
"ref": "main",
"sha": "a1b2c3d4..."
}The path must resolve inside the cloned repository — directory escape is rejected.
5. NPM package
Declares the plugin as an npm package. version is optional:
"source": {
"source": "npm",
"package": "@acme/omp-plugin",
"version": "1.2.0"
}Note: npm plugin sources are declared in the schema but installation support is not yet fully implemented. Use Git-based sources for plugins that need to work today.
Plugin structure
A plugin directory (regardless of source type) ships its content in conventional locations, all optional:
my-plugin/
skills/<name>/SKILL.md ← skills
commands/*.md ← slash commands
agents/*.md ← subagent definitions
hooks/pre/, hooks/post/ ← hooks
tools/ ← custom tools
.mcp.json ← MCP server definitions
package.json ← optional; its version is a fallback when the catalog entry has no version
README.md ← recommended: description + usage
Note: extension modules declared via
package.jsonomp.extensionsare not loaded from marketplace installs — that mechanism only applies to npm-installed oromp plugin linked plugins. Ship marketplace plugin behavior through the conventional directories above.
Install command
/marketplace install name@marketplace-name
/marketplace install --force name@marketplace-name # reinstall
/marketplace install --scope project name@marketplace # project-scoped
CLI equivalent:
omp plugin marketplace add owner/repo
omp plugin install name@marketplace-name
Scope behavior:
- user (default) — installed in
~/.omp/plugins/installed_plugins.json, available in all projects - project — installed in
<project>/.omp/plugins/installed_plugins.json, available only in that project
Project-scoped installs shadow user-scoped installs of the same plugin name.
Naming rules
Marketplace names and plugin names must:
- Contain only lowercase letters, digits, hyphens (
-), and dots (.) - Start and end with a lowercase letter or digit
- Be at most 64 characters
Plugin IDs (name@marketplace) must be at most 128 characters total.
Valid: my-plugin, code-review, acme.tools, ai-v2
Invalid: -bad-start, bad-end-, .dot-start, Under_score, HAS_CAPS
Publishing workflow
- Create
marketplace.jsonat.omp-plugin/marketplace.json(omp-only) or.claude-plugin/marketplace.json(shared with Claude Code) in a new Git repo. - Add plugin entries pointing to subdirectories (or external sources).
- Push to GitHub.
- Share the
owner/repostring. Users add it with/marketplace add owner/repo. - When you update the catalog, users run
/marketplace update your-marketplace-nameto pull the latest.
To test locally before publishing:
/marketplace add ./path/to/my-marketplace
Local path sources also accept ~/ and absolute paths.
Further reading
docs/marketplace.md— marketplace system internals, on-disk layout, command referencedocs/skills/authoring-extensions.md— how to author the extension modules inside pluginsdocs/skills/examples/mini-marketplace/— minimal working marketplace example
CLI Reference & Tools
This document describes the bash tool runtime path used by agent tool calls, from command normalization to execution, truncation/artifacts, and rendering.
It also calls out where behavior diverges in interactive TUI, print mode, RPC mode, and user-initiated bang (!) shell execution.
Scope and runtime surfaces
There are two different bash execution surfaces in coding-agent:
- Tool-call surface (
toolName: "bash"): used when the model calls the bash tool.- Entry point:
BashTool.execute(). - Parameters include
command, optionalenv,timeout,cwd,pty, and, whenasync.enabledis true,async.
- Entry point:
- User bang-command surface (
!cmdfrom interactive input or RPCbashcommand): session-level helper path.- Entry point:
AgentSession.executeBash().
- Entry point:
Both eventually use executeBash() in src/exec/bash-executor.ts for non-PTY execution, but only the tool-call path runs normalization/interception, optional managed background-job handling, and tool renderer logic.
Set bash.enabled: false in settings to remove the model-facing bash tool from the active tool registry. This does not disable user-initiated bang commands or RPC bash requests.
End-to-end tool-call pipeline
1) Input handling and parameter merge
BashTool.execute() currently handles input before execution as follows:
- validates optional
envnames against shell-variable syntax, - when
bash.stripTrailingHeadTailis enabled (default), applies conservative native fixups that remove safe trailing| head/| tailpipes and redundant trailing2>&1, - extracts a leading single-line
cd <path> && ...intocwdwhencwdwas not supplied, - rejects
async: truewhenasync.enabledis false.
There are no structured head or tail tool parameters in the current schema. Output limiting is handled by OutputSink truncation/artifacts, and the optional trailing-pipe fixup exists to avoid hiding output before the harness can capture it.
2) Optional interception (blocked-command path)
If bashInterceptor.enabled is true, BashTool loads rules from settings (getBashInterceptorRules()) and runs checkBashInterception() against the command — checking both the original and the cwd-normalized form (after a leading cd … && is extracted) when they differ.
Interception behavior:
- command is blocked only when:
- regex rule matches, and
- the suggested tool is present in
ctx.toolNames.
- invalid regex rules are silently skipped.
- on block,
BashToolthrowsToolErrorwith message:Blocked: ...- original command included.
Default rule patterns (defined in code) target common misuses:
- file readers (
cat,head,tail, …) - search tools (
grep,rg, …) - file finders (
find,fd, …) - in-place editors (
sed -i,perl -i,awk -i inplace) - shell redirection writes (
echo ... > file, heredoc redirection)
Caveat
InterceptionResult includes suggestedTool, but BashTool currently surfaces only the message text (no structured suggested-tool field in details).
3) CWD validation and timeout clamping
cwd is resolved relative to session cwd (resolveToCwd), then validated via stat:
- missing path ->
ToolError("Working directory does not exist: ...") - non-directory ->
ToolError("Working directory is not a directory: ...")
Timeout is clamped to [1, 3600] seconds and converted to milliseconds.
4) Artifact allocation
Before execution, the tool allocates an artifact path/id (best-effort) for truncated output storage.
- artifact allocation failure is non-fatal (execution continues without artifact spill file),
- artifact id/path are passed into execution path for full-output persistence on truncation.
5) PTY vs non-PTY execution selection
PTY eligibility is decided by canUseInteractiveBashPty(pty, ctx) (src/tools/bash-pty-selection.ts); the local PTY overlay runs only when all are true:
- tool input
pty === true PI_NO_PTY !== "1"- tool context has UI (
ctx.hasUI === trueandctx.uiset)
If pty is requested but unavailable, the call falls back to non-PTY and appends a pty requested but unavailable … notice.
Before the local PTY/non-PTY choice, a foreground (async: false) call can route to a managed background job (auto-backgrounding; see below) or — when the session’s client advertises a terminal capability (clientBridge.capabilities.terminal + createTerminal, with pty false) — to a client-bridge editor terminal that runs the command remotely (streaming terminalId updates, killing on timeout, mapping a signal kill to exit code 137). Otherwise it uses non-interactive executeBash().
That means print mode and non-UI RPC/tool contexts always use non-PTY.
Non-interactive execution engine (executeBash)
Shell session reuse model
executeBash() caches native Shell instances in a process-global map keyed by:
- shell path,
- configured command prefix,
- snapshot path,
- serialized shell env,
- optional agent session key,
- minimizer configuration.
Session-level bang-command executions pass sessionKey: this.sessionId.
Tool-call executions pass sessionKey: this.session.getSessionId?.(), when available. In both surfaces, a session key isolates shell reuse per session; without one, reuse falls back to shell config/snapshot/env.
Concurrent calls never share one Shell: the native session runs one command at a time and Shell.abort() kills every in-flight run on it. executeBash() tracks in-flight keys in shellSessionsInUse; while a key is busy, overlapping calls skip the cache and run through one-shot executeShell() (same isolation as quarantined sessions). Only the owning call releases the in-use flag or deletes the cached session in its finally.
Shell config and snapshot behavior
At each call, executor loads settings shell config (shell, env, optional prefix).
If selected shell includes bash, it attempts getOrCreateSnapshot():
- snapshot captures aliases/functions/options from user rc,
- snapshot creation is best-effort,
- failure falls back to no snapshot.
If prefix is configured, command becomes:
<prefix> <command>The per-command child environment is built by buildNonInteractiveEnv() (src/exec/non-interactive-env.ts), which layers non-interactive hardening defaults under the caller’s env overrides:
- pagers disabled (
PAGER=cat,GIT_PAGER=cat, … andLESS=FRX), - editor prompts disabled (
GIT_EDITOR=true,EDITOR=true,VISUAL=true), - terminal/credential prompts reduced (
TERM=dumb,GIT_TERMINAL_PROMPT=0,SSH_ASKPASS=/usr/bin/false,NO_COLOR=1,CI=1), - package-manager/tooling automation flags for non-interactive behavior (npm/pnpm/yarn/pip/cargo/terraform/gh, …),
- on Windows, UTF-8 locale/codepage defaults are added when absent.
Streaming and cancellation
Shell.run() streams chunks to OutputSink and optional onChunk callback.
Cancellation:
- aborted signal triggers
shellSession.abort(...), - timeout from native result is mapped to
cancelled: true+ annotation text, - explicit cancellation similarly returns
cancelled: true+ annotation.
No exception is thrown inside executor for timeout/cancel; it returns structured BashResult and lets caller map error semantics.
Interactive PTY path (runInteractiveBashPty)
When PTY is enabled, tool runs runInteractiveBashPty() which opens an overlay console component and drives a native PtySession.
Behavior highlights:
- xterm-headless virtual terminal renders viewport in overlay,
- keyboard input is normalized (including Kitty sequences and application cursor mode handling),
escwhile running kills the PTY session,- terminal resize propagates to PTY (
session.resize(cols, rows)).
Unlike the non-PTY engine, the interactive PTY path does not apply the non-interactive hardening. It inherits the user’s environment and sets a real TERM=xterm-256color (applied as an override on the Rust side) so editors, pagers, and TUIs behave like a normal terminal.
PTY output is normalized (CRLF/CR to LF, sanitizeText) and written into OutputSink, including artifact spill support.
On PTY startup/runtime error, sink receives PTY error: ... line and command finalizes with undefined exit code.
Output handling: streaming, truncation, artifact spill
Both PTY and non-PTY paths use OutputSink.
OutputSink semantics
The bash executor builds the sink with headBytes and maxColumns from settings (resolveOutputSinkHeadBytes / resolveOutputMaxColumns).
- keeps a UTF-8-safe rolling tail window (
spillThreshold,DEFAULT_MAX_BYTES, currently 50KB); on overflow it trims to the tail (UTF-8 boundary safe) and markstruncated, - when
headBytes > 0(tools.artifactHeadBytes, default 20KB) it also retains a head window and elides the middle, splicing an elision marker between head and tail indump(), - per-line column cap: when
maxColumns > 0(tools.outputMaxColumns, default 768 bytes) over-wide lines are ellipsis-truncated at write time and the rest of the line is dropped, - tracks total bytes/lines seen,
- mirrors the raw, uncapped stream to the artifact file when output overflows, a column cap dropped bytes, or the file is already active,
- marks
truncatedon tail overflow, middle elision, column-cap drops, or file spill.
dump() returns:
output(possibly annotated prefix),truncated,totalLines/totalBytes,outputLines/outputBytes,elidedBytes/elidedLineswhen the middle was elided,columnDroppedBytes/columnTruncatedLineswhen the per-line cap fired,artifactIdif artifact file was active.
Long-output caveat
Runtime truncation is byte-threshold based in OutputSink (50KB tail window by default, plus an optional head window for middle elision). It does not enforce a hard line-count cap in this code path.
Shell output minimizer
Non-PTY execution also passes shell-minimizer settings into the native Shell session. When the minimizer rewrites verbose output, the executor replaces the sink’s visible text with the minimized text and, when possible, saves the raw original capture as a separate bash-original artifact referenced by a [raw output: artifact://<id>] footer.
Live tool updates and async jobs
For non-PTY foreground execution, BashTool uses a separate TailBuffer for partial updates and emits onUpdate snapshots while command is running.
For PTY execution, live rendering is handled by custom UI overlay, not by onUpdate text chunks.
When async.enabled is true and the call passes async: true, BashTool starts a managed bash job, returns a running job result with a job id, and stores completion through the session managed-job path. Auto-backgrounding can also start this path after bash.autoBackground.thresholdMs.
Result shaping, metadata, and error mapping
After execution:
cancelledhandling:- if abort signal is aborted -> throw
ToolAbortError(abort semantics), - else -> throw
ToolError(treated as tool failure).
- if abort signal is aborted -> throw
- PTY
timedOut-> throwToolError. - empty output becomes
(no output). - attach truncation metadata via
toolResult(...).truncationFromSummary(result, { direction: "tail" }). - exit-code mapping:
- missing exit code -> throw
ToolError("... missing exit status") - non-zero exit -> error result with
"Command exited with code N"anddetails.exitCode - zero exit -> success result.
- missing exit code -> throw
Success payload structure:
content: text output,details.meta.truncationwhen truncated, including:direction,truncatedBy, total/output line+byte counts,shownRange,artifactIdwhen available.
Because built-in tools are wrapped with wrapToolWithMetaNotice(), truncation notice text is appended to final text content automatically (for example: Read artifact://<id> for full output).
Rendering paths
Tool-call renderer (bashToolRenderer)
bashToolRenderer is used for tool-call messages (toolCall / toolResult):
- collapsed mode shows visual-line-truncated preview,
- expanded mode shows all currently available output text,
- warning line includes truncation reason and
artifact://<id>when truncated, - timeout value (from args) is shown in footer metadata line.
Caveat: full artifact expansion
BashRenderContext has isFullOutput, but current renderer context builder does not set it for bash tool results. Expanded view still uses the text already in result content (tail/truncated output) unless another caller provides full artifact content.
User bang-command component (BashExecutionComponent)
BashExecutionComponent is for user ! commands in interactive mode (not model tool calls):
- streams chunks live,
- collapsed preview keeps last 20 logical lines,
- line clamp at 4000 chars per line,
- shows truncation + artifact warnings when metadata is present,
- marks cancelled/error/exit state separately.
This component is wired by CommandController.handleBashCommand() and fed from AgentSession.executeBash().
Mode-specific behavior differences
| Surface | Entry path | PTY eligible | Live output UX | Error surfacing |
|---|---|---|---|---|
| Interactive tool call | BashTool.execute | Yes, when pty=true and UI exists and PI_NO_PTY!=1 | PTY overlay (interactive) or streamed tail updates | Tool errors become toolResult.isError |
| Print mode tool call | BashTool.execute | No (no UI context) | No TUI overlay; output appears in event stream/final assistant text flow | Same tool error mapping |
| RPC tool call (agent tooling) | BashTool.execute | Usually no UI -> non-PTY | Structured tool events/results | Same tool error mapping |
Interactive bang command (!) | AgentSession.executeBash + BashExecutionComponent | No (uses executor directly) | Dedicated bash execution component | Controller catches exceptions and shows UI error |
RPC bash command | rpc-mode -> session.executeBash | No | Returns BashResult directly | Consumer handles returned fields |
Operational caveats
- Interceptor only blocks commands when suggested tool is currently available in context.
- If artifact allocation fails, truncation still occurs but no
artifact://back-reference is available. - Shell session cache has no explicit eviction in this module; lifetime is process-scoped.
- PTY and non-PTY timeout surfaces differ:
- PTY exposes explicit
timedOutresult field, - non-PTY maps timeout into
cancelled + annotationsummary.
- PTY exposes explicit
Implementation files
src/tools/bash.ts— tool entrypoint, input handling/interception, async and PTY/non-PTY selection, result/error mapping, bash tool renderer.src/tools/bash-pty-selection.ts—canUseInteractiveBashPtypredicate for choosing the local PTY overlay.src/tools/bash-command-fixup.ts— native-backed conservative cleanup for trailinghead/tailpipes and redundant2>&1.src/tools/bash-interceptor.ts— interceptor rule matching and blocked-command messages.src/exec/bash-executor.ts— non-PTY executor, shell session reuse, cancellation wiring, output sink integration.src/exec/non-interactive-env.ts— non-interactive child-process env defaults (buildNonInteractiveEnv) used by the non-PTY executor.src/tools/bash-interactive.ts— PTY runtime, overlay UI, input normalization, and interactiveTERMsetup.src/session/streaming-output.ts—OutputSink,TailBuffer, truncation/artifact spill, and summary metadata.src/tools/output-meta.ts— truncation metadata shape + notice injection wrapper.src/session/agent-session.ts— session-levelexecuteBash, message recording, abort lifecycle.src/modes/components/bash-execution.ts— interactive!command execution component.src/modes/controllers/command-controller.ts— wiring for interactive!command UI stream/update completion.src/modes/rpc/rpc-mode.ts— RPCbashandabort_bashcommand surface.src/internal-urls/artifact-protocol.ts—artifact://<id>resolution.
This document describes the Python execution stack in packages/coding-agent.
It covers tool behavior, runner lifecycle, environment handling, execution semantics, output rendering, supported magics, and operational failure modes.
Scope and Key Files
- Tool surface:
src/tools/eval.ts - Session/per-call kernel orchestration:
src/eval/py/executor.ts - Subprocess kernel client:
src/eval/py/kernel.ts - Python wrapper / NDJSON server:
src/eval/py/runner.py - Prelude helpers loaded into every kernel:
src/eval/py/prelude.py - Host-side subagent helper bridge:
src/eval/agent-bridge.ts - MIME bundle renderer (text + structured outputs):
src/eval/py/display.ts - Interactive-mode renderer for user-triggered Python runs:
src/modes/components/eval-execution.ts - Runtime/env filtering and Python resolution:
src/eval/py/runtime.ts
What eval’s Python backend is
The eval tool executes one or more Python cells inside a retained python subprocess that speaks NDJSON over stdin/stdout. No Jupyter gateway and no extra pip dependencies are required — a vanilla Python 3.8+ interpreter is enough. Rich display() output (PIL, pandas, plotly, matplotlib figures) keeps working because the wrapper implements MIME-bundle dispatch.
Tool params:
{
cells: Array<{
language: "py" | "js";
code: string;
title?: string;
timeout?: number; // seconds, clamped to 1..3600, default 30. Inactivity budget — see "Cell timeout".
reset?: boolean; // reset this cell's selected runtime before execution
}>;
}The tool is concurrency = "exclusive" for a session, so calls do not overlap.
Kernel lifecycle
Each Python kernel is a single subprocess: <resolved-python> -u <runner.py>. The runner is bundled with the host binary (Bun text import), written to an omp-python-runner cache under the OS temp directory once per script hash, and reused by subsequent spawns.
Kernel startup sequence:
- Availability check (
checkPythonKernelAvailability) — verifies that a Python interpreter resolves and runs. - Spawn
python -u runner.pywith filtered env andcwd. - Send an init request that runs
os.chdir(cwd), injects env entries, and addscwdtosys.path. - Execute
PYTHON_PRELUDE(idempotent — only initializes once per process).
Kernel shutdown:
- Send
{"type": "exit"}over stdin. - Wait for process exit with
SHUTDOWN_GRACE_MSbudget. - Escalate to
SIGTERMand finallySIGKILLif the process does not exit in time.
Wire protocol (NDJSON, host ↔ runner)
One JSON object per line, UTF-8, \n terminated.
Host → runner:
{"id": "<reqId>", "code": "<source>", "silent": false, "storeHistory": true, "cwd": "<optional>", "env": {"KEY": "VAL"}}
{"type": "exit"}Runner → host:
{"type": "started", "id": "<reqId>"}
{"type": "stdout", "id": "<reqId>", "data": "..."}
{"type": "stderr", "id": "<reqId>", "data": "..."}
{"type": "display", "id": "<reqId>", "bundle": {<mime>: <value>}}
{"type": "result", "id": "<reqId>", "bundle": {<mime>: <value>}}
{"type": "error", "id": "<reqId>", "ename": "...", "evalue": "...", "traceback": ["..."]}
{"type": "done", "id": "<reqId>", "status": "ok"|"error", "executionCount": N, "cancelled": false}Status events the prelude emits (e.g. _emit_status("find", count=…)) ship inside display bundles under application/x-omp-status so the existing TUI status renderer keeps working.
Magics
The runner’s source transformer rewrites IPython-style magics to plain Python calls before parsing. Supported set:
| Magic | Effect |
|---|---|
%pip <args> | python -m pip <args> with live streaming output. Newly installed packages are evicted from sys.modules so the next import picks up the fresh install. |
%cd <path> | os.chdir(path) (with ~ expansion); emits status event. |
%pwd | Returns os.getcwd(). |
%ls [path] | Returns sorted(os.listdir(path)). |
%env [KEY[=VAL]] | List, read, or set env vars (matches prelude env() semantics). |
%set_env KEY VALUE | Set os.environ[KEY]. |
%time <expr> / %timeit <expr> | Time the expression; emits status event with elapsed ms. |
%who / %whos | List user-namespace names. |
%reset | Clear user globals and re-inject prelude. |
%load <path> | Read a file into a fresh cell and execute. |
%run <path> | runpy.run_path and merge globals back. |
%%bash / %%sh | Run the cell body via bash/sh. |
%%capture [name] | Run body with stdout/stderr captured into name. |
%%timeit | Time the cell body. |
%%writefile <path> | Write body to file. |
!cmd / var = !cmd | Run command via subprocess shell; returns an SList-style result with .n / .s helpers. |
var = %name args | Assignment forms work for line magics and !cmd. |
Unknown magic names raise NameError: UsageError: ... inside the cell.
Session persistence semantics
python.kernelMode controls retained kernel reuse:
session(default)- Reuses kernel sessions keyed by namespaced eval session id plus normalized cwd and interpreter.
- Multiple owners can share the same retained kernel for that key.
- Calls through the tool are exclusive, so tool invocations do not overlap.
- A dead retained subprocess is replaced before execution.
- If the subprocess dies during execution, it is replaced and the cell is retried once.
per-call- Spawns a fresh subprocess for each request.
- Shuts the subprocess down after the request.
- No cross-call state persistence.
Multi-cell behavior in a single tool call
Python cells run sequentially in the same selected Python kernel instance for that tool call.
If an intermediate cell fails:
- Earlier cell state remains in memory.
- Tool returns a targeted error indicating which cell failed.
- Later cells are not executed.
reset=true is per cell and resets that language runtime before the cell executes.
Environment filtering and runtime resolution
Environment is filtered before launching the runner:
- Allowlist includes core vars like
PATH,HOME, locale vars,VIRTUAL_ENV,PYTHONPATH, etc. - Allow-prefixes:
LC_,XDG_,PI_ - Denylist strips common API keys (OpenAI/Anthropic/Gemini/etc.)
Runtime selection order (skipped entirely when the python.interpreter setting names an explicit executable):
- Active/located venv (
VIRTUAL_ENV, thenCONDA_PREFIX, then<cwd>/.venv,<cwd>/venv) - Managed venv at
~/.omp/python-env pythonorpython3on PATH
When a venv is selected, its bin/Scripts path is prepended to PATH.
The runner additionally receives PYTHONUNBUFFERED=1 and PYTHONIOENCODING=utf-8 so streamed output reaches the host promptly.
Tool availability and mode selection
eval.py / eval.js (both default true) plus optional boolean env flags PI_PY / PI_JS control eval backend exposure:
- Python backend only (
eval.py=true,eval.js=false, orPI_PY=1 PI_JS=0) - JavaScript backend only (
eval.py=false,eval.js=true, orPI_PY=0 PI_JS=1) - both backends (
eval.py=true,eval.js=true, orPI_PY=1 PI_JS=1)
PI_PY and PI_JS use normal boolean flag parsing. Each flag, when set, overrides only its own setting; an unset flag falls back to its setting (eval.py / eval.js, both default true).
If Python preflight fails and eval.js is enabled, eval remains available for js cells; py cells fail with a Python-backend availability error.
Python prelude helpers include agent(prompt, *, agent="task", model=None, label=None, schema=None, handle=False). It synchronously calls the host bridge, runs one subagent through the task executor, and returns the final text. When schema is supplied, the helper parses the subagent’s JSON output and returns the object. When handle=True, it instead returns a DAG node dict ({"text", "output", "handle", "id", "agent"}) whose handle is the spawned agent’s recoverable agent://<id> URI (the parsed object lands under "data" when schema is also set), so a downstream pipeline/parallel stage can reference the transcript by handle instead of re-inlining it.
Execution flow and cancellation/timeout
Cell timeout
Each eval cell timeout is in seconds, defaults to 30, and is clamped to 1..3600. It is a wall-clock budget on the cell’s own work that the watchdog (IdleTimeout, src/eval/idle-timeout.ts) enforces, but it is suspended while a host-side agent()/parallel()/completion() bridge call is in flight: those calls emit synthetic pause/resume timeout-control status events (withBridgeTimeoutPause, src/eval/bridge-timeout.ts) that pause the watchdog entirely and start a fresh timeout window when control returns to the runtime, so a long fanout or a slow completion runs to completion instead of being killed mid-stream. Pause is reference-counted because parallel() can have multiple bridge calls in flight at once.
The pause/resume events are the sole mechanism that suspends the budget. Everything else the cell does — compute, stdout/stderr, log()/phase(), and ordinary (non-agent) tool calls — counts against timeout, so a cell that is not delegating to an agent/completion is bounded by a plain wall-clock timeout. The tool combines the caller abort signal, the session abort signal, and the watchdog’s signal with AbortSignal.any(...); no wall-clock deadline is passed to the backend, so neither runtime arms a competing fixed timer.
Kernel execution cancellation
On abort/timeout:
- The host sends
kill("SIGINT")to the runner subprocess. - The runner’s exec-time signal handler raises
KeyboardInterruptinside the user code. - Result includes
cancelled=true; a kernel timeout is annotated aseval cell timed out after <n>s; kernel interrupted but remains running. Reset the kernel via { reset: true } if state appears corrupted. - Between requests the runner installs
SIG_IGNfor SIGINT so a stray cancel does not tear down the kernel.
If the runner does not emit done within 5s of the interrupt (INTERRUPT_ESCALATION_MS — e.g. stuck in C code holding the GIL), the host shuts the subprocess down (escalating exit → SIGTERM → SIGKILL), the cell is annotated as kernel-killed, and the kernel is recreated on the next call.
stdin behavior
Interactive stdin is not supported. The runner does not forward input() prompts; user code that calls input() blocks until cancellation.
Output capture and rendering
Captured output classes
From runner frames:
stdout/stderr→ plain text chunksdisplay/result→ rich display handling (MIME bundle)error→ traceback textapplication/x-omp-statusMIME insidedisplay→ structured status events
Display MIME precedence:
text/markdowntext/plaintext/html(converted to basic markdown)
Additionally captured as structured outputs:
application/json→ JSON tree dataimage/png/image/jpeg→ image payloadsapplication/x-omp-status→ status events
Matplotlib
The runner sets MPLBACKEND=Agg as an environ default so figures render off-screen. After every cell, pyplot.get_fignums() is iterated; each figure is saved to PNG, emitted as an image/png display, and closed.
Storage and truncation
Output is streamed through OutputSink and may be persisted to artifact storage. Tool results can include truncation metadata and artifact://<id> for full output recovery.
Renderer behavior
- Tool renderer (
eval-render.ts, re-exported fromeval.ts):- shows code-cell blocks with per-cell status
- collapsed preview defaults to 10 lines
- supports expanded mode for all output retained in the tool result
- Interactive renderer (
eval-execution.ts):- used for user-triggered Python execution in TUI
- collapsed preview defaults to 20 lines
- clamps very long individual lines to 4000 chars for display safety
- shows cancellation/error/truncation notices
Operational troubleshooting
- Python backend not available — Check
eval.py,PI_PY, and thatpython/python3is on PATH. If preflight fails andeval.jsis enabled, use ajscell. - No Python on PATH — Install a system Python 3.8+ or place a venv at
~/.omp/python-env.omp setup python --checkreports the resolved interpreter. - Execution hangs then times out — Increase tool
timeout(max 3600s) if workload is legitimate. For stuck native code, cancellation triggersSIGINTfirst then escalates; the session restarts on the next request. - stdin/input prompts in Python code —
input()is not supported; pass data programmatically. - Working directory errors — Tool validates
cwdexists and is a directory before execution.
Relevant environment variables
PI_PY/PI_JS— eval backend exposure overridesPI_PYTHON_SKIP_CHECK=1— bypass Python preflight/warm checksPI_PYTHON_INTEGRATION=1— enable gated integration tests that spawn a real PythonPI_PYTHON_IPC_TRACE=1— log NDJSON frames exchanged with the runner subprocess
This document explains how preview/apply workflows are modeled in coding-agent and how built-in or custom tools can participate via the pending-invoker registry and pushPendingAction. (Pending previews live in a separate non-forcing registry inside ToolChoiceQueue; only genuine hard forces use the consuming directive queue.)
Scope and key files
src/tools/resolve.tssrc/tools/ast-edit.tssrc/extensibility/custom-tools/types.tssrc/extensibility/custom-tools/loader.tssrc/sdk.ts
What resolve does
resolve is a hidden tool that finalizes a pending preview action.
action: "apply"executes the queued action’sapply(reason, extra)callback and returns that result with resolve metadata.action: "discard"invokesreject(reason, extra)if provided; otherwise returnsDiscarded: <label>. Reason: <reason>.extrais optional free-form metadata. Queue handlers receive it; producers decide whether it has meaning.
If no pending action exists, resolve(action="apply") fails with:
No pending action to resolve. Nothing to apply or discard.
resolve(action="discard") with no pending action succeeds instead, returning Nothing to discard; no pending action remains. — the desired end-state (no staged change) already holds.
Pending previews use a non-forcing soft tool requirement
Preview producers call queueResolveHandler(...), which registers a non-forcing
pending invoker on the session (a stack keyed by a unique
pending-action:<tool>:<seq> id — never clobbered by label). It does NOT force
tool_choice and does NOT inject a steering reminder.
While a preview is pending, the session’s getToolChoice callback
(nextToolChoiceDirective) returns a SoftToolRequirement (toolName: "resolve")
carrying the resolve reminder, as a non-consuming peek. The agent runtime owns the
lifecycle: it injects the reminder once, runs with tool_choice unchanged, and
escalates to a one-turn forced resolve choice ONLY if the model fails to call
resolve that turn (skipping any detour tool batch first). A model that resolves
on the reminder pays no message-cache invalidation — the previous design forced
tool_choice on every preview, busting the provider message cache twice per cycle.
Runtime behavior:
- the pending invoker owns the
apply/rejectcallbacks, resolvedispatches viapeekQueueInvoker() ?? peekPendingInvoker() ?? peekStandingResolveHandler(),- a genuine hard forced tool choice (dequeued first by
nextToolChoiceDirective) preempts the soft requirement, - if an apply callback throws, the helper re-registers the same pending invoker (same id) so the preview can still be discarded or retried.
resolve also checks a standing resolve handler after the invokers; this is used by long-lived approval flows that are not ordinary preview tool calls.
Multiple pending previews stack as unique-keyed invokers and resolve independently (head-first), not through forced tool-choice ordering.
Built-in producer example (ast_edit)
ast_edit previews structural replacements first. When the preview has replacements and is not applied yet, it queues a resolve handler that contains:
- label (human-readable summary)
sourceToolName(ast_edit)apply(reason: string, extra?: Record<string, unknown>)callback that reruns AST edit withdryRun: false
resolve(action="apply", reason="...") passes both reason and extra into this callback, but ast_edit’s apply ignores both — its parameter is _reason, and the rerun is independent of reason/extra.
Custom tools: pushPendingAction
Custom tools can register resolve-compatible pending actions through CustomToolAPI.pushPendingAction(...). The custom tool loader forwards these actions to queueResolveHandler(...) when that hook is available.
CustomToolPendingAction:
label: string(required)apply(reason: string): Promise<AgentToolResult<unknown>>(required) — invoked on apply;reasonis the string passed toresolvereject?(reason: string): Promise<AgentToolResult<unknown> | undefined>(optional) — invoked on discard; return value replaces the default “Discarded” message if provideddetails?: unknownexists on the public custom-tool type but is not currently forwarded by the loader into resolve metadatasourceToolName?: string(optional, defaults to"custom_tool")
Minimal usage example
import type { CustomToolFactory } from "@oh-my-pi/pi-coding-agent";
const factory: CustomToolFactory = (pi) => ({
name: "batch_rename_preview",
label: "Batch Rename Preview",
description: "Previews renames and defers commit to resolve",
parameters: pi.zod.object({
files: pi.zod.array(pi.zod.string()),
}),
async execute(_toolCallId, params) {
const previewSummary = `Prepared rename plan for ${params.files.length} files`;
pi.pushPendingAction({
label: `Batch rename: ${params.files.length} files`,
sourceToolName: "batch_rename_preview",
apply: async (reason) => {
// apply writes here
return {
content: [
{ type: "text", text: `Applied batch rename. Reason: ${reason}` },
],
};
},
reject: async (reason) => {
// optional: cleanup or notify on discard
return {
content: [
{ type: "text", text: `Discarded batch rename. Reason: ${reason}` },
],
};
},
});
return {
content: [
{
type: "text",
text: `${previewSummary}. Call resolve to apply or discard.`,
},
],
};
},
});
export default factory;Runtime availability and failures
pushPendingAction is wired by the custom tool loader through the active session’s resolve queue hook.
If the runtime did not provide the resolve queue hook, pushPendingAction throws:
Pending action store unavailable for custom tools in this runtime.
Tool-choice behavior
When queueResolveHandler(...) registers a preview, the agent runtime forces a one-shot resolve tool choice so pending previews are explicitly finalized before normal tool flow continues.
Developer guidance
- Use pending actions only for destructive or high-impact operations that should support explicit apply/discard.
- Keep
labelconcise and specific; it is shown in resolve renderer output. - Ensure
apply(reason)is deterministic and idempotent enough for one-shot execution;reasonis informational and should not change behavior. - Implement
reject(reason)when the discard needs cleanup (temp state, locks, notifications); omit it for stateless previews where the default message suffices. - If your tool can stage multiple previews, remember they stack as unique-keyed pending invokers (resolved head-first), not a forced tool-choice sequence and not a separate
pushPendingActionstack.
This document describes current .ipynb handling in coding-agent and its relationship to the kernel-backed Python runtime.
The critical distinction: notebook support is file conversion/editing, not notebook execution. .ipynb files are exposed as editable cell-marked text through read and the edit pipeline; no notebook-specific tool starts or talks to a Python kernel.
Implementation files
src/edit/notebook.tssrc/edit/read-file.tssrc/tools/read.tssrc/tools/eval.tssrc/eval/py/executor.tssrc/eval/py/kernel.tssrc/session/streaming-output.ts
1) Runtime boundary: editing vs executing
.ipynb file conversion (src/edit/notebook.ts)
readtreats.ipynbfiles as notebooks unless the selector is:raw.- The default notebook view is editable text with markers:
# %% [code] cell:N# %% [markdown] cell:N# %% [raw] cell:N
- Line selectors and multi-range selectors operate on that virtual text.
- Edit/write paths round-trip virtual text back to notebook JSON through
serializeEditedNotebookText(...). - Existing notebook metadata is preserved when a marker references an existing
cell:N; new cells get fresh empty metadata. - Missing notebooks edited through this path start from an empty nbformat 4.5 notebook.
No kernel lifecycle exists in this path:
- no kernel session ID
- no code execution
- no stream chunks from Python
- no rich display capture
- no output artifact pipeline from execution
Kernel-backed execution path (src/tools/eval.ts + src/eval/py/*)
When the agent needs to run cell-style Python code (sequential cells, persistent state, rich displays), that goes through the eval tool with per-cell language: "py", not through notebook file handling.
That path is where Python subprocess lifecycle, reset/cancel behavior, chunk streaming, rich displays, and output artifact truncation live.
2) Notebook cell handling semantics
Source normalization
Notebook JSON source is converted to virtual text by joining source arrays. When virtual text is serialized back, cell source is split with newline preservation:
- each line ending in
\nstays as a separate source entry with the newline - a final non-newline-terminated line is stored without forcing a trailing newline
- empty content becomes an empty
sourcearray
This mirrors notebook JSON conventions and avoids accidental line concatenation on later edits.
Marker parsing and cell preservation
- The first representation line must be a marker; text before the first marker, including a blank line, is rejected.
- Markers must match
# %% [code|markdown|raw]with optionalcell:N. - If
cell:Npoints at an unused existing cell, that cell is cloned, itscell_typeandsourceare updated, and unrelated metadata is preserved. - If no valid unused original index is present, a new cell is created.
- Code cells ensure
execution_countexists andoutputsexists. - Markdown/raw cells remove
execution_countandoutputs.
Error surfaces
Hard failures are thrown for:
- missing notebook on read
- invalid JSON
- missing/non-array
cells - invalid cell objects or cell types
- invalid editable representation (for example, text before the first cell marker)
These surface through the caller (read, edit, or write) as normal tool errors.
3) Kernel session semantics (where they actually exist)
Kernel semantics are implemented in executePython / PythonKernel and apply to the Python backend of the eval tool.
Modes
PythonKernelMode:
session(default)- kernels are cached by
(session id, cwd, interpreter) - multiple owners can share a retained kernel for the same key
- execution is serialized by the tool’s exclusive concurrency and backend execution path
- dead kernels are replaced before execution
- kernels are cached by
per-call- creates a subprocess for the request
- executes
- always shuts down the subprocess in
finally
Reset behavior
Each eval cell has its own optional reset flag. reset: true resets the selected Python session before that cell executes; it is not a top-level tool parameter.
Kernel death / restart / retry
In session mode:
- if the retained subprocess is not alive before execution, it is replaced
- if execution fails because the subprocess died, the kernel is replaced and the code is retried once
- concurrent resets for the same session key coalesce: a reset already in flight is awaited instead of starting another, and runs queued behind it proceed on the freshly-restarted kernel
4) Environment/session variable injection
Kernel startup and per-execution environment patching can receive:
PI_SESSION_FILEPI_ARTIFACTS_DIRPI_TOOL_BRIDGE_URLPI_TOOL_BRIDGE_TOKENPI_TOOL_BRIDGE_SESSIONPI_EVAL_LOCAL_ROOTS
The runner initializes process state so code executes in the requested cwd, managed env entries are reflected in os.environ, and cwd is available on sys.path.
5) Streaming/chunk and display handling (kernel-backed path)
The Python backend uses an NDJSON subprocess runner. The host processes frames per execution:
stdout/stderr-> text chunks toonChunkdisplay/result-> MIME bundle renderingerror-> traceback text and structured error metadatadone-> final status, execution count, cancellation state
Display text MIME precedence:
text/markdowntext/plain- converted
text/html
Structured outputs captured separately include:
application/json-> JSON display outputimage/png/image/jpeg-> image outputapplication/x-omp-status-> status event
Cancellation/timeout:
- abort/timeout sends
SIGINTto the runner - if the runner does not settle after the interrupt grace window, shutdown escalates and the kernel is recreated on the next call
- timeout output is annotated with a timeout message
6) Truncation and artifact behavior
OutputSink in src/session/streaming-output.ts is used by kernel execution paths:
- sanitizes every chunk
- tracks total/output lines and bytes
- optionally spills full output to an artifact file
- keeps a UTF-8-safe in-memory tail buffer when output exceeds the configured threshold
eval converts this metadata into result truncation notices and TUI warnings.
Notebook file conversion does not use OutputSink; it has no stream/artifact truncation pipeline because it does not execute code.
7) Renderer assumptions and formatting
Read/edit notebook representation
Notebook files are rendered to the model as text. The visible cell markers are part of the editable representation, not comments that are ignored during serialization.
Python renderer (for actual execution output)
Kernel-backed execution rendering expects:
- per-cell status transitions (
pending/running/complete/error) - optional structured status events
- optional JSON output trees
- image outputs
- truncation warnings + optional
artifact://<id>pointer
This renderer behavior is unrelated to notebook JSON editing except that both reuse shared TUI primitives.
8) Practical workflow
If a workflow needs both notebook mutation and execution:
- read or edit the
.ipynbfile through the normal file tools - copy the desired cell source into
evalcells withlanguage: "py"to execute it - write resulting source changes back to the notebook if needed
Current implementation does not provide a single tool that both mutates .ipynb and executes notebook cells through kernel context.
Environment & Secrets
This reference is derived from current code paths in:
packages/coding-agent/src/**packages/ai/src/**(provider/auth resolution used by coding-agent)packages/utils/src/**andpackages/tui/src/**where those vars directly affect coding-agent runtime
It documents only active behavior.
Resolution model and precedence
Most runtime lookups use $env from @oh-my-pi/pi-utils (packages/utils/src/env.ts).
$env loading order:
- Existing process environment (
Bun.env) - Project
.env($PWD/.env) for keys not already set - Agent
.env(~/.omp/agent/.env, respectingPI_CONFIG_DIR/PI_CODING_AGENT_DIR) for keys not already set - Config-root
.env(~/.omp/.env, respectingPI_CONFIG_DIR) for keys not already set - Home
.env(~/.env) for keys not already set
Additional rule inside each .env file: OMP_* keys are mirrored to PI_* keys in that parsed file.
1) Model/provider authentication
These are consumed via getEnvApiKey() (packages/ai/src/stream.ts) unless noted otherwise.
Core provider credentials
| Variable | Used for | Required when | Notes / precedence |
|---|---|---|---|
ANTHROPIC_OAUTH_TOKEN | Anthropic API auth | Using Anthropic with OAuth token auth | Takes precedence over ANTHROPIC_API_KEY for provider auth resolution |
ANTHROPIC_API_KEY | Anthropic API auth | Using Anthropic without OAuth token | Fallback after ANTHROPIC_OAUTH_TOKEN |
ANTHROPIC_FOUNDRY_API_KEY | Anthropic via Azure Foundry / enterprise gateway | CLAUDE_CODE_USE_FOUNDRY enabled | Takes precedence over ANTHROPIC_OAUTH_TOKEN and ANTHROPIC_API_KEY when Foundry mode is enabled |
OPENAI_API_KEY | OpenAI auth | Using OpenAI-family providers without explicit apiKey argument | Used by OpenAI Completions/Responses providers |
GEMINI_API_KEY | Google Gemini auth | Using google provider models | Primary key for Gemini provider mapping |
GOOGLE_API_KEY | Gemini image tool auth fallback | Using gemini_image tool without GEMINI_API_KEY | Used by coding-agent image tool fallback path |
GROQ_API_KEY | Groq auth | Using Groq models | |
CEREBRAS_API_KEY | Cerebras auth | Using Cerebras models | |
FIREWORKS_API_KEY | Fireworks auth | Using Fireworks models | |
FIREPASS_API_KEY | Fire Pass auth | Using Fire Pass models | |
TOGETHER_API_KEY | Together auth | Using together provider | |
AIMLAPI_API_KEY | AIML API auth | Using aimlapi provider | OpenAI-compatible AIML API endpoint at https://api.aimlapi.com/v1 |
HUGGINGFACE_HUB_TOKEN | Hugging Face auth | Using huggingface provider | Primary Hugging Face token env var |
HF_TOKEN | Hugging Face auth | Using huggingface provider | Fallback when HUGGINGFACE_HUB_TOKEN is unset |
SYNTHETIC_API_KEY | Synthetic auth | Using Synthetic models | |
NVIDIA_API_KEY | NVIDIA auth | Using nvidia provider | |
NANO_GPT_API_KEY | NanoGPT auth | Using nanogpt provider | |
VENICE_API_KEY | Venice auth | Using venice provider | |
LITELLM_API_KEY | LiteLLM auth | Using litellm provider | OpenAI-compatible LiteLLM proxy key |
LM_STUDIO_API_KEY | LM Studio auth (optional) | Using lm-studio provider with authenticated hosts | Local LM Studio usually runs without auth; any non-empty token works when a key is required |
OLLAMA_API_KEY | Ollama auth (optional) | Using ollama provider with authenticated hosts | Local Ollama usually runs without auth; any non-empty token works when a key is required |
LLAMA_CPP_API_KEY | llama.cpp auth (optional) | Using llama.cpp provider with authenticated hosts | Local llama.cpp usually runs without auth; any non-empty token works when a key is configured |
XIAOMI_API_KEY | Xiaomi MiMo auth | Using xiaomi provider | |
XIAOMI_TOKEN_PLAN_AMS_API_KEY | Xiaomi MiMo Token Plan auth (AMS) | Using xiaomi-token-plan-ams provider | |
XIAOMI_TOKEN_PLAN_CN_API_KEY | Xiaomi MiMo Token Plan auth (CN) | Using xiaomi-token-plan-cn provider | |
XIAOMI_TOKEN_PLAN_SGP_API_KEY | Xiaomi MiMo Token Plan auth (SGP) | Using xiaomi-token-plan-sgp provider | |
MOONSHOT_API_KEY | Moonshot auth | Using moonshot provider | |
XAI_API_KEY | xAI auth | Using xAI models or as fallback for xai-oauth | |
XAI_OAUTH_TOKEN | xAI OAuth/SuperGrok auth | Using xai-oauth provider | Takes precedence over XAI_API_KEY for xai-oauth |
OPENROUTER_API_KEY | OpenRouter auth | Using OpenRouter models | Also used by image tool when preferred/auto provider is OpenRouter |
MISTRAL_API_KEY | Mistral auth | Using Mistral models | |
ZAI_API_KEY | z.ai auth | Using z.ai models | Also used by z.ai web search provider |
ZHIPU_API_KEY | Zhipu Coding Plan auth | Using zhipu-coding-plan provider | |
UMANS_AI_CODING_PLAN_API_KEY | Umans AI Coding Plan auth | Using umans provider | |
MINIMAX_API_KEY | MiniMax auth | Using minimax provider | |
MINIMAX_CODE_API_KEY | MiniMax Code auth | Using minimax-code provider | |
MINIMAX_CODE_CN_API_KEY | MiniMax Code CN auth | Using minimax-code-cn provider | |
OPENCODE_API_KEY | OpenCode auth | Using opencode-go / opencode-zen models | |
QIANFAN_API_KEY | Qianfan auth | Using qianfan provider | |
QWEN_OAUTH_TOKEN | Qwen Portal auth | Using qwen-portal with OAuth token | Takes precedence over QWEN_PORTAL_API_KEY |
QWEN_PORTAL_API_KEY | Qwen Portal auth | Using qwen-portal with API key | Fallback after QWEN_OAUTH_TOKEN |
ZENMUX_API_KEY | ZenMux auth | Using zenmux provider | Used for ZenMux OpenAI and Anthropic-compatible routes |
VLLM_API_KEY | vLLM auth/discovery opt-in | Using vllm provider (local OpenAI-compatible servers) | Any non-empty value works for no-auth local servers |
CURSOR_ACCESS_TOKEN | Cursor provider auth | Using Cursor provider | |
AI_GATEWAY_API_KEY | Vercel AI Gateway auth | Using vercel-ai-gateway provider | |
CLOUDFLARE_AI_GATEWAY_API_KEY | Cloudflare AI Gateway auth | Using cloudflare-ai-gateway provider | Base URL must be configured as https://gateway.ai.cloudflare.com/v1/<account>/<gateway>/anthropic |
ALIBABA_CODING_PLAN_API_KEY | Alibaba Coding Plan auth | Using alibaba-coding-plan provider | |
DEEPSEEK_API_KEY | DeepSeek auth | Using DeepSeek models | |
KILO_API_KEY | Kilo auth | Using Kilo models | |
OLLAMA_CLOUD_API_KEY | Ollama Cloud auth | Using ollama-cloud provider | |
WAFER_SERVERLESS_API_KEY | Wafer Serverless auth | Using wafer-serverless provider | Pay-as-you-go Wafer SKU; validated against https://pass.wafer.ai/v1/models |
GITLAB_TOKEN | GitLab Duo auth | Using gitlab-duo provider |
GitHub/Copilot tokens
| Variable | Used for | Notes |
|---|---|---|
COPILOT_GITHUB_TOKEN | GitHub Copilot provider auth | Generic GitHub tokens are not used here |
GH_TOKEN | GitHub API auth in web scraper | Web scraper fallback after GITHUB_TOKEN |
GITHUB_TOKEN | GitHub API auth in web scraper | Web scraper checks this before GH_TOKEN |
Auth broker / auth gateway (remote credential vault)
When the broker is enabled, the local SQLite credential store is bypassed and all OAuth refresh / access tokens live on the broker host. See auth-broker-gateway.md for the full protocol, CLI surface, and 5-min/15-s usage cache layering.
| Variable | Used for | Required when | Notes / precedence |
|---|---|---|---|
OMP_AUTH_BROKER_URL | Base URL of the remote auth-broker (e.g. https://broker.tailnet:8765); selects broker mode | Resolving credentials through a broker; also required by omp auth-gateway serve (the gateway is itself a broker client) | Wins over auth.broker.url in config.yml. When set with no resolvable token, resolveAuthBrokerConfig() hard-errors instead of falling back to local SQLite. |
OMP_AUTH_BROKER_TOKEN | Bearer token sent on every broker endpoint except /v1/healthz | OMP_AUTH_BROKER_URL is set and no token is available from auth.broker.token or <config-dir>/auth-broker.token | Resolution: this env → auth.broker.token ($ENV_NAME indirection supported) → <config-dir>/auth-broker.token (mode 0600). <config-dir> is ~/.omp/ (respecting PI_CONFIG_DIR). |
OMP_AUTH_BROKER_SNAPSHOT_TTL_MS | Freshness window for the encrypted local broker snapshot cache | Optional in broker mode | Default 3600000 (1 h). Freshness is based on broker snapshot.generatedAt; 0 disables cache reads/writes and forces the old blocking fetch every startup. |
OMP_AUTH_BROKER_SNAPSHOT_CACHE | Path to the encrypted local broker snapshot cache | Optional in broker mode | Defaults to ~/.omp/cache/auth-broker-snapshot.enc (or XDG cache equivalent). Useful for tests, ephemeral hosts, or relocating the 0600 cache file. |
The gateway has no dedicated env vars — it inherits OMP_AUTH_BROKER_*. Its own inbound bearer token lives at <config-dir>/auth-gateway.token and is managed via omp auth-gateway token.
2) Provider-specific runtime configuration
Anthropic Foundry Gateway (Azure / enterprise proxy)
When CLAUDE_CODE_USE_FOUNDRY is enabled, Anthropic requests switch to Foundry mode:
- Base URL resolves from
FOUNDRY_BASE_URL(fallback remains model/default base URL if unset). - API key resolution for provider
anthropicbecomes:ANTHROPIC_FOUNDRY_API_KEY→ANTHROPIC_OAUTH_TOKEN→ANTHROPIC_API_KEY. ANTHROPIC_CUSTOM_HEADERSis parsed as comma/newline-separatedkey: valuepairs and merged into request headers. They are also forwarded whenANTHROPIC_BASE_URLpoints to a non-Anthropic host (e.g. a corporate API gateway), so enterprise gateways requiring proprietary auth headers work without enabling Foundry mode.- TLS client/server material can be injected from env values:
NODE_EXTRA_CA_CERTS,CLAUDE_CODE_CLIENT_CERT,CLAUDE_CODE_CLIENT_KEY. Each accepts either:- a filesystem path to PEM content, or
- inline PEM (including escaped
\nsequences).
| Variable | Value type | Behavior |
|---|---|---|
CLAUDE_CODE_USE_FOUNDRY | Boolean-like string (1, true, yes, on) | Enables Foundry mode for Anthropic provider |
FOUNDRY_BASE_URL | URL string | Anthropic endpoint base URL in Foundry mode |
ANTHROPIC_FOUNDRY_API_KEY | Token string | Used for Authorization: Bearer <token> |
ANTHROPIC_CUSTOM_HEADERS | Header list string | Extra headers; format header-a: value, header-b: value or newline-separated. Also forwarded outside Foundry whenever ANTHROPIC_BASE_URL is non-Anthropic. |
NODE_EXTRA_CA_CERTS | PEM path or inline PEM | Extra CA chain for server certificate validation |
CLAUDE_CODE_CLIENT_CERT | PEM path or inline PEM | mTLS client certificate |
CLAUDE_CODE_CLIENT_KEY | PEM path or inline PEM | mTLS client private key (must be paired with cert) |
Amazon Bedrock
| Variable | Default / behavior |
|---|---|
AWS_REGION | Primary region source |
AWS_DEFAULT_REGION | Fallback if AWS_REGION unset |
AWS_PROFILE | Enables named profile auth path |
AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY | Enables IAM key auth path |
AWS_BEARER_TOKEN_BEDROCK | Highest-precedence bearer token auth path; skips AWS profile/credential-chain lookup when set |
AWS_CONTAINER_CREDENTIALS_RELATIVE_URI / AWS_CONTAINER_CREDENTIALS_FULL_URI | Marks Bedrock as available in provider detection (credential resolution itself covers env keys, profiles/SSO/credential_process, then IMDSv2) |
AWS_WEB_IDENTITY_TOKEN_FILE + AWS_ROLE_ARN | Marks Bedrock as available in provider detection (same caveat as the ECS variables above) |
AWS_BEDROCK_SKIP_AUTH | If 1, injects dummy credentials (proxy/non-auth scenarios) |
HTTPS_PROXY / HTTP_PROXY | Honored via Bun’s native fetch proxy support (the provider no longer ships an AWS SDK / proxy-agent transport) |
NO_PROXY | Excludes matching hosts from Bun’s native proxy routing |
Region fallback in provider code: options.region → AWS_REGION → AWS_DEFAULT_REGION → us-east-1.
Azure OpenAI Responses
| Variable | Default / behavior |
|---|---|
AZURE_OPENAI_API_KEY | Required unless API key passed as option |
AZURE_OPENAI_API_VERSION | Default v1 |
AZURE_OPENAI_BASE_URL | Direct base URL override |
AZURE_OPENAI_RESOURCE_NAME | Used to construct base URL: https://<resource>.openai.azure.com/openai/v1 |
AZURE_OPENAI_DEPLOYMENT_NAME_MAP | Optional mapping string: modelId=deploymentName,model2=deployment2 |
Base URL resolution: option azureBaseUrl → env AZURE_OPENAI_BASE_URL → option/env resource name → model.baseUrl.
Google Vertex AI
| Variable | Required? | Notes |
|---|---|---|
GOOGLE_CLOUD_PROJECT | Yes (unless passed in options) | Primary project ID source |
GCP_PROJECT | Fallback | Alternate project ID source |
GCLOUD_PROJECT | Fallback | Alternate project ID source |
GOOGLE_CLOUD_PROJECT_ID | OAuth login helper only | Used by Gemini CLI OAuth project discovery |
GOOGLE_VERTEX_LOCATION | Yes (unless passed in options) | Primary Vertex location source |
GOOGLE_CLOUD_LOCATION | Fallback | Alternate Vertex location source |
VERTEX_LOCATION | Fallback | Alternate Vertex location source |
GOOGLE_CLOUD_API_KEY | Conditional | Direct Vertex API-key auth; otherwise ADC fallback can authenticate when project and location are set |
GOOGLE_APPLICATION_CREDENTIALS | Conditional | If set, file must exist; otherwise ADC fallback path is checked (~/.config/gcloud/application_default_credentials.json) |
Kimi
| Variable | Default / behavior |
|---|---|
KIMI_CODE_OAUTH_HOST | Primary OAuth host override |
KIMI_OAUTH_HOST | Fallback OAuth host override |
KIMI_CODE_BASE_URL | Overrides Kimi usage endpoint base URL (usage/kimi.ts) |
OAuth host chain: KIMI_CODE_OAUTH_HOST → KIMI_OAUTH_HOST → https://auth.kimi.com.
Gemini CLI compatibility
| Variable | Default / behavior |
|---|---|
PI_AI_GEMINI_CLI_VERSION | Overrides Gemini CLI user-agent version tag (0.35.3 if unset) |
OpenAI Codex responses (feature/debug controls)
| Variable | Behavior |
|---|---|
PI_CODEX_DEBUG | 1/true enables Codex provider debug logging |
PI_CODEX_WEBSOCKET | 1/true enables websocket transport preference |
PI_OPENAI_STATEFUL | Overrides the stateful-chaining default for the platform OpenAI Responses API (previous_response_id, forces store: true): on by default against api.openai.com, off elsewhere |
PI_CODEX_WEBSOCKET_IDLE_TIMEOUT_MS | Positive integer override (default 300000) |
PI_CODEX_WEBSOCKET_RETRY_BUDGET | Non-negative integer override (default 5) |
PI_CODEX_WEBSOCKET_RETRY_DELAY_MS | Positive integer base backoff override (default 500) |
PI_OPENAI_STREAM_FIRST_EVENT_TIMEOUT_MS | Positive integer OpenAI first-event timeout override |
PI_OPENAI_STREAM_IDLE_TIMEOUT_MS | Positive integer OpenAI stream idle timeout override |
Cursor provider debug
| Variable | Behavior |
|---|---|
DEBUG_CURSOR | Enables provider debug logs; 2/verbose for detailed payload snippets |
DEBUG_CURSOR_LOG | Optional file path for JSONL debug log output |
Prompt cache compatibility switch
| Variable | Behavior |
|---|---|
PI_CACHE_RETENTION | If long, enables long retention where supported (anthropic, openai-responses, Bedrock retention resolution) |
3) Web search subsystem
Search provider credentials
| Variable | Used by |
|---|---|
EXA_API_KEY | Exa search provider and Exa MCP tools |
BRAVE_API_KEY | Brave search provider |
PERPLEXITY_API_KEY | Perplexity search provider API-key mode |
PERPLEXITY_COOKIES | Perplexity cookie-auth search mode |
TAVILY_API_KEY | Tavily search provider |
ZAI_API_KEY | z.ai search provider (also checks stored OAuth in agent.db) |
OPENAI_API_KEY / Codex OAuth in DB | Codex search provider availability/auth |
PI_CODEX_WEB_SEARCH_MODEL | Codex search provider model override |
MOONSHOT_SEARCH_API_KEY / KIMI_SEARCH_API_KEY | Kimi/Moonshot search provider env auth |
MOONSHOT_SEARCH_BASE_URL / KIMI_SEARCH_BASE_URL | Kimi/Moonshot search endpoint override |
KAGI_API_KEY | Kagi search provider |
JINA_API_KEY | Jina search provider |
PARALLEL_API_KEY | Parallel search provider |
SEARXNG_ENDPOINT, SEARXNG_TOKEN | SearXNG endpoint and optional bearer token |
SEARXNG_BASIC_USERNAME, SEARXNG_BASIC_PASSWORD | SearXNG HTTP Basic Auth credentials |
SearXNG also reads the equivalent searxng.endpoint, searxng.token, searxng.basicUsername, and searxng.basicPassword settings from ~/.omp/agent/config.yml; environment variables are fallbacks.
Anthropic web search auth chain
searchAnthropic() resolves credentials in this order:
ANTHROPIC_SEARCH_API_KEYauthStorage.getApiKey("anthropic")fallback credentials (runtime/config overrides, stored API-key credentials, stored OAuth credentials, then generic Anthropic env fallback:ANTHROPIC_FOUNDRY_API_KEYin Foundry mode, otherwiseANTHROPIC_OAUTH_TOKEN/ANTHROPIC_API_KEY)
For either credential path, base URL resolution is:
ANTHROPIC_SEARCH_BASE_URLFOUNDRY_BASE_URLwhenCLAUDE_CODE_USE_FOUNDRYis enabledANTHROPIC_BASE_URLhttps://api.anthropic.com
Related vars:
| Variable | Default / behavior |
|---|---|
ANTHROPIC_SEARCH_API_KEY | API key used exclusively for the Anthropic web search provider. Highest-priority search auth; overrides ANTHROPIC_API_KEY / OAuth / Foundry for search calls without affecting chat completions. |
ANTHROPIC_SEARCH_BASE_URL | Base URL used exclusively for the Anthropic web search provider. Applied to either ANTHROPIC_SEARCH_API_KEY or fallback Anthropic credentials; overrides ANTHROPIC_BASE_URL (and FOUNDRY_BASE_URL in Foundry mode) for search calls. |
ANTHROPIC_SEARCH_MODEL | Search model override. Defaults to claude-haiku-4-5. |
ANTHROPIC_BASE_URL | Generic fallback base URL for Anthropic requests when no search-specific base URL is set. |
Use ANTHROPIC_SEARCH_BASE_URL (optionally with ANTHROPIC_SEARCH_API_KEY) to keep chat routed through an enterprise gateway (ANTHROPIC_BASE_URL or CLAUDE_CODE_USE_FOUNDRY=true) while pointing web search at a direct Anthropic endpoint, or vice versa.
Perplexity OAuth flow behavior flag
| Variable | Behavior |
|---|---|
PI_AUTH_NO_BORROW | If set, disables macOS native-app token borrowing path in Perplexity login flow |
4) Python tooling and kernel runtime
| Variable | Default / behavior |
|---|---|
PI_PY | Boolean-like override for the Python eval backend: truthy (1/true/yes/on) enables, any other value disables; unset defers to the eval.py setting (default enabled) |
PI_JS | Same boolean-like override for the JavaScript eval backend; unset defers to the eval.js setting (default enabled) |
PI_PYTHON_SKIP_CHECK | If 1, skips Python interpreter availability checks (subprocess runner still starts on demand) |
PI_PYTHON_INTEGRATION | If 1, opts gated integration tests in (e.g. python-runner.integration.test.ts) into running against real Python |
PI_PYTHON_IPC_TRACE | If 1, logs NDJSON frames exchanged with the Python runner subprocess |
VIRTUAL_ENV | Highest-priority venv path for Python runtime resolution |
Extra conditional behavior:
- If
BUN_ENV=testorNODE_ENV=test, Python availability checks are treated as OK and warming is skipped. - Python env filtering denies common API keys and allows safe base vars +
LC_,XDG_,PI_prefixes.
5) Agent/runtime behavior toggles
| Variable | Default / behavior |
|---|---|
PI_SMOL_MODEL | Ephemeral model-role override for smol (CLI --smol takes precedence) |
PI_SLOW_MODEL | Ephemeral model-role override for slow (CLI --slow takes precedence) |
PI_PLAN_MODEL | Ephemeral model-role override for plan (CLI --plan takes precedence) |
PI_NO_TITLE | If set (any non-empty value), disables auto session title generation on first user message |
PI_TINY_DEVICE | ONNX execution provider for local tiny models; overrides the providers.tinyModelDevice setting (default: CPU; supports cpu, gpu, metal/webgpu, auto, cuda, dml, coreml, wasm, webnn, webnn-gpu, webnn-cpu, webnn-npu) |
PI_TINY_DTYPE | ONNX quantization/precision for local tiny models; overrides the providers.tinyModelDtype setting (default: each model’s shipped dtype, currently q4; supports auto, fp32, fp16, q8, int8, uint8, q4, bnb4, q4f16, q2, q2f16, q1, q1f16) |
PI_NO_INTERLEAVED_THINKING | If 1, disables Anthropic interleaved thinking budget behavior and uses output-token inflation for older thinking mode |
NULL_PROMPT | If true, system prompt builder returns empty string |
PI_BLOCKED_AGENT | Blocks a specific subagent type in task tool |
PI_SUBPROCESS_CMD | Overrides subagent spawn command (omp / omp.cmd resolution bypass) |
PI_TASK_MAX_OUTPUT_BYTES | Max captured output bytes per subagent (default 500000) |
PI_TASK_MAX_OUTPUT_LINES | Max captured output lines per subagent (default 5000) |
PI_TIMING | If set (any non-empty value), prints a hierarchical timing-span tree to stderr via logger.printTimings(). In interactive mode the tree prints once the agent is ready (before the TUI starts); in print mode it prints after the whole prompt batch completes. Print-mode prompts are wrapped in print:prompt:initial / print:prompt:next spans so each user message shows up as its own row. PI_TIMING=x exits the process with code 0 right after printing in interactive mode (use to measure cold startup only). PI_TIMING=full lists every module-load entry instead of just the top N. |
PI_DEBUG_STARTUP | If set (any non-empty value), streams one synchronous [startup] <phase>:start / :done marker line to stderr as each startup phase begins/ends — including command-module imports (cli:load:<name>) and the native addon extraction/dlopen (native:*). Unlike PI_TIMING (which prints only once startup completes), the markers survive a hard hang: the last line on stderr names the phase the process is stuck in. Combine with PI_TIMING freely; markers and the span tree share the same phase names. |
PI_PACKAGE_DIR | Overrides package asset base dir resolution (docs/, examples/, CHANGELOG.md) |
PI_DISABLE_LSPMUX | If 1, disables lspmux detection/integration and forces direct LSP server spawning |
PI_RPC_EMIT_TITLE | Boolean-like flag enabling title events in RPC mode |
SMITHERY_URL | Smithery web URL override (default https://smithery.ai) |
SMITHERY_API_URL | Smithery API base URL override (default https://api.smithery.ai) |
SMITHERY_API_KEY | Smithery API key for managed MCP auth lookup |
PUPPETEER_EXECUTABLE_PATH | Browser tool Chromium executable override |
LITELLM_BASE_URL | LiteLLM proxy base URL fallback (http://localhost:4000/v1 if unset); explicit providers.litellm.baseUrl / models.yml config wins |
LM_STUDIO_BASE_URL | Default implicit LM Studio discovery base URL override (http://127.0.0.1:1234/v1 if unset) |
OLLAMA_BASE_URL | Default implicit Ollama discovery base URL override (OLLAMA_HOST if unset, then http://127.0.0.1:11434) |
OLLAMA_HOST | Ollama host used for implicit Ollama discovery when OLLAMA_BASE_URL is unset; accepts Ollama-style values such as 127.0.0.1:11434 or http://host:11434 |
OLLAMA_CONTEXT_LENGTH | Positive integer context-window override for implicit Ollama discovery; affects OMP context budgeting only and does not change Ollama’s runtime num_ctx |
LLAMA_CPP_BASE_URL | Default implicit Llama.cpp discovery base URL override (http://127.0.0.1:8080 if unset) |
PI_EDIT_VARIANT | Forces edit tool variant when valid (patch, replace, hashline, apply_patch) |
PI_FORCE_IMAGE_PROTOCOL | Forces supported image protocol (kitty, iterm2/iterm, sixel, none) where used |
PI_ALLOW_SIXEL_PASSTHROUGH | Allows SIXEL passthrough when PI_FORCE_IMAGE_PROTOCOL=sixel |
PI_NO_PTY | If 1, disables interactive PTY path for bash tool |
OMP_MCP_TIMEOUT_MS | Overrides MCP client request timeout (ms) for every MCP server. 0 disables client-side timeouts (AbortSignal never fires). Invalid (negative or non-numeric) values are ignored with a warning and the per-server config or default (30000) is used. |
PI_NO_PTY is also set internally when CLI --no-pty is used.
6) Storage and config root paths
These are consumed via @oh-my-pi/pi-utils/dirs and affect where coding-agent stores data.
| Variable | Default / behavior |
|---|---|
PI_CONFIG_DIR | Config root dirname under home (default .omp) |
PI_CODING_AGENT_DIR | Full override for agent directory (default ~/<PI_CONFIG_DIR or .omp>/agent) |
PWD | Used when matching canonical current working directory in path helpers |
7) Shell/tool execution environment
(From packages/utils/src/procmgr.ts and coding-agent bash tool integration.)
| Variable | Behavior |
|---|---|
PI_BASH_NO_CI | Suppresses automatic CI=true injection into spawned shell env |
CLAUDE_BASH_NO_CI | Legacy alias fallback for PI_BASH_NO_CI |
PI_BASH_NO_LOGIN | Disables login-shell mode; shell args become ['-c'] instead of ['-l','-c'] |
CLAUDE_BASH_NO_LOGIN | Legacy alias fallback for PI_BASH_NO_LOGIN |
PI_SHELL_PREFIX | Optional command prefix wrapper |
CLAUDE_CODE_SHELL_PREFIX | Legacy alias fallback for PI_SHELL_PREFIX |
VISUAL | Preferred external editor command |
EDITOR | Fallback external editor command |
Current implementation: PI_BASH_NO_LOGIN/CLAUDE_BASH_NO_LOGIN are active; when either is set, getShellArgs() returns ['-c'].
8) UI/theme/session detection (auto-detected env)
These are read as runtime signals; they are usually set by the terminal/OS rather than manually configured.
| Variable | Used for |
|---|---|
COLORTERM, TERM, WT_SESSION | Color capability detection (theme color mode) |
COLORFGBG | Terminal background light/dark auto-detection |
TERM_PROGRAM, TERM_PROGRAM_VERSION, TERMINAL_EMULATOR | Terminal identity in system prompt/context |
TMUX_PANE, CMUX_SURFACE_ID, KITTY_WINDOW_ID, TERM_SESSION_ID, WT_SESSION | Stable per-terminal session breadcrumb IDs |
SHELL, ComSpec, TERM_PROGRAM, TERM | System info diagnostics |
APPDATA, XDG_CONFIG_HOME | lspmux config path resolution |
HOME | Path shortening in MCP command UI |
9) TUI runtime flags (shared package, affects coding-agent UX)
| Variable | Behavior |
|---|---|
PI_NOTIFICATIONS | off / 0 / false suppress desktop notifications |
PI_TUI_WRITE_LOG | If set, logs TUI writes to file |
PI_HARDWARE_CURSOR | If 1, enables hardware cursor mode |
PI_NO_SYNC_OUTPUT | If set (any non-empty value), disables DEC 2026 synchronized-output wrappers while keeping TUI autowrap guards |
PI_NO_DECCARA | If set (truthy), disables Kitty DECCARA rectangular-SGR background fills (forces padded-string rendering) |
PI_DEBUG_REDRAW | If 1, enables redraw debug logging |
PI_FORCE_IMAGE_PROTOCOL | Forces terminal image protocol detection (kitty, iterm2/iterm, sixel, none) |
PI_TUI_RESIZE_IN_PLACE | 1/true force in-place resize (no alt-screen borrow, no ED3 rewrap); 0/false force the alt-screen fast path. Default-on for Warp, which re-reports its size on alt-screen toggles |
10) Commit generation controls
| Variable | Behavior |
|---|---|
PI_COMMIT_TEST_FALLBACK | If true (case-insensitive), force commit fallback generation path |
PI_COMMIT_NO_FALLBACK | If true, disables fallback when agent returns no proposal |
PI_COMMIT_MAP_REDUCE | If false, disables map-reduce commit analysis path |
DEBUG | If set, commit agent error stack traces are printed |
Security-sensitive variables
Treat these as secrets; do not log or commit them:
- Provider/API keys and OAuth/bearer credentials (all
*_API_KEY,*_TOKEN, OAuth access/refresh tokens) - Cloud credentials (
AWS_*,GOOGLE_APPLICATION_CREDENTIALSpath may expose service-account material) - Search/provider auth vars (
EXA_API_KEY,BRAVE_API_KEY,PERPLEXITY_API_KEY, Anthropic search keys) - Foundry mTLS material (
CLAUDE_CODE_CLIENT_CERT,CLAUDE_CODE_CLIENT_KEY,NODE_EXTRA_CA_CERTSwhen it points to private CA bundles)
Python runtime also explicitly strips many common key vars before spawning kernel subprocesses (packages/coding-agent/src/eval/py/runtime.ts).
Architecture & Internals
This document describes how coding-agent stores large/binary payloads outside session JSONL, how truncated tool output is persisted, and how internal URLs (artifact://, agent://) resolve back to stored data.
Why two storage systems exist
The runtime uses two different persistence mechanisms for different data shapes:
- Content-addressed blobs (
blob:sha256:<hash>): global storage used to externalize large image base64 payloads and provider image data URLs from persisted session entries. - Session-scoped artifacts (files under
<sessionFile-without-.jsonl>/): per-session text files used for full tool outputs and subagent outputs.
They are intentionally separate:
- blob storage optimizes deduplication and stable references by content hash,
- artifact storage optimizes append-only session tooling and human/tool retrieval by local IDs.
Storage boundaries and on-disk layout
Blob store boundary (global)
SessionManager constructs BlobStore(getBlobsDir()), so blob files live in a shared global blob directory, not in a session folder.
Blob file naming:
- file path:
<blobsDir>/<sha256-hex> - canonical file has no extension; when an extension is supplied (image MIME type), a typed sidecar
<sha256-hex>.<ext>is hardlinked (or copied) next to it so OS openers can type-detect - reference string stored in entries:
blob:sha256:<sha256-hex>
Implications:
- same binary content across sessions resolves to the same hash/path,
- writes are idempotent at the content level,
- blobs can outlive any individual session file.
Artifact boundary (session-local)
ArtifactManager derives artifact directory from session file path:
- session file:
.../<timestamp>_<sessionId>.jsonl - artifacts directory:
.../<timestamp>_<sessionId>/(strip.jsonl)
Artifact types share this directory:
- truncated tool output files:
<numericId>.<toolType>.log(forartifact://) - subagent output files:
<outputId>.md(foragent://) - subagent session JSONL sidecars:
<outputId>.jsonlwhen task execution receives an artifacts directory
Subagents can adopt the parent ArtifactManager; in that case parent and subagent tree share one artifact directory and numeric artifact ID space.
ID and name allocation schemes
Blob IDs: content hash
BlobStore.put() / putSync() computes SHA-256 over the bytes it is given and returns:
hash: hex digest,path:<blobsDir>/<hash>,displayPath:<blobsDir>/<hash>.<ext>when an extension was supplied, otherwise the canonical path,ref:blob:sha256:<hash>.
No session-local counter is used.
Artifact IDs: session-local monotonic integer
ArtifactManager scans existing *.log artifact files on first directory-backed allocation to find max existing numeric ID and sets nextId = max + 1.
Allocation behavior:
- file format:
{id}.{toolType}.log - IDs are sequential strings (
"0","1", …) - resume does not overwrite existing artifacts because scan happens before allocation
- the directory is created lazily on first save/allocation
If the artifact directory is missing, scanning yields an empty list and allocation starts from 0.
Non-persistent sessions without an adopted manager can store saveArtifact(...) content in memory under numeric IDs, but artifact:// resolution is file-backed through registered artifact directories.
Agent output IDs (agent://)
AgentOutputManager allocates IDs for subagent outputs from the requested name, used verbatim the first time and suffixed (-2, -3, …) only when the same name repeats (e.g. Anna, Anna-2). Nested outputs are grouped under the parent prefix (e.g. Parent.Child). It scans existing .md files on initialization so a resumed session never reuses a name that would clobber a prior output.
Persistence dataflow
1) Session entry persistence rewrite path
Before a session entry is written — incremental append (#appendToSessionFile) or a full-file rewrite (#rewriteSynchronously / #rewriteAtomically) — SessionManager serializes it through #lineFor(), which runs prepareEntryForPersistence() over the truncation pipeline.
Key behaviors:
- Large string truncation: oversized strings are cut and suffixed with
"[Session persistence truncated large content]"; signature fields (thinkingSignature,thoughtSignature,textSignature) are cleared instead of truncated. - Transient field stripping:
partialJsonandjsonlEventsare removed from persisted entries. - Image externalization to blobs:
- image blocks in
contentarrays are externalized whendatais not already a blob ref and base64 length is at least threshold (BLOB_EXTERNALIZE_THRESHOLD = 1024), - provider-style
image_urldata URLs are externalized when they start withdata:image/and contain;base64,, - image block
datais stored as decoded binary bytes, - provider data URLs are stored as the original UTF-8 data URL string,
- persisted values are replaced with
blob:sha256:<hash>.
- image blocks in
This keeps session JSONL compact while preserving recoverability.
2) Session load rehydration path
When opening a session (setSessionFile), after migrations, SessionManager runs resolveBlobRefsInEntries().
For message/custom-message image blocks with blob:sha256:<hash> and for persisted provider image_url fields with blob refs:
- reads blob bytes from blob store,
- converts image-block bytes back to base64,
- converts provider
image_urlblobs back to the original string, - mutates in-memory entry fields for runtime consumers.
If a blob is missing:
- image-block resolution logs a warning and keeps the original
blob:sha256:ref string in memory, - provider
image_urlresolution logs a warning and keeps the original ref string, - load continues.
3) Tool output spill/truncation path
OutputSink powers streaming output in bash/python/ssh and related executors.
Behavior:
- Every chunk is sanitized with
sanitizeWithOptionalSixelPassthrough(..., sanitizeText)and appended to in-memory accounting. - Optional live
onChunkreceives sanitized pre-column-cap chunks, throttled if configured. - A per-line column cap can drop bytes from long lines in the LLM-facing buffer; when this happens, artifact mirroring starts so the on-disk file keeps the full sanitized stream.
- When the in-memory tail buffer would exceed spill threshold (
DEFAULT_MAX_BYTES, 50KB), sink marks output truncated and starts artifact mirroring if an artifact path is available. - If a file sink is opened, it first writes the current buffer, then all queued/subsequent sanitized chunks.
- In-memory buffer is trimmed to a tail window, or to head + elision marker + tail when head retention is configured.
dump()returns summary includingartifactIdonly when file sink creation succeeded.
Practical effect:
- UI/tool return shows bounded output,
- full sanitized output is preserved in artifact file and referenced as
artifact://<id>when file-backed artifact mirroring succeeded.
If file sink creation fails (I/O error, missing path, etc.), sink falls back to in-memory truncation only; full output is not persisted.
URL access model
blob: references
blob:sha256:<hash> is a persistence reference inside session entry payloads, not an internal URL scheme handled by the router. Resolution is done by SessionManager during session load.
artifact://<id>
Handled by ArtifactProtocolHandler over registered active session artifact directories:
- requires a numeric ID,
- searches each registered artifacts directory for filename prefix
<id>., - returns raw text (
text/plain) from the matched.logfile, - when missing, error includes available numeric artifact IDs from existing artifact files.
Failure behavior:
- if no artifact directories are registered: throws
No session - artifacts unavailable, - if registered directories exist but none are present on disk: throws
No artifacts directory found, - if ID is not numeric: throws
artifact:// ID must be numeric, got: <id>.
agent://<id>
Handled by AgentProtocolHandler over registered active session artifact directories and <artifactsDir>/<id>.md:
- plain form returns markdown text,
/pathor?q=forms perform JSON extraction,- path and query extraction cannot be combined,
- if extraction requested, file content must parse as JSON.
Failure behavior:
- if no artifact directories are registered: throws
No session - agent outputs unavailable, - if registered directories exist but none are present on disk: throws
No artifacts directory found, - missing output throws
Not found: <id>with available.mdoutput IDs when directory listing succeeds.
Read tool integration:
readsupports offset/limit pagination for non-extraction internal URL reads,- rejects offset/limit when
agent://extraction is used.
Resume, fork, and move semantics
Resume
ArtifactManagerscans existing{id}.*.logfiles on first allocation and continues numbering.AgentOutputManagerscans existing.mdoutput IDs and continues numbering.SessionManagerrehydrates blob refs to base64/data URLs on load.
Fork
SessionManager.fork() creates a new session file with new session ID and parentSession link, then returns old/new file paths. Artifact copying is handled by AgentSession.fork():
- flushes current session first,
- attempts recursive copy of old artifact directory to new artifact directory,
- missing old directory is tolerated,
- non-ENOENT copy errors are logged as warnings and fork still completes.
ID implications after fork:
- if copy succeeded, artifact counters in the new session continue after max copied ID when the new
ArtifactManagerfirst scans, - if copy failed/skipped, new session artifact IDs start from
0.
Blob implications after fork:
- blobs are global and content-addressed, so no blob directory copy is required.
Move to new cwd
SessionManager.moveTo() renames both session file and artifact directory to the new default session directory, with rollback logic if a later step fails. This preserves artifact identity while relocating session scope.
Failure handling and fallback paths
| Case | Behavior |
|---|---|
| Blob file missing during image-block rehydration | Warn and keep blob:sha256: ref string in memory |
Blob file missing during provider image_url rehydration | Warn and keep blob:sha256: ref string in memory |
Blob read ENOENT via BlobStore.get | Returns null |
Artifact directory missing (ArtifactManager.listFiles) | Returns empty list (allocation can start fresh) |
No registered artifact dirs (artifact://) | Throws No session - artifacts unavailable |
No registered artifact dirs (agent://) | Throws No session - agent outputs unavailable |
| Registered artifact dirs missing on disk | Throws explicit No artifacts directory found |
| Artifact ID not found | Throws with available IDs listing |
| OutputSink artifact writer init fails | Continues with bounded in-memory output only |
Non-persistent saveArtifact | Stores text in SessionManager memory map; not file-backed URL data |
Binary blob externalization vs text-output artifacts
- Blob externalization is for image payloads inside persisted session entry content and provider image data URLs; it replaces inline payload strings in JSONL with stable content refs.
- Artifacts are plain text files for execution output and subagent output; file-backed artifacts are addressable by session-local IDs through internal URLs.
The two systems intersect only indirectly: both reduce session JSONL bloat, but they have different identity, lifetime, and retrieval paths.
Implementation files
src/session/blob-store.ts— blob reference format, hashing, put/get, externalize/resolve helpers.src/session/artifacts.ts— session artifact directory model and numeric artifact ID/path allocation.src/session/streaming-output.ts—OutputSinktruncation/spill-to-file behavior and summary metadata.src/session/session-manager.ts—BlobStore/ArtifactManagerconstruction, persistence-transform and blob-rehydration call sites, session fork/move interactions.src/session/session-persistence.ts—prepareEntryForPersistence(): large-string truncation, transient-field stripping, and synchronous image-blob externalization.src/session/session-loader.ts—resolveBlobRefsInEntries(): blob-ref rehydration to base64 / data URLs on load.src/session/agent-session.ts— artifact directory copy during interactive fork.src/internal-urls/artifact-protocol.ts—artifact://resolver.src/internal-urls/agent-protocol.ts—agent://resolver + JSON extraction.src/internal-urls/router.ts— internal URL router wiring.src/task/output-manager.ts— session-scoped agent output ID allocation foragent://.src/task/executor.ts— subagent output artifact writes (<id>.md) and session JSONL sidecars.
@oh-my-pi/pi-natives is a two-layer package around an ESM loader:
- ESM loader/package entrypoint resolves and loads the correct
.nodeaddon withcreateRequire, validates the release sentinel outside workspace-dev loads, and re-exports generated classes/functions plus enum runtime objects as explicit named ESM exports. - Rust N-API module layer implements the exported functions/classes and emits the generated TypeScript declarations.
This document is the foundation for deeper module-level docs.
Implementation files
packages/natives/native/index.jspackages/natives/native/index.d.tspackages/natives/native/loader-state.jspackages/natives/native/embedded-addon.jspackages/natives/scripts/build-native.tspackages/natives/scripts/embed-native.tspackages/natives/scripts/gen-enums.tspackages/natives/package.jsoncrates/pi-natives/src/lib.rs
Package entrypoint and public surface
packages/natives/package.json points at generated native artifacts:
main:./native/index.jstypes:./native/index.d.tsexports["."].types:./native/index.d.tsexports["."].import:./native/index.js
There is no current packages/natives/src TypeScript wrapper layer. Consumers import functions/classes/enums directly from @oh-my-pi/pi-natives; the type contract is the generated native/index.d.ts plus the explicit named exports generated into native/index.js by scripts/gen-enums.ts.
Current capability groups in the generated API include:
- Search/text/code primitives:
grep,search,hasMatch,fuzzyFind,glob,astGrep,astEdit,blockRangeAt,summarizeCode, text width/slicing/wrapping/sanitization, syntax highlighting, token counting. - Execution/process/terminal primitives:
executeShell,Shell,PtySession,Process, key parsing, bash fixups. - System/media/isolation/conversion primitives: clipboard, SIXEL encoding, HTML-to-Markdown, macOS appearance/power helpers, work profiling, workspace scanning, isolation backend helpers (
iso*).
Loader layer
packages/natives/native/index.js is the package entrypoint; it calls loadNative() from loader-state.js, which owns runtime addon selection and optional embedded extraction.
Candidate resolution model
- Platform tag is
${process.platform}-${process.arch}. - Supported tags are currently:
linux-x64linux-arm64darwin-x64darwin-arm64win32-x64
- x64 can use CPU variants:
modern(AVX2-capable)baseline(fallback)
- Non-x64 uses the default filename without a variant suffix.
Filename strategy:
- Default:
pi_natives.<platform>-<arch>.node - x64 variant:
pi_natives.<platform>-<arch>-modern.nodeor...-baseline.node - x64 runtime fallback includes the unsuffixed default filename after variant candidates.
Platform-specific variant detection
For x64, variant selection uses:
- Linux:
/proc/cpuinfo - macOS:
sysctl -n machdep.cpu.leaf7_features, thenmachdep.cpu.features - Windows: PowerShell check for
System.Runtime.Intrinsics.X86.Avx2
PI_NATIVE_VARIANT can force modern or baseline; invalid values are ignored.
Binary distribution and extraction model
The published @oh-my-pi/pi-natives package ships only the loader layer in native/: the ESM loader (index.js), generated declarations (index.d.ts), the loader-state.js/.d.ts helpers, and the embedded-addon manifest stub (embedded-addon.js). It carries no .node binaries.
Each platform’s prebuilt .node is published as a separate optional-dependency leaf package — @oh-my-pi/pi-natives-<platform>-<arch>, one per supported tag — which the core lists in optionalDependencies at the lockstep version during publish. npm/bun install only the leaf whose os/cpu match the host. The working-tree package keeps built .node files under native/ for local dev; the release-publish rewrite (prepareNativeCorePackage in scripts/ci-release-publish.ts) strips them from the core tarball, and the leaves are generated by packages/natives/scripts/gen-npm-packages.ts (LEAF_TARGETS). Adding a build target therefore requires a matching LEAF_TARGETS entry, or the binary never reaches npm users.
For compiled binaries, loader behavior is:
- Check versioned user cache path:
<getNativesDir()>/<packageVersion>/.... - Check legacy compiled-binary location:
- Windows:
%LOCALAPPDATA%/omp(fallback%USERPROFILE%/AppData/Local/omp) - non-Windows:
~/.local/bin
- Windows:
- Fall back to packaged
native/and executable directory candidates.
getNativesDir() uses $XDG_DATA_HOME/omp/natives when $XDG_DATA_HOME/omp exists; otherwise it uses ~/.omp/natives.
If a populated embedded addon manifest is present, it is also treated as a compiled-binary signal. Current embedded manifests point at a gzip-compressed tar archive (embedded-addons.<tag>.tar.gz) that contains one or more matching .node files. The loader extracts the archive into the versioned cache directory, validates the selected file by size, and prepends that cache path before normal candidate probing.
For npm/bun installs (non-compiled), loader-state.js resolves the platform leaf directory via require.resolve("@oh-my-pi/pi-natives-<tag>/package.json") and probes its .node before the core package’s native/ directory and the executable directory. The optional-dependency binary is therefore preferred over any .node left in the core (e.g. a stale local-dev build). On Windows node_modules installs, the loader first stages the selected leaf/core addon into <getNativesDir()>/<packageVersion>/... and prepends that staged path so running processes do not lock the node_modules copy during global updates.
Failure modes
Loader failures are explicit:
- Unsupported platform tag: after failed probing, throws with supported platform list.
- No loadable candidate: throws with all attempted paths and remediation hints.
- Embedded/staging errors: directory/write/archive/staging failures are recorded and included in final load diagnostics if no candidate loads.
- Release mismatch: outside workspace-dev loads, a candidate that loads but lacks the version sentinel export for
package.json#versionis rejected with a reinstall hint.
Rust N-API module layer
crates/pi-natives/src/lib.rs declares exported module ownership:
appearanceastblockclipboardcrash_handlerfdfs_cacheglobglob_utilgrephighlighthtmlisokeyslanguage(re-exported frompi_ast)powerprofpsptyshellsixelsnapcompactsummarytasktexttokensutils(crate-private helpers)workspace
N-API exports are generated from Rust #[napi] functions/classes/objects/enums. Snake_case Rust names are exposed as camelCase JavaScript names unless explicitly configured by napi-rs.
Ownership boundaries
- Loader/package ownership (
packages/natives/native,packages/natives/scripts)- runtime binary selection
- CPU variant selection and override handling
- compiled-binary embedded archive extraction
- Windows
node_modulesaddon staging - generated TypeScript declarations and explicit ESM export/enum patching
- Rust ownership (
crates/pi-natives/src)- algorithmic and system-level implementation
- platform-native behavior and performance-sensitive logic
- N-API symbol implementation consumed directly by package callers
- Consumer ownership (
packages/coding-agent,packages/tui)- user-facing policy and fallbacks that are not built into the native API
- higher-level rendering, artifact, shell-session, and command behavior
Runtime flow (high level)
- Consumer imports from
@oh-my-pi/pi-natives. native/index.jscomputes platform/arch/variant and candidate paths.- Optional embedded archive extraction or Windows
node_modulesstaging can prepend a versioned-cache candidate. - Each candidate is
require(...)d; install/compiled loads must expose the package-version sentinel. - The loaded addon object is bound to explicit named ESM exports, including generated enum objects.
- Caller invokes generated N-API functions/classes directly.
Glossary
- Native addon: A
.nodebinary loaded via Node-API (N-API). - Platform tag: Runtime tuple
platform-arch(for exampledarwin-arm64). - Platform leaf package: Per-platform npm package
@oh-my-pi/pi-natives-<tag>that carries one platform’s prebuilt.node. The core depends on every leaf viaoptionalDependencies; the package manager installs only the host-matching one (os/cpu). - Variant: x64 CPU-specific build flavor (
modernAVX2,baselinefallback). - Generated binding declaration:
native/index.d.tsemitted by napi-rs duringbuild-native.ts. - Version sentinel: Rust export named from the package version (for example
__piNativesV16_0_3) that lets the loader reject a.nodefrom a different release. - Compiled binary mode: Runtime mode where the CLI is bundled and native addons are resolved from embedded/cache paths before package-local paths.
- Embedded addon: Build artifact metadata and archive reference generated into
native/embedded-addon.jsso compiled binaries can extract matching.nodepayloads.
This document explains how token/tool streaming is normalized in @oh-my-pi/pi-ai, then propagated through @oh-my-pi/pi-agent-core and coding-agent session events.
End-to-end flow
streamSimple()(packages/ai/src/stream.ts) maps generic options and dispatches to a provider stream function.- Provider stream functions translate provider-native stream events into the unified
AssistantMessageEventsequence. Current built-ins include Anthropic, OpenAI Responses/Completions/Codex/Azure Responses, Google Gemini/Gemini CLI/Vertex, Bedrock Converse, Ollama, Cursor, pi-native gateway transport, plus GitLab Duo/Kimi/Synthetic/xAI-Grok-Responses wrappers and extension-registered custom APIs. - Each provider pushes events into
AssistantMessageEventStream(packages/ai/src/utils/event-stream.ts), which exposes:- async iteration for incremental updates
result()for finalAssistantMessage
agentLoop(packages/agent/src/agent-loop.ts) consumes those events, mutates in-flight assistant state, and emitsmessage_updateevents carrying the rawassistantMessageEvent.AgentSession(packages/coding-agent/src/session/agent-session.ts) subscribes to agent events, persists messages, drives extension hooks, and applies session behaviors (retry, compaction, TTSR, streaming-edit abort checks).
Unified stream contract in @oh-my-pi/pi-ai
All providers emit the same shape (AssistantMessageEvent in packages/ai/src/types.ts):
start- content block lifecycle triplets:
- text:
text_start→text_delta* →text_end - thinking:
thinking_start→thinking_delta* →thinking_end - tool call:
toolcall_start→toolcall_delta* →toolcall_end
- text:
- terminal event:
donewithreason: "stop" | "length" | "toolUse"- or
errorwithreason: "aborted" | "error"
AssistantMessageEventStream guarantees:
- final result is resolved by terminal event (
doneorerror) - events are delivered to consumers immediately, in push order (no batching or merging)
Delta throttling behavior
AssistantMessageEventStream itself no longer throttles or merges delta events — every provider event is delivered as pushed. The per-delta cost control moved into tool-call argument parsing: providers accumulate partial JSON and re-parse it via parseStreamingJsonThrottled() (packages/ai/src/utils/json-parse.ts), which skips the re-parse until at least STREAMING_JSON_PARSE_MIN_GROWTH (256) new bytes have arrived, bounding mid-stream parse cost from quadratic to linear. The final toolcall_end parse is always unconditional and authoritative.
There is no provider backpressure: providers still produce at full speed, while the local stream queues.
Provider normalization details
Anthropic (anthropic-messages)
Source: packages/ai/src/providers/anthropic.ts
Normalization points:
message_startinitializes usage (input/output/cache tokens)content_block_startmaps to text/thinking/toolcall startscontent_block_deltamaps:text_delta→text_deltathinking_delta→thinking_deltainput_json_delta→toolcall_deltasignature_deltaupdatesthinkingSignatureonly (no event)
content_block_stopemits corresponding*_endmessage_delta.stop_reasonmaps viamapStopReason()
Tool-call argument streaming:
- each tool block carries internal
partialJson - every JSON delta appends to
partialJson argumentsare reparsed on appended deltas viaparseStreamingJsonThrottled()(re-parse only after ≥256 new bytes)toolcall_endreparses once more, then stripspartialJson
OpenAI Responses family (openai-responses, openai-codex-responses, azure-openai-responses)
Sources: packages/ai/src/providers/openai-responses.ts, openai-codex-responses.ts, and azure-openai-responses.ts
Normalization points:
response.output_item.addedstarts reasoning/text/function-call/custom-tool blocks- reasoning summary events (
response.reasoning_summary_text.delta) and raw reasoning events (response.reasoning_text.delta) becomethinking_delta - output/refusal deltas become
text_delta response.function_call_arguments.deltaandresponse.custom_tool_call_input.deltabecometoolcall_deltaresponse.output_item.doneemitsthinking_end/text_end/toolcall_endresponse.completedmaps status to stop reason and usage;response.failed/ SDKerrorevents throw into the wrapper’s terminalerrorpath
Tool-call argument streaming:
- same
partialJsonaccumulation pattern as Anthropic for function-call JSON arguments - custom tools stream raw string input and expose final arguments as
{ input: <raw> } - providers that send only
response.function_call_arguments.donestill populate final args - tool call IDs are normalized as
"<call_id>|<item_id>"
Google Generative AI (google-generative-ai)
Source: packages/ai/src/providers/google.ts (thin request wrapper) and google-shared.ts (streamGoogleGenAI, shared chunk-to-block translation)
Normalization points:
- iterates
candidate.content.parts - text parts are split into thinking vs text by
isThinkingPart(part) - block transitions close previous block before starting a new one
part.functionCallis treated as a complete tool call (start/delta/end emitted immediately)- finish reason mapped by
mapStopReason()fromgoogle-shared.ts
Tool-call argument streaming:
- function call args arrive as structured object, not incremental JSON text
- implementation emits one synthetic
toolcall_deltacontainingJSON.stringify(arguments) - no partial JSON parser needed for Google in this path
Partial tool-call JSON accumulation and recovery
Shared behavior for Anthropic/OpenAI Responses uses parseStreamingJson() / parseStreamingJsonThrottled() (packages/ai/src/utils/json-parse.ts):
- try
JSON.parse - fallback to the in-house
RelaxedJsonparser (relaxed/repairing) for incomplete fragments - if both fail, return
{}
Implications:
- malformed or truncated argument deltas do not crash stream processing immediately
- in-progress
argumentsmay temporarily be{} - later valid deltas can recover structured arguments because parsing is retried as the buffer grows (throttled to ≥256-byte growth steps mid-stream)
- final
toolcall_endperforms one more parse attempt before emission
Stop reasons vs transport/runtime errors
Provider stop reasons are mapped to normalized stopReason:
- Anthropic:
end_turn→stop,max_tokens→length,tool_use→toolUse, safety/refusal cases→error - OpenAI Responses:
completed→stop,incomplete→length,failed/cancelled→error - Google:
STOP→stop,MAX_TOKENS→length, safety/prohibited/malformed-function-call classes→error
Error semantics are split in two stages:
- Model completion semantics (provider reported finish reason/status)
- Transport/runtime failure (network/client/parser/abort exceptions)
If provider stream throws or signals failure, each provider wrapper catches and emits terminal error event with:
stopReason = "aborted"when abort signal is set- otherwise
stopReason = "error" errorMessage = finalizeErrorMessage(error, rawRequestDump)(packages/ai/src/utils/http-inspector.ts), which wrapsformatErrorMessageWithRetryAfter()and appends any captured HTTP-error body / raw-request dump (thecursorwrapper callsformatErrorMessageWithRetryAfter()directly)
Malformed chunk / SSE parse failure behavior
The OpenAI Completions/Responses paths use the in-repo HTTP+SSE transport postOpenAIStream() (packages/ai/src/utils/openai-http.ts), which decodes frames with readSseJson() and replaced the openai SDK client. Anthropic uses the in-repo AnthropicMessagesClient (packages/ai/src/providers/anthropic-client.ts); the Google paths and the Codex SSE fallback read SSE via readSseJson() directly, and websocket Codex frames are normalized through the same event handler.
Observed behavior in current implementation:
- malformed SSE framing or chunk JSON surfaces as an exception or stream
errorevent - malformed Codex SSE JSON/framing throws from the local SSE reader
- provider wrapper converts failures into unified terminal
errorevents - no provider-specific resume/retry inside the stream function itself, except Codex websocket-to-SSE transport fallback before replay-unsafe output is emitted
- higher-level retries are handled in
AgentSessionauto-retry logic (message-level retry, not stream-chunk replay)
Cancellation boundaries
Cancellation is layered:
- AI provider request:
options.signalis passed into provider client stream call. - Provider wrapper: after stream loop, aborted signal forces error path (
"Request was aborted"). - Agent loop: checks
signal.abortedbefore handling each provider event and can synthesize an aborted assistant message from the latest partial. - Session/agent controls:
AgentSession.abort()->agent.abort()-> shared abort controller cancellation.
Tool execution cancellation is separate from model stream cancellation:
- tool runners use
AbortSignal.any([agentSignal, steeringAbortSignal]) - steering interrupts can abort remaining tool execution while preserving already-produced tool results
Backpressure boundaries
There is no hard backpressure mechanism between provider SDK stream and downstream consumers:
EventStreamuses in-memory queues with no max size- the throttled partial-JSON re-parse reduces per-delta CPU cost but does not slow provider intake
- if consumers lag significantly, queued events can grow until completion
Current design favors responsiveness and simple ordering over bounded-buffer flow control.
How stream events surface as agent/session events
agentLoop.streamAssistantResponse() bridges AssistantMessageEvent to AgentEvent:
- on
start: pushes placeholder assistant message and emitsmessage_start - on block events (
text_*,thinking_*,toolcall_*): updates last assistant message, emitsmessage_updatewith rawassistantMessageEvent - on terminal (
done/error): resolves final message fromresponse.result(), emitsmessage_end
AgentSession then consumes those events for session-level behaviors:
- TTSR watches
message_update.assistantMessageEventfortext_delta,thinking_delta, andtoolcall_delta - streaming edit guard inspects
toolcall_delta/toolcall_endoneditcalls and can abort early - persistence writes finalized messages at
message_end - auto-retry examines assistant
stopReason === "error"pluserrorMessageheuristics
Unified vs provider-specific responsibilities
Unified (common contract):
- event shape (
AssistantMessageEvent) - final result extraction (
done/error) - immediate in-order event delivery
- agent/session event propagation model
Provider-specific (not fully abstracted):
- upstream event taxonomies and mapping logic
- stop-reason translation tables
- tool-call ID conventions
- reasoning/thinking block semantics and signatures
- usage token semantics and availability timing
- message conversion constraints per API
Implementation files
../../ai/src/stream.ts— provider dispatch, option mapping, API key/session plumbing, custom API dispatch, and provider-specific credential handling.../../ai/src/utils/event-stream.ts— generic stream queue + final-result resolution.../../ai/src/utils/json-parse.ts— partial JSON parsing for streamed tool arguments.../../ai/src/providers/anthropic.ts— Anthropic event translation and tool JSON delta accumulation.../../ai/src/providers/openai-responses.ts,openai-shared.ts,openai-codex-responses.ts,azure-openai-responses.ts— Responses-family event translation and status mapping.../../ai/src/providers/google.ts,google-gemini-cli.ts,google-vertex.ts— Gemini stream chunk-to-block translation variants.../../ai/src/providers/google-shared.ts— Gemini finish-reason mapping and shared conversion rules.../../ai/src/providers/amazon-bedrock.ts,openai-completions.ts,ollama.ts,cursor.ts,pi-native-client.ts— additional built-in stream adapters using the same event contract.../../agent/src/agent-loop.ts— provider stream consumption andmessage_updatebridging.../src/session/agent-session.ts— session-level handling of streaming updates, abort, retry, and persistence.
This document covers the current Time Traveling Stream Rules (TTSR) runtime path from rule discovery to stream interruption, retry injection, extension notifications, and session-state handling.
Implementation files
../src/sdk.ts../src/export/ttsr.ts../src/session/agent-session.ts../src/session/session-manager.ts../src/prompts/system/ttsr-interrupt.md../src/capability/index.ts../src/extensibility/extensions/types.ts../src/extensibility/hooks/types.ts../src/extensibility/custom-tools/types.ts../src/modes/controllers/event-controller.ts
1. Discovery feed and rule registration
At session creation, createAgentSession() loads discovered rules, constructs a TtsrManager, and buckets rules through bucketRules(...):
const ttsrSettings = settings.getGroup("ttsr");
const ttsrManager = new TtsrManager(ttsrSettings);
const rulesResult = await loadCapability<Rule>(ruleCapability.id, { cwd });
const { rulebookRules, alwaysApplyRules } = bucketRules(
rulesResult.items,
ttsrManager,
{
builtinRules: ttsrSettings.builtinRules,
disabledRules: ttsrSettings.disabledRules,
},
);bucketRules(...) drops names listed in ttsr.disabledRules, drops embedded builtin-defaults rules when ttsr.builtinRules === false, registers accepted TTSR rules, and then routes the remaining rules to always-apply/rulebook buckets.
Pre-registration dedupe behavior
loadCapability("rules") deduplicates by rule.name with first-wins semantics (higher provider priority first). Shadowed duplicates are removed before TTSR registration.
TtsrManager.addRule() behavior
Registration is skipped when:
- TTSR is disabled (
ttsr.enabled === false) - both
rule.condition(regex) andrule.astCondition(ast-grep patterns) are absent, or every regex condition fails to compile and there are no AST conditions - a rule with the same
rule.namewas already registered in this manager - the rule scope excludes all monitored streams
Invalid regex conditions and unreachable scopes are logged as warnings and ignored; session startup continues. If a TTSR rule defines globs, those globs are compiled as a global file-path gate for matching.
AST conditions (astCondition)
A rule may carry astCondition: a list of ast-grep patterns (OR’d, same as regex condition), matched structurally instead of textually. A repeated metavariable inside one pattern requires both occurrences to be equal (if ($X) clearTimeout($X) matches but if ($X) clearTimeout($Y) does not).
AST conditions only evaluate on edit/write tool-argument streams — they need a language, which is inferred from the file extension on the tool’s path argument, and they match against the tool’s reconstructed source snapshot (matcherDigest), not the raw wire delta. Matching is performed in memory by the native astMatch engine (no temp files) with Smart strictness. Streams without a usable file path (prose, thinking, path-less tool calls) skip AST conditions entirely. A rule may mix condition and astCondition; the regex paths keep working on every scope while AST paths apply only to those tool streams.
Setting gating
TtsrSettings.enabled gates the manager: when ttsr.enabled === false, addRule() refuses registration and checkDelta()/checkSnapshot()/checkAstSnapshot()/hasRules()/hasAstRules() all return empty/false, so no matching runs.
2. Streaming monitor lifecycle
TTSR detection runs inside AgentSession.#handleAgentEvent.
Turn start
On turn_start, the stream buffer is reset:
ttsrManager.resetBuffer()
During stream (message_update)
When assistant updates arrive and rules exist:
- monitor
text_delta,thinking_delta, andtoolcall_delta - for tools exposing
matcherDigest(edit/write), replace the scoped buffer with the reconstructed source snapshot and callcheckSnapshot(snapshot, matchContext); otherwise append the delta into a source/tool scoped manager buffer and callcheckDelta(delta, matchContext)(synchronous regex matching either way) - for edit/write tool streams, when
hasAstRules()is true,await checkAstSnapshot(snapshot, matchContext)(asynchronous AST matching)
checkDelta()/checkSnapshot() iterate registered rules and return all matching rules that pass scope, global path-glob, regex condition, and repeat policy checks. checkAstSnapshot() applies the same scope/path/repeat gates, then runs each candidate rule’s astCondition patterns against the snapshot via the native astMatch engine. It is throttled per stream key: an identical consecutive snapshot (common when only non-source arguments change between deltas) is skipped without re-running the matcher. Both paths feed their matches through the same trigger-decision handler.
3. Trigger decision and immediate abort path
When one or more rules match and at least one matched rule allows interruption:
- Matched rules are deduplicated into
#pendingTtsrInjections. #ttsrAbortPending = trueand a TTSR resume gate is created.agent.abort()is called immediately.ttsr_triggeredevent is emitted asynchronously (fire-and-forget).- retry work is scheduled via the post-prompt task scheduler with a 50ms delay.
Abort is not blocked on extension callbacks.
4. Retry scheduling, context mode, and reminder injection
After the 50ms timeout:
#ttsrAbortPending = false- read
ttsrManager.getSettings().contextMode - if
contextMode === "discard", drop the targeted partial assistant output withagent.replaceMessages(...slice(0, targetAssistantIndex)) - build injection content from pending rules using
ttsr-interrupt.mdtemplate - append and persist a hidden
custom_message/runtime custom message withcustomType: "ttsr-injection"anddetails.rules - mark those rule names injected, persist a
ttsr_injectionentry, and callagent.continue()to retry generation
Template payload is:
<system-interrupt reason="rule_violation" rule="{{name}}" path="{{path}}">
...
{{content}}
</system-interrupt>Pending injections are cleared after content generation.
contextMode behavior on partial output
discard: partial/aborted assistant message is removed before retry.keep: partial assistant output remains in conversation state; reminder is appended after it.
Non-interrupting matches
Non-interrupting matches split by matchContext.source:
-
source === "tool"(tool-source match). The rule is bucketed into#perToolTtsrInjections, keyed by the matched tool call’sid. There is no deferred follow-up turn and the stream is not aborted. When the tool actually produces a result, theafterToolCallhook prepends a renderedttsr-tool-reminder.mdblock toctx.result.content(a singletextblock inserted ahead of the tool’s own content), and persists attsr_injectionentry with the consumed rule names. The template payload is:<system-reminder reason="rule_violation" rule="{{name}}" path="{{path}}"> ... {{content}} </system-reminder> -
source === "text"/"thinking"(prose-source match). Behavior is unchanged: the rule is queued in#pendingTtsrInjectionsand, after a successful non-error, non-aborted assistant message,AgentSessioninjects the hiddenttsr-injectioncustom message as a follow-up and schedules continuation.
Within a single matching batch, each rule is attached to exactly one sibling tool call — if multiple sibling tool calls would satisfy the same rule, deduplication picks one and the others are left untouched. Multiple distinct rules can still fold onto the same tool call.
Implications for tool authors and transcript readers
- The tool’s own
toolResultcontent is preserved verbatim; the reminder is prepended as an additional leading text block. Renderers that assumecontent[0]is the tool’s primary output must scan past any block whose text begins with<system-reminder reason="rule_violation"(or filter on the wrapper tag) to find the real payload. - The reminder is in-band on the tool result, not a separate
custom_message/ttsr-injectionentry. Transcript readers looking for non-interrupting TTSR activity on tool-source rules MUST inspect tool results (and the persistedttsr_injectionentry list), not just synthetic injection entries. - A single tool result may carry reminders for several rules concatenated with a blank line between rendered templates.
- If the assistant message ends with
stopReason === "aborted"or"error"before the matched tools run, the pending per-tool buckets are cleared — those rules are not persisted as injected and remain eligible to re-trigger on a future turn (subject to repeat policy).
5. Repeat policy and gap logic
TtsrManager tracks #messageCount and per-rule lastInjectedAt.
repeatMode: "once"
A rule can trigger only once after it has an injection record.
repeatMode: "after-gap"
A rule can re-trigger only when:
messageCount - lastInjectedAt >= repeatGap
messageCount increments on turn_end, so gap is measured in completed turns, not stream chunks.
6. Event emission and extension/hook surfaces
Session event
AgentSessionEvent includes:
{ type: "ttsr_triggered"; rules: Rule[] }Extension runner
#emitSessionEvent() routes the event to:
- extension listeners (
ExtensionRunner.emit({ type: "ttsr_triggered", rules })) - local session subscribers
Hook and custom-tool typing
- extension API exposes
on("ttsr_triggered", ...) - hook API exposes
on("ttsr_triggered", ...) - custom tools receive
onSession({ reason: "ttsr_triggered", rules })
Interactive-mode rendering difference
Interactive mode uses session.isTtsrAbortPending to suppress showing the aborted assistant stop reason as a visible failure during TTSR interruption, and renders a TtsrNotificationComponent when the event arrives.
7. Persistence and resume state (current implementation)
SessionManager persists injected-rule state:
- entry type:
ttsr_injection - append API:
appendTtsrInjection(ruleNames) - query API:
getInjectedTtsrRules() - context reconstruction includes
SessionContext.injectedTtsrRules
TtsrManager supports restoration via restoreInjected(ruleNames).
Current wiring status
In the current runtime path:
- interrupted injections append a hidden
custom_messagewithcustomType: "ttsr-injection"and append attsr_injectionentry viaappendTtsrInjection(...) - deferred non-interrupting prose-source injections are marked/persisted when their queued custom message reaches
message_end - non-interrupting tool-source injections are marked at match time and persisted via
appendTtsrInjection(...)from theafterToolCallhook when the matched tool’s result is produced createAgentSession()restoresexistingSession.injectedTtsrRulesintottsrManager
Net effect: injected-rule suppression is persisted/restored across session reload/resume for the current branch path.
8. Race boundaries and ordering guarantees
Abort vs retry callback
- abort is synchronous from TTSR handler perspective (
agent.abort()called immediately) - retry is deferred by timer (
50ms) - extension notification is asynchronous and intentionally not awaited before abort/retry scheduling
Multiple matches in same stream window
checkDelta() returns all currently matching eligible rules for that scoped buffer. Pending injections are deduplicated by rule name before injection.
Between abort and continue
During the timer window, state can change (user interruption, mode actions, additional events). The retry call is best-effort: agent.continue() is awaited in a try/catch; on failure the error is swallowed and the TTSR resume gate is resolved.
9. Edge cases summary
- Invalid
conditionregex: skipped with warning; other conditions/rules continue. - Duplicate rule names at capability layer: lower-priority duplicates are shadowed before registration.
- Duplicate names at manager layer: second registration is ignored.
ttsr.disabledRules: listed names are dropped before TTSR registration and are not surfaced through always-apply/rulebook buckets.ttsr.builtinRules: false: embeddedbuiltin-defaultsrules are dropped before TTSR registration; user/project rules still load.globson a TTSR rule require the stream match context to include at least one matching file path.contextMode: "keep": partial violating output can remain in context before reminder retry.interruptMode: "never": prose-source matches queue a deferred hidden injection after a successful assistant message; tool-source matches fold an in-band<system-reminder>into the matched tool call’stoolResultcontent via theafterToolCallhook (no mid-stream abort, no separate follow-up turn).- Tool-source non-interrupting buckets are cleared when the parent assistant message ends with
stopReason === "aborted"or"error", so rules whose target tool never produced a result remain eligible to re-trigger. - Repeat-after-gap depends on turn count increments at
turn_end; mid-turn chunks do not advance gap counters.
This document describes how coding-agent discovers rules from supported config formats, normalizes them into a single Rule shape, resolves precedence conflicts, and splits the result into:
- Rulebook rules (available to the model via system prompt +
rule://URLs) - TTSR rules (Time Traveling Stream Rules)
It reflects the current implementation, including partial semantics and metadata that is parsed but not enforced.
Implementation files
packages/coding-agent/src/capability/rule.tspackages/coding-agent/src/capability/rule-buckets.tspackages/coding-agent/src/capability/index.tspackages/coding-agent/src/discovery/index.tspackages/coding-agent/src/discovery/helpers.tspackages/coding-agent/src/discovery/builtin.tspackages/coding-agent/src/discovery/omp-plugins.tspackages/coding-agent/src/discovery/builtin-defaults.tspackages/coding-agent/src/discovery/agents.tspackages/coding-agent/src/discovery/cursor.tspackages/coding-agent/src/discovery/windsurf.tspackages/coding-agent/src/discovery/cline.tspackages/coding-agent/src/sdk.tspackages/coding-agent/src/system-prompt.tspackages/coding-agent/src/internal-urls/rule-protocol.tspackages/utils/src/frontmatter.ts
1. Canonical rule shape
All providers normalize source files into Rule:
interface Rule {
name: string;
path: string;
content: string;
globs?: string[];
alwaysApply?: boolean;
description?: string;
condition?: string[];
astCondition?: string[];
scope?: string[];
interruptMode?: "never" | "prose-only" | "tool-only" | "always";
_source: SourceMeta;
}Capability identity is rule.name (ruleCapability.key = rule => rule.name).
Consequence: precedence and deduplication are name-based only. Two different files with the same name are considered the same logical rule.
2. Discovery sources and normalization
src/discovery/index.ts auto-registers providers. For rules, current providers are:
native(priority100)omp-plugins(priority90) —rules/*.{md,mdc}inside configured extension package roots, normalized via the sharedbuildRuleFromMarkdownpathagents(priority70)cursor(priority50)windsurf(priority50)cline(priority40)builtin-defaults(priority1)
Native provider (builtin.ts)
Loads .omp rules from:
- project:
<cwd>/.omp/rules/*.{md,mdc}when the cwd.ompdirectory exists - user:
~/.omp/agent/rules/*.{md,mdc} - sticky user rule:
~/.omp/agent/RULES.md - sticky project rule: nearest ancestor
.omp/RULES.mdwhile walking from cwd toward the repository root
Normalization:
name= filename without.md/.mdc- frontmatter parsed via
parseFrontmatter content= body (frontmatter stripped)globs,alwaysApply,description,condition/legacyttsr_trigger,astCondition,scope, andinterruptModeare parsed bybuildRuleFromMarkdown- top-level
RULES.mdis synthesized as rule nameRULESand forced toalwaysApply: true
Important caveat: condition values that look like file globs are converted into tool:edit(...) / tool:write(...) scope shorthands with catch-all condition .*.
Agents provider (agents.ts)
Loads from both .agent and .agents directories:
- project: walk upward from
cwdto repo root, loading<ancestor>/.agent/rules/*.{md,mdc}and<ancestor>/.agents/rules/*.{md,mdc} - user:
~/.agent/rules/*.{md,mdc}and~/.agents/rules/*.{md,mdc}
Normalization uses the shared buildRuleFromMarkdown path: filename-derived name, stripped frontmatter body, and parsed globs, alwaysApply, description, condition/legacy ttsr_trigger, astCondition, scope, and interruptMode.
Cursor provider (cursor.ts)
Loads from:
- user:
~/.cursor/rules/*.{mdc,md} - project:
<cwd>/.cursor/rules/*.{mdc,md}
Normalization (transformMDCRule):
description: kept only if stringalwaysApply: normalized to a boolean —trueonly when frontmatter hasalwaysApply: true(anything else becomesfalse)globs: accepts array (string elements only) or single stringcondition/legacyttsr_trigger,astCondition,scope, andinterruptModeare parsed by shared rule helpersnamefrom filename without extension
Windsurf provider (windsurf.ts)
Loads from:
- user:
~/.codeium/windsurf/memories/global_rules.md(fixed rule nameglobal_rules) - project:
<cwd>/.windsurf/rules/*.md
Normalization:
globs: array-of-string or single stringalwaysApply,description,condition/legacyttsr_trigger,astCondition,scope, andinterruptModeparsed by shared rule helpersnameis fixed toglobal_rulesfor the user global file and derived from filename for project rules
Cline provider (cline.ts)
Searches upward from cwd for nearest .clinerules:
- if directory: loads
*.mdinside it - if file: loads single file as rule named
clinerules
Normalization:
globs: array-of-string or single stringalwaysApply,description,condition/legacyttsr_trigger,astCondition,scope, andinterruptModeparsed by shared rule helpersnameis fixed toclinerulesfor a.clinerulesfile and derived from filename for.clinerules/*.md
3. Frontmatter parsing behavior and ambiguity
All providers use parseFrontmatter (utils/frontmatter.ts) with these semantics:
- Frontmatter is parsed only when content starts with
---and has a closing\n---. - Body is trimmed after frontmatter extraction.
- If YAML parse fails:
- warning is logged,
- parser falls back to simple
key: valueline parsing (^([\w-]+):\s*(.*)$).
Ambiguity consequences:
- Fallback parser does not support arrays, nested objects, or quoting rules.
- Fallback values become strings (for example
alwaysApply: truebecomes string"true"), so providers requiring boolean/string types may drop metadata. ttsr_triggerworks in fallback (underscore key); hyphenated keys likethinking-levelalso parse and are normalized to camelCase (thinkingLevel) — key normalization applies to the YAML path too.- Files without valid frontmatter still load as rules with empty metadata and full content body.
4. Provider precedence and deduplication
loadCapability("rules") (capability/index.ts) merges provider outputs and then deduplicates by rule.name.
Precedence model
- Providers are ordered by priority descending.
- Equal priority keeps registration order (
cursorbeforewindsurffromdiscovery/index.ts). - Dedup is first-wins: first encountered rule name is kept; later same-name items are marked
_shadowedinalland excluded fromitems.
Effective rule provider order is currently:
native(100)omp-plugins(90)agents(70)cursor(50)windsurf(50)cline(40)builtin-defaults(1)
Intra-provider ordering caveat
Within a provider, item order comes from loadFilesFromDir glob result ordering plus explicit push order. This is deterministic enough for normal use but not explicitly sorted in code.
Notable source-order differences:
nativeappends project.omp/rules, user~/.omp/agent/rules, userRULES.md, then nearest projectRULES.md.omp-pluginsappendsrules/results per configured extension package root.agentsappends project-walk.agent/.agentsrule dirs before user home dirs.cursorappends user then project results.windsurfappends userglobal_rulesfirst, then project rules.clineloads only nearest.clinerulessource.builtin-defaultsuses the embedded rule source order.
5. Split into Rulebook, Always-Apply, and TTSR buckets
After rule discovery in createAgentSession (sdk.ts), bucketRules(...) applies session-level filtering and bucket assignment:
- Drop rules listed in
ttsr.disabledRules. - Drop rules from the
builtin-defaultsprovider whenttsr.builtinRules === false. - Register rules with a non-empty
conditionorastConditionintoTtsrManager; if registration succeeds, the rule is TTSR-only. - Put remaining
alwaysApply === truerules intoalwaysApplyRules. - Put remaining rules with
descriptionintorulebookRules.
Bucket behavior
- TTSR bucket: any enabled rule with a non-empty parsed
condition(regex) orastCondition(ast-grep patterns) thatTtsrManager.addRule(...)accepts. Takes priority over other buckets. - Always-apply bucket:
alwaysApply === true, not TTSR. Full content injected into system prompt. Resolvable viarule://. - Rulebook bucket: must have description, must not be TTSR, must not be
alwaysApply. Listed in system prompt by name+description; content read on demand viarule://. - A rule with both a trigger condition and
alwaysApplygoes to TTSR only if TTSR registration accepts it; otherwise it can fall through to always-apply. - A rule with both
alwaysApplyanddescriptiongoes to always-apply only (not rulebook).
6. How metadata affects runtime surfaces
description
- Required for inclusion in rulebook.
- Rendered in the system prompt rulebook block (
<domain-rules>in the default template,<rules>in the custom-prompt template). - Missing description keeps the rule out of the rulebook listing; unless it is always-apply or an accepted TTSR rule, it is also not addressable via
rule://.
globs
- Carried through on
Rule. - Rendered inline in the default prompt’s rulebook listing (
- <name> (<glob>, ...): <description>); the custom-prompt template renders them as<glob>...</glob>entries. - Exposed in rules UI state (
extensionsmode list). - Used by TTSR as a global path gate: if a TTSR rule has globs, the match context must include at least one matching file path.
- Not used to automatically select rulebook rules for
rule://; rulebook matching remains advisory prompt behavior.
alwaysApply
- Parsed and preserved by providers.
- Used in UI display (
"always"trigger label in extensions state manager). - Used as an exclusion condition from
rulebookRules. - Full rule content is auto-injected into the system prompt (before the rulebook rules section).
- Rule is also addressable via
rule://<name>for re-reading.
condition, astCondition, scope, and interruptMode
conditionis the regex TTSR trigger field; legacyttsr_trigger/ttsrTriggerare accepted as fallback inputs during parsing.astConditionis the ast-grep trigger field: a string or list of structural patterns, kept verbatim (no glob inference). It only matches on edit/write tool streams, where the language is inferred from the file path. A rule may setcondition,astCondition, or both.scopenarrows TTSR matching scope. Aconditiontoken that looks like a file glob becomestool:edit(<glob>)andtool:write(<glob>)scope entries plus catch-all condition.*;astConditiontokens never trigger this shorthand.interruptModecan override the global TTSR interrupt mode for the rule.
7. System prompt inclusion path
buildSystemPromptInternal receives both rules (rulebook) and alwaysApplyRules.
Always-apply rules are deduped against custom prompt sources (dedupeAlwaysApplyRules drops a rule whose content already appears in the SYSTEM/APPEND_SYSTEM customization) and rendered first, injecting their raw content directly into the prompt (inside a <generic-rules> block in the default template).
Rulebook rules are rendered in a <domain-rules> block as - <name> (<globs>): <description> lines; the URL list in the prompt documents rule://<name> and the workflow section tells the model to read relevant rules first. The custom-prompt template (custom-system-prompt.md) instead renders <rule name="..."> entries with <glob> children under an explicit “You MUST read rule://<name>” instruction.
This is advisory/contextual: prompt text asks the model to read applicable rules, but code does not enforce glob applicability.
8. rule:// internal URL behavior
RuleProtocolHandler resolves against the process-global active-rule snapshot
installed once per top-level session in sdk.ts:
setActiveRules([...rulebookRules, ...alwaysApplyRules, ...ttsrManager.getRules()]);Implications:
rule://<name>resolves against rulebookRules, alwaysApplyRules, and registered TTSR rules.- TTSR rules are bucketed out before rulebook/always, but
ttsrManager.getRules()re-adds them to the snapshot so a triggered rule (e.g. a builtin) stays addressable for re-reading. - Rules with no description, no
alwaysApply, and no accepted TTSR condition are not addressable viarule://. - Resolution is exact name match.
- Unknown names return error listing available rule names.
- Returned content is raw
rule.content(frontmatter stripped), content typetext/markdown.
9. Known partial / non-enforced semantics
- The rule providers currently loaded for
rulesarenative,omp-plugins,agents,cursor,windsurf,cline, and embeddedbuiltin-defaults; provider files for other tools may parse other config formats but do not register rule loaders. globsmetadata is surfaced to prompt/UI and is used as a global path gate for TTSR matching, but it is not used to automatically select rulebook rules forrule://.- Rule selection for
rule://includes rulebook, always-apply, and registered TTSR rules (so a triggered TTSR rule can be re-read), but not rules that registered no condition and carry neither a description noralwaysApply. - Discovery warnings (
loadCapability("rules").warnings) are produced butcreateAgentSessiondoes not currently surface/log them in this path.
This document defines the current contract for the shared filesystem scan cache implemented in Rust (crates/pi-natives/src/fs_cache.rs) and consumed by native discovery/search APIs exposed to packages/coding-agent.
What this cache is
The cache stores full directory-scan entry lists (GlobMatch[]) keyed by scan scope, traversal policy, and requested metadata detail. Higher-level operations (glob filtering, fuzzyFind scoring, and cached grep candidate selection) run against those cached entries.
Primary goals:
- avoid repeated filesystem walks for repeated discovery/search calls
- keep consistency across native discovery/search flows when they share the same scan policy
- allow explicit staleness recovery for empty results and explicit invalidation after file mutations
Ownership and public surface
- Cache implementation and policy:
crates/pi-natives/src/fs_cache.rs - Native consumers:
crates/pi-natives/src/glob.rscrates/pi-natives/src/fd.rs(fuzzyFind)crates/pi-natives/src/grep.rs(cached directory mode only)crates/pi-natives/src/ast.rs(astGrep/astEditfile discovery; always cached)
- JS binding/export:
packages/natives/native/index.d.ts(invalidateFsScanCache)packages/natives/native/index.js
- Coding-agent mutation invalidation helpers:
packages/coding-agent/src/tools/fs-cache-invalidation.ts
Cache key partitioning (hard contract)
Each entry is keyed by:
- canonicalized
rootdirectory path include_hiddenbooleanuse_gitignorebooleanskip_node_modulesbooleandetail(ScanDetail::MinimalorScanDetail::Full)
Implications:
- Hidden and non-hidden scans do not share entries.
- Gitignore-respecting and ignore-disabled scans do not share entries.
- Scans that prune
node_modulesdo not share entries with scans that include it. - Minimal scans (path + file type only) do not share entries with full scans (mtime + regular-file size metadata).
follow_linksis part ofScanOptionsused to build the walker, but is not currently part ofCacheKey; calls that differ only byfollow_linkscan share a cache entry.
Consumers must pass stable semantics for hidden/gitignore/node_modules/detail behavior; changing any keyed flag creates a different cache partition.
Scan collection behavior
Cache population uses ignore::WalkBuilder configured by include_hidden, use_gitignore, skip_node_modules, and follow_links:
- sorted by file path
.gitis always prunednode_modulesis pruned at traversal time whenskip_node_modules=true- cancellation is checked before the walk and every 128 visited entries per parallel visitor
ScanDetail::Minimalrecords normalized relative path and file type onlyScanDetail::Fullalso records mtime and regular-file size
Search roots for cache scans are resolved by fs_cache::resolve_search_path:
- relative paths are resolved against current cwd
- target must be an existing directory
- root is canonicalized when possible
Freshness and eviction policy
Global policy (environment-overridable):
FS_SCAN_CACHE_TTL_MS(default1000)FS_SCAN_EMPTY_RECHECK_MS(default200)FS_SCAN_CACHE_MAX_ENTRIES(default16)
Behavior:
get_or_scan(...)- if TTL is
0: bypass cache entirely, always fresh scan (cache_age_ms = 0) - on cache hit within TTL: return cloned cached entries + non-zero
cache_age_ms - on expired hit: evict key, rescan, store fresh entry
- if TTL is
force_rescan(..., store=false): remove any matching key, scan fresh, and do not repopulate cacheforce_rescan(..., store=true): remove any matching key, scan fresh, then store the new entry- max entry enforcement is oldest-first eviction by
created_atafter insert
Empty-result fast recheck (separate from normal hits)
Normal cache hit:
- a cache hit inside TTL returns cached entries and does nothing else.
Empty-result fast recheck:
- this is a caller-side policy using
ScanResult.cache_age_ms - if filtered/query result is empty and cached scan age is at least
empty_recheck_ms(), caller performs oneforce_rescan(..., store=true)and retries - intended to reduce stale-negative results when files were added while the cache is still inside TTL
Current consumers:
glob: rechecks when filtered matches are empty and scan age exceeds thresholdfuzzyFind(fd.rs): rechecks only when query is non-empty and scored matches are emptygrep: rechecks when cached directory candidate file list is emptyastGrep/astEdit(ast.rs): recheck when the candidate file list is empty
Consumer defaults and cache usage
Cache is opt-in on glob/fuzzyFind/grep (cache?: boolean, default false). astGrep/astEdit file discovery always uses the cache (there is no opt-in flag).
Current defaults in native APIs:
glob:hidden=false,gitignore=true,cache=false;node_modulesis included only whenincludeNodeModules=trueor the pattern mentionsnode_modules; full detail is used only whensortByMtime=truefuzzyFind:hidden=false,gitignore=true,cache=false,node_modulesis skipped,follow_links=true, minimal detailgrep:hidden=true,gitignore=true,cache=false; cached directory mode skipsnode_modulesunless the glob mentionsnode_modules; minimal detailastGrep/astEdit(file discovery):hidden=true,gitignore=true, always cached;node_modulesis skipped unless the glob mentionsnode_modules;follow_links=false; minimal detail
Current callers:
@-mention fuzzy file autocomplete enables cache (fuzzyFindwithcache: true):packages/tui/src/autocomplete.ts
- Mutation flows invalidate through
packages/coding-agent/src/tools/fs-cache-invalidation.ts. - Tool-level search integration (
packages/coding-agent/src/tools/search.ts) currently calls nativegrepwithcache: false.
Invalidation contract
Native invalidation entrypoint:
invalidateFsScanCache(path?: string)- with
path: remove cache entries whose root is a prefix of the target path - without path: clear all scan cache entries
- with
Path handling details:
- relative invalidation paths are resolved against cwd
- invalidation attempts canonicalization
- if target does not exist (for example after delete), fallback canonicalizes the parent and reattaches the filename when possible
- this preserves invalidation behavior for create/delete/rename where one side may not exist
Coding-agent mutation flow responsibilities
Coding-agent code must invalidate after successful filesystem mutations.
Central helpers:
invalidateFsScanAfterWrite(path)invalidateFsScanAfterDelete(path)invalidateFsScanAfterRename(oldPath, newPath)(invalidates both sides when paths differ)
Current mutation callsites include:
packages/coding-agent/src/tools/write.tspackages/coding-agent/src/edit/hashline/filesystem.tspackages/coding-agent/src/edit/modes/patch.tspackages/coding-agent/src/edit/modes/replace.ts
Rule: if a flow mutates filesystem content or location and bypasses these helpers, cache staleness bugs are expected.
Adding a new cache consumer safely
When introducing cache use in a new scanner/search path:
-
Use stable scan policy inputs
- decide hidden/gitignore/node_modules/detail semantics first
- pass them consistently to
get_or_scan/force_rescanso cache partitions are intentional
-
Treat cache data as pre-filtered only by traversal policy
- apply tool-specific filtering (glob patterns, type filters, scoring) after retrieval
- never assume cached entries already reflect your higher-level filters
-
Implement empty-result fast recheck only for stale-negative risk
- use
scan.cache_age_ms >= empty_recheck_ms() - retry once with
force_rescan(..., store=true, ...) - keep this path separate from normal cache-hit logic
- use
-
Respect no-cache mode explicitly
- when caller disables cache, call
force_rescan(..., store=false, ...)or use an uncached streaming walker - do not populate shared cache in a no-cache request path
- when caller disables cache, call
-
Wire mutation invalidation for any new write path
- after successful write/edit/delete/rename, call the coding-agent invalidation helper
- for rename/move, invalidate both old and new paths
-
Do not add per-call TTL knobs
- current contract is global policy only (env-configured), no per-request TTL override
Known boundaries
- Cache scope is process-local in-memory (
DashMap), not persisted across process restarts. - Cache stores scan entries, not final tool results.
glob/fuzzyFind/cachedgrep/astGrepshare scan entries only when key dimensions (root,hidden,gitignore,skip_node_modules,detail) match..gitis always excluded at scan collection time regardless of caller options.
This document covers the media/system/conversion exports currently present in @oh-my-pi/pi-natives: terminal SIXEL image encoding, HTML conversion, clipboard access, token counting, macOS appearance/power helpers, and work profiling.
Implementation files
crates/pi-natives/src/sixel.rscrates/pi-natives/src/html.rscrates/pi-natives/src/clipboard.rscrates/pi-natives/src/tokens.rscrates/pi-natives/src/appearance.rscrates/pi-natives/src/power.rscrates/pi-natives/src/prof.rscrates/pi-natives/src/task.rspackages/natives/native/index.d.ts
There is no native PhotonImage class, image.rs, or ProjFS overlay helper module in the current pi-natives addon. General-purpose image decode/resize/encode is expected to live outside this native surface; the native image export here is only terminal SIXEL encoding.
JS API ↔ Rust export/module mapping
| JS export | Rust N-API export | Rust module |
|---|---|---|
encodeSixel(bytes, width, height) | encode_sixel | sixel.rs |
htmlToMarkdown(html, options?) | html_to_markdown | html.rs |
copyToClipboard(text) | copy_to_clipboard | clipboard.rs |
readImageFromClipboard() | read_image_from_clipboard | clipboard.rs |
countTokens(input, encoding?) | count_tokens | tokens.rs |
detectMacOSAppearance() | detect_macos_appearance | appearance.rs |
MacAppearanceObserver.start(cb) | MacAppearanceObserver::start | appearance.rs |
MacOSPowerAssertion.start(options?) | MacOSPowerAssertion::start | power.rs |
getWorkProfile(lastSeconds) | get_work_profile | prof.rs |
Data format boundaries and conversions
SIXEL image encoding (sixel)
- JS input boundary:
Uint8Arraycontaining encoded image bytes. - Rust decode boundary: format is guessed with
ImageReader::with_guessed_format(), then decoded toDynamicImage. - Resize boundary: image is resized with
resize_exact(..., FilterType::Lanczos3)only when source dimensions differ fromtargetWidthPx/targetHeightPx. - Output boundary:
encodeSixel(...)returns a SIXEL escape string synchronously.
Supported decode formats are whatever the compiled image crate supports for ImageReader in this build (commonly PNG/JPEG/WebP/GIF). Invalid target dimensions (0 width or height) fail with Target SIXEL dimensions must be greater than zero.
HTML conversion (html)
- JS input boundary: HTML
string+ optional{ cleanContent?: boolean; skipImages?: boolean }. - Rust conversion boundary: conversion is scheduled through
task::blocking("html_to_markdown", (), ...); there is no timeout/abort option on this export. - Output boundary: Markdown
stringpromise.
Conversion behavior:
cleanContentdefaults tofalse.- When
cleanContent=true, preprocessing is enabled withPreprocessingPreset::Aggressive,remove_navigation=true, andremove_forms=true. skipImagesdefaults tofalseand is passed tohtml_to_markdown_rs::ConversionOptions.
Clipboard (clipboard)
copyToClipboard(text)is a synchronous native call usingarboard::Clipboard::set_text. On Linux a single process-lifetimeClipboardinstance is kept alive (X11/Wayland selection ownership); macOS/Windows use a transient instance per call.readImageFromClipboard()runs intask::blocking("clipboard.read_image", (), ...).- Image read returns
null/undefinedwhenarboardreportsContentNotAvailable. - Successful image read converts clipboard RGBA data into PNG bytes and returns
{ data: Uint8Array, mimeType: "image/png" }. - Clipboard access or image encoding failures reject/throw as native errors.
There is no current packages/natives TS wrapper that emits OSC52, handles Termux, or suppresses native clipboard failures. Any best-effort clipboard policy must live in consumers.
Tokens (tokens)
countTokens(input, encoding?)accepts a single string or an array of strings.- Arrays return one aggregate token count; array elements are encoded in parallel via rayon.
- Default encoding is
O200kBase;Cl100kBaseis also exported. - The implementation uses
encode_ordinary, not special-token handling. - BPE tables are initialized once through
LazyLockand reused.
macOS appearance and power helpers
detectMacOSAppearance()returns"dark","light", ornullon non-macOS.MacAppearanceObserver.start(callback)returns a handle withstop(); on macOS it uses distributed notifications plus a 2-second polling fallback, and on non-macOS it is a no-op observer.MacOSPowerAssertion.start(options?)returns a handle withstop(); on macOS it acquires one or more IOKit assertions, and on other platforms it is a no-op handle.- Power assertion options are
{ reason?, idle?, system?, user?, display? }. If every boolean is unset or omitted,idlebehavior is used by default.
Work profiling (prof)
- Collection boundary: profiling samples are produced by
profile_region(tag)guards intask::blockingandtask::future. - Storage format: fixed-size circular buffer (
MAX_SAMPLES = 10_000) storing stack path, duration, and timestamp. - Output boundary:
getWorkProfile(lastSeconds)returns:folded: folded-stack text (flamegraph input)summary: markdown table summarysvg: optional flamegraph SVGtotalMs,sampleCount
Lifecycle and state transitions
SIXEL lifecycle
encodeSixel(bytes, targetWidthPx, targetHeightPx)validates target dimensions.- Rust guesses and decodes the encoded image.
- Image is resized exactly to the target dimensions when needed.
- Pixels are converted to RGBA8 and encoded with
icy_sixel::sixel_encode. - The SIXEL escape string is returned synchronously.
Failure transitions:
- Format detection/decode failure throws.
- Invalid target dimensions throw.
- SIXEL encoding failure throws with
Failed to encode SIXEL: ....
HTML lifecycle
htmlToMarkdown(html, options)schedules a blocking conversion task.- Conversion runs with defaulted options (
cleanContent=false,skipImages=false) unless specified. - Returns markdown string or rejects with
Conversion error: ....
Clipboard lifecycle
- Text copy calls
set_textsynchronously; macOS/Windows construct a transientarboard::Clipboardper call, while Linux initializes one process-lifetime instance on first copy and reuses it. - Image read constructs an
arboard::Clipboard, callsget_image, encodes PNG on success, mapsContentNotAvailabletoNone, and rejects other errors.
Work profiling lifecycle
- No explicit start: profiling is active when task helpers execute.
- Every instrumented task scope records one sample on guard drop.
- Samples overwrite oldest entries after buffer capacity is reached.
getWorkProfile(lastSeconds)reads a time window and derives folded/summary/svg artifacts.
Failure transitions:
- SVG generation failure is soft (
svgomitted/undefined), while folded and summary still return. - Empty sample windows return empty folded data and no SVG, not an error.
Unsupported operations and error propagation
SIXEL
- Unsupported or corrupted image input is a strict failure.
- Invalid SIXEL target dimensions are a strict failure.
- No JS fallback path is exposed by the natives package.
HTML
- Conversion errors are strict failures.
- Option omission is defaulting, not failure.
Clipboard
- Text copy is strict at the native API surface.
- Image read distinguishes “no image” (
null/undefined) from operational failure (rejection).
Work profiling
- Retrieval is strict for the function call itself.
- Flamegraph SVG generation is nullable/optional.
- Buffer truncation is expected ring-buffer behavior.
Platform caveats
- Clipboard access depends on OS/session support exposed through
arboard. - macOS appearance and power helpers intentionally return no-op/null behavior on unsupported platforms.
- ProjFS is not exposed by this media/system native utility surface. Isolation backend selection, including any ProjFS support, lives in the separate
isosubsystem.
Troubleshooting & Rules
@oh-my-pi/pi-ai exposes one unified schema normalizer that providers consume
before tools are sent on the wire. All walkers live in
packages/ai/src/utils/schema/normalize.ts; the operational contract is
packages/ai/src/utils/schema/CONSTRAINTS.md.
There is no separate strict-mode.ts module any more — OpenAI strict-mode
sanitization, OpenAI Responses oneOf rewriting, Google/Vertex/Gemini-CLI
sanitization, Cloud Code Assist Claude sanitization, and MCP sanitization all
share the same option-driven walk.
Entry points
All exports live under @oh-my-pi/pi-ai/utils/schema:
normalizeSchema(value, options)— generic option-driven walker.normalizeSchemaForGoogle(value)— Gemini / Vertex / Gemini CLI.normalizeSchemaForCCA(value)— Cloud Code Assist Claude (Antigravity + GCA).normalizeSchemaForMCP(value)— MCP inputSchemas before they enter the custom-tool registry.tool-bridge.tsruns every MCPinputSchemathrough this dispatcher.sanitizeSchemaForOpenAIResponses(schema)(aliasnormalizeSchemaForOpenAIResponses) — rewritesoneOf→anyOffor the Responses family.sanitizeSchemaForStrictMode(schema)andenforceStrictSchema(schema)/tryEnforceStrictSchema(schema)— the OpenAI strict-mode pipeline (sanitize → enforce). All three are exported fromnormalize.ts.adaptSchemaForStrict(schema, strict)from./adapt— thin composer that upgrades draft-07 inputs to 2020-12 and wrapstryEnforceStrictSchemafor provider call sites../adaptalso exports theNO_STRICTglobal-bypass flag (envPI_NO_STRICT) honored by every provider that emitsstrict: true.
Removed in the unified-flow refactor:
strict-mode.ts(merged intonormalize.ts).sanitize-google.tsandnormalize-cca.ts(replaced bynormalizeSchemaFor*dispatchers).StringEnumhelper — usez.enum([...])directly; Zod’s emitted JSON Schema is already wire-compatible with Google and other providers.sanitizeSchemaFor{Google,CCA,MCP}/prepareSchemaForCCA— renamed tonormalizeSchemaFor{Google,CCA,MCP}.
Dispatcher mapping
| Provider transport(s) | Dispatcher |
|---|---|
openai-completions, openai-responses, openai-codex-responses | adaptSchemaForStrict (sanitize + enforce) |
openai-responses family (oneOf → anyOf only) | normalizeSchemaForOpenAIResponses |
google-generative-ai, google-vertex, Gemini CLI | normalizeSchemaForGoogle |
Cloud Code Assist Claude (Antigravity + GCA, claude-* model ids) | normalizeSchemaForCCA |
MCP inputSchema ingestion | normalizeSchemaForMCP |
anthropic-messages (native, not CCA) | per-provider whitelist in anthropic.ts |
Gemini CLI / Antigravity CCA MUST run the full normalizeSchemaForCCA
pipeline (not just the first keyword-stripping pass) to keep parity with the
shared Google Claude path.
Walk semantics
normalizeSchema first detoxifies serialized Zod-instance-shaped inputs, upgrades them to
JSON Schema 2020-12, dereferences the tree, then walks it with the option set
pinned by the dispatcher. Each node:
- Renames
snake_casecombinator/property keys to camelCase (any_of→anyOf, etc.; collisions follow python-genaipop(from)/set(to)semantics — snake_case wins). - Applies the
handle_null_fieldscollapse for nullable unions before recursing into children. - Strips keys the target provider does not support, optionally lifting
human-meaningful keys (
pattern,format, min/max,default,examples, …) into the siblingdescriptionvia the spill formatter (spill.ts). Structural/meta keys ($ref,$defs,additionalProperties) are not spilled. - Normalizes type unions (
type: ["T", "null"]→type: "T"+ nullable marker on Google, plaintype: "T"on CCA). - Collapses object-only / same-type combiners, optionally lossy-collapses mixed-type combiners (CCA only), and runs the residual-combiner fixpoint.
- Validates with the in-house structural validator (
isValidJsonSchemafrommeta-validator.ts) whenvalidateAndFallbackis set (CCA path) and emits the per-tool fallback{ "type": "object", "properties": {} }on residual incompatibility —typearray,type: "null",nullablekey, or any remaininganyOf/oneOf/allOf.
OpenAI strict-mode pipeline
adaptSchemaForStrict(schema, strict) runs tryEnforceStrictSchema,
which composes:
- Sanitize (
sanitizeSchemaForStrictMode): strips non-structural keywords (format,pattern, min/max,examples,default,if/then/else,not,unevaluated*,patternProperties,dependent*,content*,min/maxProperties,$dynamicRef, etc.). Thedefaultvalue is inlined into the siblingdescriptionas(default: X)before being dropped, unlessdescriptionalready contains(default:or nodescriptionexists. - Enforce (
enforceStrictSchema): every object node getsadditionalProperties: false, every property goes intorequired, and optional properties become nullable unions (anyOf: [<original>, { "type": "null" }]). TupleprefixItemsare strictified recursively.
The two passes use cache/cycle guards, so refs, allOf, and nullable wrapping
stay deterministic without recursing forever. tryEnforceStrictSchema is
fail-open: if anything throws, it returns { strict: false, schema: upgraded }
so callers MUST emit strict: true only when enforcement actually succeeded.
Edge cases the strict-mode normalizer handles
- Local
$refinlining. OpenAI strict mode rejects{ "$ref": "...", "description": "..." }with sibling keys. The sanitizer pre-resolves local#/...refs against the root and merges with sibling keys winning over the resolved def — same precedence asopenai-python’s_ensure_strict_json_schema. Recursive refs are guarded by the per-walk epoch. - Single-item
allOf. A{ "allOf": [X], ...siblings }collapses to{ ...X, ...siblings }with the inlined entry’s keys winning over the original siblings (matchesopenai-python’s_pydantic.py:79-83). Multi- itemallOfis left intact for the downstream validator to reject if needed. - Type-array branches and nullable unions. When a node has
type: ["T", "U"], the sanitizer emits one variant schema per type, pruning type-specific keywords (e.g.properties/requiredonly stay on theobjectvariant,itemsonly on thearrayvariant). The shareddescriptionis hoisted onto theanyOfwrapper instead of being duplicated on every branch — so a strict nullable union becomes{ anyOf: [T, { type: "null" }], description: "..." }, notanyOf: [{ ..., description }, { ..., description }]. - Enum/const without a
type. Both sanitize and enforce paths callinferStrictPrimitiveTypeFromEnumOrConstto infer the primitivetypefromenum/constvalues. Mixed-primitive enums ([1, "two", null]), enums containing objects/arrays, and non-primitiveconstvalues ({a:1},[1,2,3]) cannot be described by a singletypekeyword and trigger the strict-mode fail-open path — emitting a typeless schema would just be rejected on the wire by OpenAI.
Performance: static fingerprint cache
resolveProviderModels in packages/catalog/src/model-manager.ts and
readModelCache/writeModelCache in packages/catalog/src/model-cache.ts
cooperate via a static_fingerprint column on the model_cache SQLite
table (current cache schema version 6).
fingerprintStatic(staticModels)hashes the static catalog slice (Bun.hash(JSON.stringify(models))in base36) and memoizes the result by tagging the array with a symbol property. Multiple cold-start arms callingresolveProviderModelswith the samestaticModelsarray pay the JSON+hash cost once.- On cache read, if the network fetch is being skipped, the cached row is
fresh + authoritative, and the cached
static_fingerprintmatches the current one,resolveProviderModelsreturns the cached models verbatim — the cache already incorporates the same static state, so re-runningmergeDynamicModels(static, cache)would just rebuild the same objects. mergeModelSourcesandmergeDynamicModelsshort-circuit on empty-source inputs (the common shape after(static, [])or for providers without a static catalog), avoiding Map churn entirely.
Cache rows written before the current schema version are dropped by the
cache-version check; the column defaults to '' for any row that survives
a version upgrade so the fingerprint-equality check naturally fails closed
and the full merge re-runs.
Related
docs/models.md— registry, equivalence, compat flags (supportsStrictMode,toolStrictMode,disableStrictTools).docs/provider-streaming-internals.md— how the normalized schemas are used downstream during the provider stream loop.docs/mcp-server-tool-authoring.md— MCPinputSchemaingestion vianormalizeSchemaForMCP.packages/ai/src/utils/schema/CONSTRAINTS.md— operational contract for every normalization rule.
The compiled macOS omp binaries shipped on GitHub Releases are signed with a
Developer ID Application certificate and notarized by Apple. This makes
them Gatekeeper-acceptable and is the prerequisite for an official Homebrew
submission (see #776).
Signing happens in CI, in the release_binary job’s darwin matrix legs
(.github/workflows/ci.yml), via scripts/ci-macos-sign.sh. It auto-skips
until the APPLE_* repository secrets below are configured, so releases keep
working (ad-hoc signed, as before) in the meantime.
How it works
ci:release:build-binariesbuilds and ad-hoc signs the binary (so it can run on the build runner).scripts/ci-macos-sign.shthen:- imports the Developer ID cert into a throwaway keychain;
- re-signs with
--options runtime --timestamp(hardened runtime + secure timestamp) and--entitlements scripts/macos-entitlements.plist; - runs
--versionand--smoke-testunder the new signature to fail fast; - notarizes the binary via
notarytool submit --wait.
release_github_verifyre-downloads the published arm64 asset and asserts it is not ad-hoc, passescodesign --verify --strict, and boots cleanly.
Why the entitlements are mandatory
The binary is a Bun single-file executable, so the hardened runtime needs:
| Entitlement | Reason |
|---|---|
com.apple.security.cs.allow-jit | JavaScriptCore JITs at runtime. |
com.apple.security.cs.allow-unsigned-executable-memory | JSC executable memory pages. |
com.apple.security.cs.disable-library-validation | omp extracts its native addon (pi_natives.<triple>.node) and other optional dylibs to a runtime cache and dlopen()s them. They do not share the main binary’s Team ID, so without this the hardened runtime aborts with “mapping process and mapped file have different Team IDs” — breaking effectively every command. |
Without disable-library-validation, a signed+notarized binary signs and
notarizes fine but fails at first real use. scripts/ci-macos-sign.sh runs
--smoke-test after signing specifically to catch this before notarizing.
Stapling limitation (important)
A bare Mach-O executable cannot be stapled (stapler only supports
.app/.pkg/.dmg). The binary is genuinely notarized — notarytool returns
Accepted and the ticket exists on Apple’s servers keyed to its cdhash — but
because there is no stapled ticket, a direct spctl -a -t exec assessment
reports rejected / source=Unnotarized Developer ID. This is expected and is
not a signing or credential failure.
What this means in practice:
curl https://omp.sh/install | sh—curlsets no quarantine bit, so Gatekeeper is never consulted; the binary just runs. ✅- Homebrew formula installs — Homebrew does not quarantine formula files, so Gatekeeper is never consulted. ✅
- Anything that quarantines the binary (a browser download, or a Homebrew
cask) and is assessed offline will be blocked, because there is no stapled
ticket. For that route, wrap the binary in a stapleable, notarized
.pkgor.dmg(xcrun stapler stapleworks on those). That is a follow-up and is not required for thecurl/formula paths.
Required GitHub secrets
Add these under Settings → Secrets and variables → Actions (repo secrets). All five secrets (cert, password, and API key trio) must be present for signing to engage.
| Secret | What it is |
|---|---|
APPLE_CERTIFICATE_P12 | base64 of the exported Developer ID Application .p12 (cert + private key). |
APPLE_CERTIFICATE_PASSWORD | password you set when exporting the .p12. |
APPLE_API_KEY_ID | App Store Connect API Key ID. |
APPLE_API_ISSUER_ID | App Store Connect API Issuer ID (UUID). |
APPLE_API_KEY | base64 of the App Store Connect .p8 private key. |
Producing the credential files
Drop these into a working directory (default ~/omp-signing):
| File | How |
|---|---|
*.p12 | Keychain Access → right-click your Developer ID Application: … identity (the entry that expands to a cert with a private key) → Export… → save as .p12 and set a password. |
p12-password.txt | the password you just set on the .p12. |
AuthKey_<KEYID>.p8 | App Store Connect → Users and Access → Integrations → App Store Connect API → create a key (Account Holder role also allows API cert creation; Developer is enough for notarization) → download once (non-recoverable). |
issuer-id.txt | the Issuer ID (UUID) shown above the keys table. |
key-id.txt | optional — the Key ID; otherwise read from the .p8 filename. |
The App Store Connect API key is the one credential that cannot be minted
from a CLI — it is the bootstrap credential for the API itself, and the .p8
downloads exactly once. Everything else is local.
Uploading (no value leaves disk)
scripts/ci-macos-upload-secrets.sh validates the files (opens the .p12 with
your password, sanity-checks the .p8) and pipes each value to gh secret set
over stdin — no secret is ever printed to the terminal, argv, or shell history:
scripts/ci-macos-upload-secrets.sh ~/omp-signing --dry-run # validate first
scripts/ci-macos-upload-secrets.sh ~/omp-signing # upload all five
gh secret list --repo can1357/oh-my-pi # confirmRe-run it whenever the certificate is renewed.
Finding your signing identity / Team ID (sanity check)
security find-identity -v -p codesigning
# e.g. "Developer ID Application: Your Name (TEAMID1234)"The script selects the first Developer ID Application identity automatically;
you do not need to store the identity string or Team ID as a secret.
Local dry run
You can exercise the full sign+notarize path locally (real cert + API key) by exporting the five env vars and running:
RELEASE_TARGETS=darwin-arm64 bun run ci:release:build-binaries
APPLE_CERTIFICATE_P12=… APPLE_CERTIFICATE_PASSWORD=… \
APPLE_API_KEY_ID=… APPLE_API_ISSUER_ID=… APPLE_API_KEY=… \
bash scripts/ci-macos-sign.sh packages/coding-agent/binaries/omp-darwin-arm64Historical research note, not a current runtime contract. The statistics below come from the named local stats database snapshot, not from checked-in tests or runtime code.
1. The problem
OpenAI frames tool calls in the Harmony chat protocol:
<|start|>assistant<|channel|>commentary to=functions.<NAME><|message|>{ARGS}<|call|>
<|channel|>commentary to=functions.NAME is the routing header —
control tokens consumed by the runtime to dispatch the call. These
tokens never appear as content under normal operation; the runtime
strips them.
The defect: gpt-5 models occasionally emit, as ordinary content
inside {ARGS}, the plain-text shadow of these routing tokens —
the same characters without the <|…|> brackets — and continue
producing more pseudo-routing structure (channel name, body marker,
multilingual spam, fake tool-result framing). The contamination lives
inside the visible tool argument and is dispatched to the tool as if it
were intended content.
Critical detail. The actual <|start|> / <|channel|> /
<|message|> / <|call|> special tokens almost never appear in tool
args. What leaks is the bracket-less spelling — analysis to=functions.X code … — because OpenAI applies a logit mask suppressing the
control-token IDs inside the args region. The mass that would have gone
to those special tokens redistributes onto the un-bracketed plain-text
representation the model also learned. This makes the leak structurally
invisible to the routing parser and lands it in the tool input verbatim.
Manifestation in tool args (real corpus example):
~ add_function(iso, ctx, ns, "installSystemChangeObserver",
os_install_system_change_observer);】【"】【analysis to=functions.edit
code above เงินไทยฟรีuser to=functions.edit code …
The leading code is real and intended. Everything after the first non-Latin token through the next clean structural boundary is corruption.
2. Observed statistics & failure modes
Source: ~/.omp/stats.db (ss_tool_calls, ss_assistant_msgs), through
2026-05-10. 1.05M tool calls scanned.
2.1 Rate
| Model | Leaks in tool args | Calls | per million |
|---|---|---|---|
| gpt-5.4 | 37 | 226,957 | 163 |
| gpt-5.3-codex | 17 | 112,243 | 151 |
| gpt-5.5 | 2 | 80,750 | 25 |
| gpt-5.2-codex | 0 | — | — |
Plus 15 hits in assistant visible text / thinking blobs.
2.2 Tool distribution
| Tool | Hits |
|---|---|
edit | 38 |
eval | 11 |
report_tool_issue | 3 |
grep/read/search/yield | 1 each |
Concentrated in tools with free-form (non-JSON-schema) argument formats.
2.3 Leak shape (deterministic)
LEAK ::= JUNK_PREFIX MARKER CHANNEL_BODY (LEAK)?
MARKER ::= "to=functions." TOOL_NAME
CHANNEL_BODY ::= " code " (SPAM | reasoning_prose | fake_tool_output)*
JUNK_PREFIX ::= (GLITCH_TOKEN | CHANNEL_WORD | NON_LATIN_RUN | "}" | "】【")+
Cascading is common. Of 96 marker occurrences across 71 contaminated
records, 39 contain ≥2 markers and 7 contain ≥3 — the model emits
multiple fake to=functions.X code … blocks back-to-back, often with
fake code_output\nCell N:\n… framing between them. Once the
plain-text scaffolding is in the residual stream, the prefix now looks
like a fresh tool envelope start, so the macro prior over continuations
keeps voting for more scaffolding. Self-amplifying.
2.4 Glitch tokens
Single-token identifiers in o200k_base whose embeddings appear to be
near-init from underrepresentation in post-training. ASCII residue
immediately before the marker in the natural corpus:
| Surface string | Single-token | Token ID | Hits in corpus |
|---|---|---|---|
Japgolly | ✅ | 199,745 | 1 |
Jsii | ✅ | 114,318 | (subtoken of Jsii_commentary) |
Jsii_commentary | — (3 toks) | — | 2 |
changedFiles | — (2 toks) | — | 8 |
RTLU | — (2 toks) | — | 3 |
Japgolly is in the last 0.13% of the vocabulary — the same family of
GitHub-corpus residue that produced SolidGoldMagikarp in the 2023
GPT-2 vocabulary (Rumbelow & Watkins). SolidGoldMagikarp itself
tokenizes to 5 tokens in o200k_base — that specific token was retired,
but the class wasn’t.
For the multi-token entries, the corpus-level signature is the surface
string; the underlying glitch trigger is a sub-token (e.g. Jsii inside
Jsii_commentary). The detector list (G signal) keys on the surface
strings.
Stable across unrelated sessions. Treated as a high-precision detector signal.
2.5 Channel-word leakage
analysis (5), assistant (5), commentary (3), user (1) appear
directly preceding to=. Always bare words; never <|channel|>analysis
or any other bracketed form. Consistent with §1 — the brackets are
masked, the words are not.
2.6 Non-Latin spam residue
96 marker hits, by script: CJK 40, Cyrillic 12, Telugu/Kannada/Malayalam
18, Thai 8, Georgian 7, Armenian 7, Arabic 1. Recurring fragments are
Chinese gambling SEO (大发时时彩, 天天中彩票), Georgian/Abkhaz junk,
and Thai casino spam — well-known low-quality crawl residue.
This is the same script distribution observed in the controlled reproduction (§7.3), independent of the prompt’s natural language.
2.7 Failure-mode breakdown for the edit tool
The edit tool exists in two variants in the corpus:
| Variant | Calls | Recovery |
|---|---|---|
Patch-DSL ([PATH#TAG]/anchor/SWAP DEL INS ops) | 27 | Recoverable by op-truncation (§3.3) |
JSON-schema ({path,edits:[…]}) | 11 | Not recoverable — contamination is escaped inside JSON strings, parser accepts it cleanly, content would be written verbatim into source files |
For Patch-DSL leaks specifically:
- 20/27 cases: contamination on the last input line; nothing follows.
- 7/27 cases: contamination mid-input; what follows is one of: a duplicate replay of an earlier file/anchor, intended content for a different tool call (the model started its next call inline), or pure hallucination. Post-contamination content is never trustworthy.
2.8 Mechanism (confirmed)
Prior collapse from null-embedding glitch tokens, into a control-token-masked basin whose mass redistributes onto the plain-text shadow of the Harmony protocol.
Step by step:
- The model is mid-
{ARGS}of a Harmony tool call. The runtime applies a logit mask suppressing structural control tokens (<|channel|>,<|message|>,<|call|>,<|start|>,<|end|>) inside the args region. Without this mask, normal generation would constantly hallucinate envelope-closes; with it, those token IDs have logit-∞in args. - A glitch token
gis sampled. By constructiongwas in the BPE merge corpus but barely in LM/RL training, so its input embeddinge_g≈ near-init noise of small norm. - At position t+1, the residual update
h_{t+1} ≈ LN(h_t + e_g + Attn + MLP)is dominated by the prefix-derived terms; the just-emitted-token signal is effectively absent. Generation diversity normally comes frome_xsteering the residual into different sub-regions — stripped here. - The next-token distribution therefore collapses onto the conditional prior over continuations of the prefix, with local conditioning removed. In a tool-calling rollout context, that prior is sharply peaked on Harmony scaffolding (control tokens + routing tokens) — that’s what RL trained.
- The mask zeros the control-token IDs. Mass redistributes onto the
next-best continuation: the un-bracketed surface-form spelling of
the same protocol (
analysis,commentary,to=functions.X,code). This spelling is unmasked because those characters are ordinary tokens. - Once a few tokens of plain-text scaffolding land in the residual stream, the prefix now resembles a fresh envelope start. The macro prior keeps voting for more scaffolding. Cascading (§2.3) follows.
- Multilingual spam after the marker is the same prior-collapse continuation, drawn from the training neighborhood of the glitch token (often ESL/auto-generated multilingual web junk — exactly the crawl residue in §2.6).
Two corollaries the corpus data demanded but only the experiment explained:
- The brackets never appear (§1, §2.5). The mask is what makes the leak land in plain text instead of as a real envelope-close.
- Counterintuitive grammar dependency (§7.4). The leak is worse in
formats closest to OpenAI’s training distribution. Off-distribution
custom grammars dampen the macro-prior basin; the official
*** Begin Patchformat is the strongest collapse target.
The 2023 SolidGoldMagikarp paper documented mechanism (1)+(2)+(4). The new piece is (5): when constrained decoding masks the natural collapse target, the mass laundered through the un-masked plain-text shadow becomes a structurally-invisible exfiltration channel.
How the coding-agent assembles the system prompt sent to the model, and what users can control via SYSTEM.md, APPEND_SYSTEM.md, and the matching CLI flags.
Primary implementation:
packages/coding-agent/src/system-prompt.ts(buildSystemPrompt,loadSystemPromptFiles)packages/coding-agent/src/main.ts(discoverSystemPromptFile,discoverAppendSystemPromptFile)packages/coding-agent/src/prompts/system/system-prompt.md(default stable instruction template)packages/coding-agent/src/prompts/system/custom-system-prompt.md(internal custom-prompt template; not the normal CLISYSTEM.mdpath)packages/coding-agent/src/prompts/system/project-prompt.md(project/environment footer)
1) Inputs
Four user-controllable inputs feed prompt assembly. All four resolve a value as either a literal string or, if the argument looks like a file path, the contents of that file (resolvePromptInput).
| Input | Source | Effect |
|---|---|---|
--system-prompt <text-or-file> | CLI flag | Replaces block 0: the default stable instructions. Highest precedence. |
SYSTEM.md | <cwd>/.omp/SYSTEM.md, then ~/.omp/agent/SYSTEM.md (and equivalent paths under .claude, .codex, .gemini) | Same effect as --system-prompt; used when the flag is absent. |
--append-system-prompt <text-or-file> | CLI flag | Adds a prompt block. Without a custom system prompt it goes after all default blocks; with one it goes after the custom block and before the preserved project/environment footer. |
APPEND_SYSTEM.md | Same discovery as SYSTEM.md | Same effect as --append-system-prompt; used when the flag is absent. |
Discovery for SYSTEM.md / APPEND_SYSTEM.md uses findConfigFile (packages/coding-agent/src/config.ts): the first existing file across the ordered bases (.omp, .claude, .codex, .gemini — project-level at <cwd> first, then user-level at ~) wins. No ancestor walk-up. Running omp from <repo>/subdir does not pick up <repo>/.omp/SYSTEM.md; the file must live directly under the cwd’s config base or in the user-level location. See docs/config-usage.md for the full discovery contract.
Precedence (highest first):
--system-prompt- project
SYSTEM.md - user
SYSTEM.md
For append, the same precedence applies between --append-system-prompt, project APPEND_SYSTEM.md, and user APPEND_SYSTEM.md.
2) Replace vs. append
Normal CLI startup builds the default provider-facing prompt blocks first, then applies CLI / discovered file overrides in packages/coding-agent/src/main.ts:
if (resolvedSystemPrompt && resolvedAppendPrompt) {
options.systemPrompt = defaultPrompt => [resolvedSystemPrompt, resolvedAppendPrompt, ...defaultPrompt.slice(1)];
} else if (resolvedSystemPrompt) {
options.systemPrompt = defaultPrompt => [resolvedSystemPrompt, ...defaultPrompt.slice(1)];
} else if (resolvedAppendPrompt) {
options.systemPrompt = defaultPrompt => [...defaultPrompt, resolvedAppendPrompt];
}The default blocks come from buildSystemPrompt:
- block 0:
system-prompt.md— the stable default instructions (staff-engineer preamble, tool inventory, exploration rules, workflow rules, etc.); - block 1, when non-empty:
project-prompt.md— dynamic project/environment context (workstation info, context files, dir-context list, workspace tree, current date/cwd, and other project footer content).
Consequences for normal CLI use:
- Providing
--system-promptorSYSTEM.mdreplaces only block 0. The stable default instructions are removed, but the dynamic project/environment footer fromproject-prompt.mdremains asdefaultPrompt.slice(1). - Providing
--append-system-promptorAPPEND_SYSTEM.mdwithout a custom system prompt appends a new block after all default blocks. - Providing both a custom system prompt and an append prompt produces: custom system prompt block, append prompt block, then the preserved dynamic project/environment footer.
If you want to keep both default blocks and add to them, use --append-system-prompt / APPEND_SYSTEM.md without --system-prompt / SYSTEM.md. If you want to replace the stable default instructions while keeping the dynamic footer, use --system-prompt / SYSTEM.md.
3) Templating contract
Contents of SYSTEM.md, APPEND_SYSTEM.md, --system-prompt, and --append-system-prompt are treated as plain text. They are resolved before prompt-block replacement and are not rendered as Handlebars templates.
The built-in prompt templates are Handlebars (packages/utils/src/prompt.ts), but user-provided strings are not compiled with that renderer. The secondary capability path can insert systemPromptCustomization into a Handlebars parent template, but a {{value}} reference in Handlebars still does not recursively render its substituted contents — the value is emitted as a string. Concretely:
{{! parent template — handled by Handlebars }}
{{#if systemPromptCustomization}}
{{systemPromptCustomization}}
{{/if}}If SYSTEM.md contains:
Working in {{cwd}} on {{date}}.
{{#if hasMemoryRoot}}Memory enabled.{{/if}}the rendered output contains those characters verbatim — {{cwd}}, {{#if hasMemoryRoot}}, etc. are NOT substituted. They will be shown to the model as literal Handlebars syntax.
This is by design. The internal template variables (cwd, date, environment, workspaceTree, skills, rules, toolRefs, hasMemoryRoot, hasObsidian, mcpDiscoveryServerSummaries, …) are not a supported public surface — they change between releases as the prompt is rewritten, and they would couple user configs to internals. Treat them as private.
If a future release exposes a templating surface for SYSTEM.md, it will be opt-in (e.g. via a settings flag or a different filename) and documented here.
4) Recommended patterns
”Tweak the default” — keep default, add a few rules
Use APPEND_SYSTEM.md (or --append-system-prompt) without SYSTEM.md. The default stable instructions and the dynamic project/environment footer stay intact; your text is appended as an additional block.
# ~/.omp/agent/APPEND_SYSTEM.md
Prefer Bun APIs over Node APIs in this project.
When you change a public function, run `bun check` before yielding.”Replace the stable default instructions” — bring your own base prompt
Use SYSTEM.md (or --system-prompt). You replace the stable default instructions in block 0, but normal CLI startup still preserves the dynamic project/environment footer block (project-prompt.md): workstation info, context files, dir-context list, workspace tree, current date, cwd, and related project context.
# ~/.omp/agent/SYSTEM.md
You are a code reviewer. Read diffs, surface issues, never edit files.
- Cite paths with backticks.
- Prefer concrete fixes over abstract advice.If you do this and want default tool guidance, exploration rules, or workflow rules, copy what you need from packages/coding-agent/src/prompts/system/system-prompt.md and maintain it yourself — there is currently no way to inherit selected sections from that stable default instruction block.
”Customize while keeping generated skills/rules/tool guidance”
Use APPEND_SYSTEM.md, not SYSTEM.md. Skills, rulebook summaries, always-apply rules, the tool inventory, and the built-in guidance that tells the model when to read skill://<name> are part of block 0 (system-prompt.md). Because SYSTEM.md replaces block 0, those generated lists are not available to the model in a custom system prompt.
The dynamic project/environment footer that remains after SYSTEM.md is only block 1 (project-prompt.md): workstation info, AGENTS.md context files, dir-context list, workspace tree, current date, cwd, and related project context. It does not include discovered skills.
There is currently no supported CLI mode for “replace the stable default instructions but keep the generated skills/rules/tool guidance.” If you need automatic skills loading, keep the default block and add your customization via APPEND_SYSTEM.md. If you fully replace with SYSTEM.md, you must hard-code any skill names/instructions you want the model to know about, and those will not track discovery automatically.
”Customize automatic session titles”
SYSTEM.md and APPEND_SYSTEM.md do not affect the model call that names a new session. Create the title-specific prompt file instead:
# ~/.omp/agent/TITLE_SYSTEM.md
Generate a session name using lowercase `<type>:<primary-objective>`.
If the message carries no concrete task, output exactly `none`.TITLE_SYSTEM.md is discovered with the same project-then-user config-directory pattern as SYSTEM.md / APPEND_SYSTEM.md. When absent, OMP uses the bundled title-system.md / tiny-title-system.md prompts. When present, the online title path still forces the set_title tool call, and the local tiny-model path keeps the <title>...</title> wrapper while using this file as the system turn.
”Replace everything, including project context” — SDK-only
The normal CLI file/flag path intentionally preserves defaultPrompt.slice(1). Code using CreateAgentSessionOptions.systemPrompt directly can return a full replacement array and omit the project footer, but that is not what .omp/SYSTEM.md, ~/.omp/agent/SYSTEM.md, or --system-prompt do.
”Replace, but keep one section of the default instructions” — not directly supported
There is no built-in way to inherit specific sections from system-prompt.md while replacing the rest. The supported CLI modes are: append to the default prompt, or replace block 0 and keep the dynamic footer.
5) Deduplication
The CLI path avoids double-injecting discovered SYSTEM.md by replacing block 0 after the default prompt blocks are rendered. Any systemPromptCustomization from the secondary capability path would have been rendered into block 0, and that block is discarded when main.ts applies [resolvedSystemPrompt, ...defaultPrompt.slice(1)].
Inside buildSystemPrompt itself, secondary customization and always-apply rules are still deduplicated:
dedupePromptSourcedrops asystemPromptCustomizationblock when it already appears in an internally suppliedcustomPromptor append prompt.dedupeAlwaysApplyRulesomits always-apply rules whose body appears verbatim in any of{customPrompt, appendPrompt, systemPromptCustomization}.
6) Discovery paths
Only one path actually drives the customization a CLI user sees: the primary CLI path. The capability layer exists but its SYSTEM.md output never reaches the rendered prompt under normal CLI startup.
- The primary CLI path (
discoverSystemPromptFile/discoverAppendSystemPromptFileinmain.ts, which feedsresolvedSystemPrompt/resolvedAppendPrompt) callsfindConfigFile.findConfigFilechecks only<cwd>/.omp,<cwd>/.claude,<cwd>/.codex,<cwd>/.gemini, and the user-level equivalents — it does not walk up ancestors. Files in<ancestor>/.omp/SYSTEM.mdare ignored whenompis started from a subdirectory. - The secondary capability path (
loadSystemPromptFiles→ builtin discovery) does walk up viafindNearestProjectConfigDirand requires the project.omp/directory to be non-empty. Its result is rendered into the template variablesystemPromptCustomization. Under normal CLI startup the default template (system-prompt.md) never references that variable, so ancestor-walk capability content has no user-visible effect.
Net effect for CLI users: put SYSTEM.md / APPEND_SYSTEM.md directly under <cwd>/.omp (or another supported config base under cwd) or in the user-level location (~/.omp/agent/SYSTEM.md etc.). Ancestor paths are not searched.
7) Quick reference
| Goal | Use |
|---|---|
| Add an instruction on top of the full default prompt | APPEND_SYSTEM.md or --append-system-prompt |
| Replace the stable default instructions but keep project/environment context | SYSTEM.md or --system-prompt |
| Preserve generated skills/rules/tool guidance while customizing | APPEND_SYSTEM.md; SYSTEM.md replaces that generated block |
| Customize automatic session titles | TITLE_SYSTEM.md; chat-turn SYSTEM.md / APPEND_SYSTEM.md do not affect title generation |
Use {{cwd}} / {{date}} / other internals in my file | Not supported. Files are inserted verbatim. |
Inherit specific sections from system-prompt.md | Not supported; use append, or copy what you need into SYSTEM.md. |
| Override at a per-repo level | Project .omp/SYSTEM.md under the cwd you launch omp from |
| Override globally | ~/.omp/agent/SYSTEM.md or ~/.omp/agent/APPEND_SYSTEM.md |