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.

![omp TUI: `LSP references` returns five hits across three files for the symbol `formatBytes`, then `LSP rename` applies the change with edits to format.ts/report.ts/cli.ts, then a `Search formatBytes 0 matches` confirmation. Final line: 'Rename complete. Five edits across three files…'.](https://omp.sh/captures/lsp.webp)

### 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.

![omp TUI: a live lldb-dap session against a native binary at /tmp/omp-native/demo. Adapter=lldb-dap, Status=stopped, Frame=xorshift32, Instruction pointer 0x10000055C, Location demo.c:6:10. Debug scopes and Debug variables cards show locals (x = 57351) and the agent confirms the math: x went from 7 → 57351 (= 7 ^ (7<<13)).](https://omp.sh/clips/dap-poster.webp)

_[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.

![omp TUI: agent reading src.rs and about to write Box::leak when the request aborts (red `Error: Request was aborted`), an amber `⚠ Injecting rule: box-leak` card injects the rule body `Don't reach for Box::leak in production code paths`, and the agent then course-corrects by proposing `Arc<str>` and asking the user to confirm.](https://omp.sh/clips/ttsr-poster.webp)

_[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.

![omp TUI showing `task` spawning two subagents `ComponentsExports` and `RoutesExports`, the constraints block requiring an IRC DM between peers, the per-subagent status cards with cost and duration, and a final Findings section listing both exports plus an honest 'IRC coordination note' about a one-sided handshake.](https://omp.sh/clips/irc-poster.webp)

_[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.

![omp TUI: /advisor status shows the advisor running on openai-codex/gpt-5.5; after the main agent scopes a catch to ENOENT instead of swallowing every error, an amber 'Advisor 1 note (concern)' card warns the fix no longer matches the user's literal acceptance criterion.](https://omp.sh/clips/advisor-poster.webp)

_[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.

![omp TUI: /collab view prints 'Collab session started!' with an omp join command, a my.omp.sh browser link, the note 'Anyone with this link can watch the session but cannot prompt the agent', and a large scannable QR code.](https://omp.sh/clips/collab-poster.webp)

_[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.

![omp TUI: web_search returns 10 ranked Perplexity sources for inference-time compute scaling, the agent picks an arxiv paper, calls read https://arxiv.org/pdf/2604.10739v1, and summarizes the paper's headline result with real numbers.](https://omp.sh/clips/web-poster.webp)

_[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://*`.

![omp TUI: ✓ Read src/session.ts (⚠ 1 conflict), then ✓ Write conflict://1 · 1 line with content @theirs, then a confirmation 'Resolved.'](https://omp.sh/clips/conflict-poster.webp)

_[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.

![omp TUI: ✓ AST Edit: console.log($X) (proposed) 3 replacements · 1 file, then ✓ Accept: 3 replacements in 1 file (AST Edit), followed by 'Applied 3 replacements in src/auth.ts.'](https://omp.sh/clips/codemod-poster.webp)

_[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.

![omp TUI driving the browser tool against DuckDuckGo](https://omp.sh/captures/browser.webp)

## 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.

![omp TUI: the ask tool renders an option picker with three choices, a (Recommended) badge on the first, and 'up/down navigate · enter select · esc cancel' footer.](https://omp.sh/captures/ask.webp)

### 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 toolACP route
bashterminal/create + terminal/output
readfs/read_text_file
writefs/write_text_file
edit, bashsession/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 dev

bun 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 -- --version

Debug Command

/debug opens tools for debugging, reporting, and profiling.

For architecture and contribution guidelines, see DEVELOPMENT.md.


Monorepo Packages

PackageDescription
collab-webBrowser guest client, mock host, and local relay for collab live sessions
pi-aiMulti-provider LLM client with streaming and model/provider integration
pi-catalogModel catalog: bundled model database, provider descriptors, and identity
pi-agent-coreAgent runtime with tool calling and state management
pi-coding-agentInteractive coding agent CLI and SDK
pi-tuiTerminal UI library with differential rendering
pi-nativesN-API bindings for grep, shell, image, text, syntax highlighting, and more
omp-statsLocal observability dashboard for AI usage statistics
pi-utilsShared utilities (logging, streams, dirs/env/process helpers)
pi-wireShared collab live-session protocol types and relay constants
hashlineLine-anchored patch language and applier behind the edit tool
pi-mnemopiLocal SQLite memory engine for Oh My Pi agents
snapcompactBitmap-frame context compression package and SQuAD eval suite
swarm-extensionSwarm orchestration extension package

Rust Crates

CrateDescription
pi-nativesCore Rust native addon (N-API cdylib) used by @oh-my-pi/pi-natives; aggregates the crates below
pi-shellEmbedded shell / PTY / process management split out of pi-natives (wraps brush-*)
pi-asttree-sitter-based code summarizer and AST utilities (50+ language grammars)
pi-isoTask isolation backend resolver: APFS clones, btrfs/zfs reflinks, overlayfs, projfs, rcopy
brush-core-vendoredVendored fork of brush-shell for embedded bash execution
brush-builtins-vendoredVendored 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

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:

  1. native (OMP) — priority 100
  2. omp-plugins (extension packages) — priority 90
  3. claude — priority 80
  4. claude-plugins — priority 70
  5. agents (.agent/.agents standard dirs) — priority 70
  6. codex — priority 70
  7. opencode — priority 55

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.all and 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 SlashCommand items

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:

  1. parse frontmatter/body (parseFrontmatter)
  2. description source:
    • frontmatter.description if present
    • else first non-empty body line (max 60 chars with ...)
  3. keep parsed body as executable template content
  4. compute a display source string like via Claude Code Project

Frontmatter parse severity is source-dependent:

  • native level -> parse errors are fatal
  • user/project levels -> parse errors are warn with 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>) when skills.enableSkillCommands is 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 /move changes working directory (handleMoveCommand -> applyCwdChange, which calls resetCapabilities() then refreshSlashCommandState(newCwd))
  • when the editor component is swapped (setEditorComponent re-runs refreshSlashCommandState())

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

  1. Extension commands (#tryExecuteExtensionCommand)
    If /name matches extension-registered command, handler executes immediately and prompt returns.
  2. TypeScript custom commands and MCP prompt commands (#tryExecuteCustomCommand) Boundary only: if matched, it executes and may return:
    • string -> replace prompt text with that string
    • void/undefined -> treated as handled; no LLM prompt
  3. File-based slash commands (expandSlashCommand)
    If text still starts with /, attempt markdown command expansion.
  4. Prompt templates (expandPromptTemplate)
    Applied after slash/custom processing.
  5. 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: $ARGUMENTS and $@
    • template rendering via prompt.render with { args, ARGUMENTS, arguments }
    • inline-argument fallback append when the template did not use an inline argument placeholder

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 streamingBehavior is 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 via session.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+P

Chord 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 IDDefaultMeaning
app.model.cycleForwardCtrl+PCycle role models forward
app.model.cycleBackwardShift+Ctrl+PCycle role models backward
app.model.selectTemporaryAlt+PPick a model temporarily for this session
app.model.selectAlt+MOpen the model selector and set roles
app.plan.toggleAlt+Shift+PToggle plan mode
app.history.searchCtrl+RSearch prompt history
app.tools.expandCtrl+OToggle tool-output expansion
app.thinking.toggleCtrl+TToggle thinking-block visibility
app.thinking.cycleShift+TabCycle thinking level
app.editor.externalCtrl+GEdit the draft in $VISUAL / $EDITOR
app.message.followUpCtrl+Q, Ctrl+EnterQueue a follow-up message
app.message.dequeueAlt+UpDequeue a queued message back into the editor
app.retryAlt+RRetry the last failed assistant turn
app.display.resetCtrl+LReset terminal display
app.clipboard.copyLineAlt+Shift+LCopy the current line
app.clipboard.copyPromptAlt+Shift+CCopy the whole prompt
app.clipboard.pasteImageCtrl+V (Alt+V fallback on Windows)Paste from the clipboard (image preferred, text fallback)
app.stt.toggleUnbound (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, .env files, 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

ScopePathRead behaviorWrite behavior
Global~/.omp/agent/config.ymlThe main persistent settings file. Always loaded./settings, omp config set, and omp config reset write here.
Global legacy~/.omp/agent/settings.jsonMigrated 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.jsonStill read; project config.yml is merged on top of it.Not written by settings commands.
CLI overlayAny file passed with --config <file>Loaded after global and project settings, for that one process. Repeatable.Never persisted.
Runtime overridesIn-memory onlySet 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/.yaml path is requested and only a sibling .json exists, it is migrated to YAML automatically (idempotent, once per process).
  • .json and .jsonc configs 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 --config overlays.

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 directory

For users who want the full first-run animation on normal launches, set startup.showSplash:

omp config set startup.showSplash true

This 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

CommandEffect
omp config listPrint 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 pathPrint 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.

TypeAccepted inputNotes
booleantrue, false, yes, no, on, off, 1, 0Case-insensitive. Anything else is rejected.
numberAny finite JavaScript numberInfinity/NaN are rejected.
enumOne of the key’s allowed valuesMust match exactly; the error lists the valid values.
arrayA JSON arraye.g. '["anthropic","openai"]'. Must parse and be an array.
recordA JSON objecte.g. '{"bash":"prompt"}'. Must parse and be a non-array object.
stringStored 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 overrides

From highest to lowest:

  1. 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.
  2. CLI config overlays — each --config <file>; later overlay files override earlier ones.
  3. Project settings<cwd>/.omp/settings.json then <cwd>/.omp/config.yml (and contributions from other discovery providers at project level).
  4. Global settings~/.omp/agent/config.yml.
  5. 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 varOverrides settingNotes
PI_SMOL_MODELmodelRoles.smolAlso exposed as --smol.
PI_SLOW_MODELmodelRoles.slowAlso exposed as --slow.
PI_PLAN_MODELmodelRoles.planAlso exposed as --plan.
PI_NO_PTY=1(disables PTY bash)Equivalent to --no-pty for the process.
PI_PYeval.pyPI_PY=0 disables the Python eval backend.
PI_JSeval.jsPI_JS=0 disables the JavaScript eval backend.
PI_TINY_DEVICEproviders.tinyModelDeviceONNX execution provider for local tiny models.
PI_TINY_DTYPEproviders.tinyModelDtypeONNX precision for local tiny models.
OMP_AUTH_BROKER_URLauth.broker.urlEnv value takes precedence over config.
OMP_AUTH_BROKER_TOKENauth.broker.tokenEnv 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: allow

Worked 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:
  - groq

Effective 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 array

Array 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: titanium

Keep 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
      - openai

Bare 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 (for enabledModels) or providers (for disabledProviders)
  • values or items (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 kindExample idsEffect
Model providersanthropic, openai, gemini, groq, ollama, openrouterRemoves those backends from model selection, even when credentials are available. See Providers.
Discovery sourcesnative, claude, codex, gemini, github, opencode, cursor, agents-mdStops 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:
  - groq

The 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
KeyTypeDefaultNotes
modelRolesrecord{}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.
modelTagsrecord{}Custom role/tag metadata; can introduce additional roles.
modelProviderOrderarray[]Preferred provider order when a model id is ambiguous.
cycleOrderarray["smol","default","slow"]Roles cycled by the model switcher.
enabledModelsarray[]Allow-list of models; supports path-scoped entries. Empty means all available models.
disabledProvidersarray[]Disabled model/discovery providers; supports path-scoped entries. See above.
includeModelInPromptbooleantrueInclude 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.

KeyTypeDefaultNotes
advisor.enabledbooleanfalseEnable the advisor runtime when modelRoles.advisor resolves to an available model.
advisor.subagentsbooleanfalseAlso enable advisor runtimes for spawned task/eval subagents.
advisor.syncBacklogenumoffBounded 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.immuneTurnsnumber3After 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
KeyTypeDefaultValues
defaultThinkingLevelenumhighminimal, low, medium, high, xhigh, auto. Override per run with --thinking.
hideThinkingBlockbooleanfalseHide thinking blocks in output. --hide-thinking sets it for the run (display only).
thinkingBudgets.minimalnumber1024Token budget for the minimal level.
thinkingBudgets.lownumber2048Token budget for low.
thinkingBudgets.mediumnumber8192Token budget for medium.
thinkingBudgets.highnumber16384Token budget for high.
thinkingBudgets.xhighnumber32768Token budget for xhigh.

Sampling

A value of -1 means “use the provider/model default” — omp does not send that parameter.

KeyTypeDefaultNotes
temperaturenumber-1Sampling temperature.
topPnumber-1Nucleus sampling.
topKnumber-1Top-K sampling.
minPnumber-1Minimum-probability cutoff.
presencePenaltynumber-1Presence penalty.
repetitionPenaltynumber-1Repetition penalty.
serviceTierenumnonenone, auto, default, flex, scale, priority, openai-only, claude-only.
personalityenumdefaultdefault, friendly, pragmatic, none.

Retry and fallback

retry:
  enabled: true
  maxRetries: 10
  baseDelayMs: 500
  maxDelayMs: 300000
  modelFallback: true
  fallbackRevertPolicy: cooldown-expiry
KeyTypeDefaultNotes
retry.enabledbooleantrueRetry transient provider errors.
retry.maxRetriesnumber10Max retries per request.
retry.baseDelayMsnumber500Initial backoff.
retry.maxDelayMsnumber300000Backoff ceiling (5 min).
retry.modelFallbackbooleantrueFall back to another model when one is unavailable.
retry.fallbackChainsrecord{}Per-model fallback chains.
retry.fallbackRevertPolicyenumcooldown-expirycooldown-expiry, never.

Tools and approvals

tools:
  approvalMode: yolo          # default
  approval:
    bash: prompt
    edit: allow
  discoveryMode: auto
  maxTimeout: 0
  intentTracing: true
KeyTypeDefaultNotes
tools.approvalModeenumyoloalways-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.approvalrecord{}Per-tool policy keyed by tool name; each value is allow, deny, or prompt. e.g. omp config set tools.approval '{"bash":"prompt"}'.
tools.discoveryModeenumautoauto, off, mcp-only, all. Controls dynamic tool discovery.
tools.essentialOverridearray[]Tool names kept available even when tools are narrowed.
tools.maxTimeoutnumber0Max tool runtime in seconds; 0 = no cap.
tools.intentTracingbooleantrueRecord per-call intent strings.
tools.outputMaxColumnsnumber768Per-line byte cap for streaming output; 0 disables.
tools.artifactSpillThresholdnumber50KB of tool output above which output spills to an artifact.
tools.artifactHeadBytesnumber20KB of head kept inline on spill; 0 = tail-only.
tools.artifactTailBytesnumber20KB of tail kept inline on spill.
tools.artifactTailLinesnumber500Max 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
KeyTypeDefaultNotes
bash.enabledbooleantrueEnable the bash tool.
bash.stripTrailingHeadTailbooleantrueStrip trailing head/tail noise from output.
bash.autoBackground.enabledbooleanfalseAuto-background long-running commands.
bash.autoBackground.thresholdMsnumber60000Threshold before auto-backgrounding.
eval.pybooleantruePython eval backend. PI_PY=0 disables for the process.
eval.jsbooleantrueJavaScript eval backend. PI_JS=0 disables for the process.
python.kernelModeenumsessionsession (persistent kernel) or per-call.
python.interpreterstring""Path to a Python interpreter; empty = auto-detect.
lsp.enabledbooleantrueLanguage-server integration. --no-lsp disables for the run.
lsp.lazybooleantrueStart servers on demand.
lsp.diagnosticsOnWritebooleantrueRun diagnostics after a write.
lsp.diagnosticsOnEditbooleanfalseRun diagnostics after an edit.
lsp.formatOnWritebooleanfalseFormat files on write.
lsp.diagnosticsDeduplicatebooleantrueCollapse duplicate diagnostics.
shellPathstring(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
KeyTypeDefaultNotes
edit.modeenumhashlineapply_patch, hashline, patch, replace.
edit.fuzzyMatchbooleantrueAllow fuzzy anchor matching.
edit.fuzzyThresholdnumber0.95Similarity threshold for fuzzy matching.
edit.blockAutoGeneratedbooleantrueRefuse to edit generated/lockfile-like files.
edit.streamingAbortbooleanfalseAbort on streaming edit mismatch.
read.defaultLimitnumber300Default line count for read without a selector.
read.summarize.enabledbooleantrueStructural summaries for code reads.
read.summarize.prosebooleanfalseSummarize prose files too.
read.toolResultPreviewbooleanfalseInline preview of tool results.
readLineNumbersbooleanfalseShow 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
KeyTypeDefaultNotes
contextPromotion.enabledbooleantruePromote relevant earlier context.
compaction.enabledbooleantrueAutomatic conversation compaction.
compaction.strategyenumsnapcompactcontext-full, handoff, shake, snapcompact, off.
compaction.thresholdPercentnumber-1Percent-of-context trigger; -1 = reserve-based default.
compaction.thresholdTokensnumber-1Fixed token trigger when > 0.
compaction.reserveTokensnumber16384Tokens reserved for the next turn.
compaction.keepRecentTokensnumber20000Recent tokens always preserved.
compaction.remoteEnabledbooleantrueAllow remote compaction service.
compaction.autoContinuebooleantrueContinue automatically after compaction.
memory.backendenumoffoff, local, hindsight, mnemopi. Each backend has its own hindsight.* / mnemopi.* / memories.* tuning keys.
autolearn.enabledbooleanfalseExperimental: 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.autoContinuebooleanfalseWhen autolearn.enabled, auto-run one capture turn at stop (uses extra tokens). Off = a passive reminder rides your next turn.
autolearn.minToolCallsnumber5Only 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
KeyTypeDefaultValues
theme.darkstringtitaniumTheme used on a dark terminal background.
theme.lightstringlightTheme used on a light terminal background.
symbolPresetenumunicodeunicode, nerd, ascii.
colorBlindModebooleanfalseUse blue instead of green for diff additions.
showHardwareCursorbooleantrueShow the terminal hardware cursor.
statusLine.presetenumdefaultdefault, minimal, compact, full, nerd, ascii, custom.
statusLine.separatorenumpowerline-thinpowerline, powerline-thin, slash, pipe, block, none, ascii.
statusLine.sessionAccentbooleantrueTint the editor border with the session color.
statusLine.transparentbooleanfalseUse the terminal background for the status line.
statusLine.showHookStatusbooleantrueShow hook status messages.
terminal.showImagesbooleantrueRender images inline (when the terminal supports it).
images.autoResizebooleantrueResize large images for model compatibility.
images.blockImagesbooleanfalseNever send images to providers.
tui.hyperlinksenumautooff, auto, always.

For a custom status line, set statusLine.preset: custom and configure statusLine.leftSegments, statusLine.rightSegments, and statusLine.segmentOptions.

Interaction

KeyTypeDefaultValues
steeringModeenumone-at-a-timeall, one-at-a-time. How queued steering messages are delivered.
followUpModeenumone-at-a-timeall, one-at-a-time.
interruptModeenumimmediateimmediate, wait.
doubleEscapeActionenumtreebranch, tree, none.
autoResumebooleanfalseAuto-resume the most recent session in the cwd.
ask.timeoutnumber0Seconds before an ask prompt times out; 0 = no timeout. (Legacy ms values are migrated to seconds.)
ask.notifyenumonon, 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
KeyTypeDefaultValues / notes
providers.webSearchenumautoauto plus the configured search providers (perplexity, gemini, anthropic, codex, zai, exa, jina, kagi, tavily, brave, kimi, parallel, synthetic, searxng).
providers.imageenumautoauto, openai, antigravity, xai, gemini, openrouter.
providers.fetchenumautoauto, native, trafilatura, lynx, parallel, jina.
providers.tinyModelenumonlineonline or a local model (lfm2-350m, qwen3-0.6b, gemma-270m, qwen2.5-0.5b, lfm2-700m).
providers.tinyModelDeviceenumdefaultONNX execution provider for local tiny models. Overridden by PI_TINY_DEVICE.
providers.tinyModelDtypeenumdefaultONNX precision for local tiny models. Overridden by PI_TINY_DTYPE.
providers.openaiWebsocketsenumautoauto, off, on.
providers.openrouterVariantenumdefaultdefault, nitro, floor, online, exacto.
providers.kimiApiFormatenumanthropicopenai, anthropic.
provider.appendOnlyContextenumautoauto, on, off.
exa.enabledbooleantrueEnable Exa integration.
exa.enableSearchbooleantrueExa search.
exa.enableResearcherbooleanfalseExa researcher.
exa.enableWebsetsbooleanfalseExa websets.
searxng.endpointstring(unset)SearXNG instance URL.
searxng.tokenstring(unset)SearXNG token; also searxng.basicUsername/searxng.basicPassword/searxng.categories/searxng.language.
auth.broker.urlstring(unset)Auth-broker URL. Overridden by OMP_AUTH_BROKER_URL.
auth.broker.tokenstring(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:

  1. ~/.omp/agent/settings.json (renamed to settings.json.bak after a successful migration).
  2. 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):

OldNew
queueModesteeringMode
ask.timeout in milliseconds (value > 1000)seconds (divided by 1000)
flat theme: "<name>" stringtheme.dark / theme.light (slot chosen by luminance; built-in light/dark are dropped to use defaults)
task.isolation.enabled: true/falsetask.isolation.mode: auto/none
task.simpleremoved
legacy task.isolation.mode (worktree, fuse-overlay, fuse-projfs)rcopy, overlayfs, projfs
lastChangelogVersionmoved to a marker file and stripped from config.yml

Troubleshooting

A project setting is not taking effect

  • Start omp from 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 --config overlays 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) disabledProviders array replacing your global one.
  • Credentials can still come from environment variables, .env, OAuth, stored auth, or models.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

Modectx.ui.custom(...) availabilityNotes
Interactive TUISupportedComponent is mounted in the editor area or overlay, focused, and must call done(result) to resolve.
Background/headlessNot interactiveUI context is no-op (hasUI === false).
RPC modeNot mountedcustom() 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:

  1. Do not intentionally exceed width on any line. The renderer truncates overwide non-image lines as a last-resort guard, but components should still return width-safe output.
  2. Measure visual width, not string length: use visibleWidth().
  3. Truncate/wrap ANSI-aware text with truncateToWidth() / wrapTextWithAnsi().
  4. 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 through TUI.showOverlay(...); without overlay, it replaces the editor component area directly.
  • Overlay custom UI is anchored at bottom-center with full terminal width/max height and is removed through the returned overlay handle when done(...) 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): calls component.dispose?.(), hides the overlay if present, restores editor + text for non-overlay flows, focuses editor, resolves promise. So done(...) 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: boolean
  • isPartial: boolean
  • spinnerFrame?: 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 CancellableLoader with AbortSignal and call done(...) from onAbort.

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.tsComponent, 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 — mounting renderCall/renderResult components 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:

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.

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:

  1. Compose the frame (render(width)), collecting liveRegionStart / commitSafeEnd from the root children (absolute row indices).
  2. 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).
  3. Classify: fullPaint (first paint, clearScrollback session replace, or geometry change outside a multiplexer — all user gestures) or update.
  4. 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-anchor W = max(0, L − height), reset C = min(B, W), keep the stale history above (no gesture, no erase).
  5. 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).
  6. Emit:
EmitterBytesWhen
#emitFullPaintclears + frame[0, C') + window rowsgestures 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 rangethe rows leaving the screen are exactly the chunk, content untouched since painted
#emitUpdate in-window diffrelative move + changed-row range rewritenothing scrolls, nothing commits (cursor-only when nothing changed)
#emitUpdate seam rewritechunk rows + full window rewritecommit 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_FRAMES clean frames.
  • stable-prefix ratchet — rows that stayed visibly identical for a full STABLE_PREFIX_COMMIT_FRAMES window 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

  1. NEVER add a new CSI 3 J (ED3) callsite. ED3 flows only through #emitFullPaint({ clearScrollback: true }), only for gestures, never inside multiplexers.
  2. NEVER rewrite a committed row. No emitter may touch frame rows < C, and W ≥ C always (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.
  3. 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 provably frame[0..C).
  4. 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.
  5. 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.
  6. 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.
  7. Cursor writes live inside the synchronized-output frame, before ESU — never as a second frame after it.
  8. NEVER throw in the render hot path. Clamp over-wide lines (truncateToWidth); a width mismatch is cosmetic, not fatal.
  9. Multiplexers get no destructive clear and no history rewrap on resize — repaint the window in place; pane history keeps its old wrap.
  10. 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_FEATURES advertises SyWT_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 under PI_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 fixed DEFAULT_TAB_WIDTH columns.
  • OSC 66 sized spans are added back as scale × (explicit w ?? payload width)Bun.stringWidth would 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:

  • #privateCsiResponseBuffer accumulates \x1b[?… partials while a sentinel is outstanding, rejoins on the terminator byte, then runs the handlers on the complete reply. A new \x1b mid-reassembly or >256 bytes abandons the partial so real keys still reach input.
  • #da1SentinelOwners is a typed FIFO discriminated by kind so 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)

VarEffect
PI_NO_SYNC_OUTPUT=1Disable DEC 2026 BSU/ESU wrappers (autowrap discipline stays on).
PI_TUI_SYNC_OUTPUT=0|1 / PI_FORCE_SYNC_OUTPUT=1Force sync output off / on.
PI_NO_DECCARADisable Kitty DECCARA rectangular-fill optimization.
PI_FORCE_IMAGE_PROTOCOL=kitty|iterm2|sixel|offOverride image protocol detection.
PI_NO_KITTY_PLACEHOLDERS=1 / PI_KITTY_PLACEHOLDERS=1Force Kitty Unicode placeholders off / on.
PI_HARDWARE_CURSOR=1Show the real hardware cursor instead of a rendered one.
PI_NOTIFICATIONS=off|0|falseSuppress terminal notifications.
PI_DEBUG_REDRAW=1Log the chosen render intent + ledger state per frame to the debug log.
PI_TUI_RESIZE_IN_PLACE=1|0Force 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 J anywhere other than the gesture-driven clearScrollback full 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

Operation matrix

OperationEntry pathSession mutationSession file creation/switchOutput artifact
/dumpInteractive slash commandNoNoClipboard text
/export [path]Interactive slash commandNoNoHTML file
--export <session.jsonl> [outputPath]CLI startup fast-pathNo runtime session mutationNo active session; reads target fileHTML file
/shareInteractive slash commandNoNoEncrypted share link (gist or share server); temp HTML only for custom handlers
/freshInteractive slash commandYes (provider-facing in-memory id/state only)No; keeps current session file/headerNone
/forkInteractive slash commandYes (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 startupYes after session creationCreates a new session fork from the selected source into current cwd/session dirNone
/resumeInteractive slash commandYes (active in-memory state replaced)Switches to selected existing session fileNone
--resumeCLI startup pickerYes after session creationOpens selected existing session fileNone
--resume <id|path>CLI startupYes after session creationOpens existing session; global cross-project match re-roots (moved dir) or forks into current projectNone
--continueCLI startupYes after session creationOpens terminal breadcrumb (re-roots it if its dir was moved) or most-recent session; creates new one if none existsNone

Export and dump

/export [outputPath] (interactive)

Flow:

  1. The builtin slash-command registry (src/slash-commands/builtin-registry.ts) routes /export... to CommandController.handleExportCommand in the TUI.
  2. The command splits on whitespace and uses only the first argument after /export as outputPath.
  3. AgentSession.exportToHtml() calls exportSessionToHtml(sessionManager, state, { outputPath, themeName }).
  4. On success, UI shows path and opens the file in browser.

Behavior details:

  • --copy, clipboard, and copy arguments are explicitly rejected with a warning to use /dump.
  • Export embeds session header/entries/leaf plus current systemPrompt and tool descriptions from agent state.
  • Subagent transcripts stored next to the session file (<session>/<AgentId>.jsonl, recursively for nested spawns) are embedded as subSessions (collectSubSessions in src/export/html/index.ts; disable with includeSubSessions: false in ExportOptions). 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 into src/export/html/tool-views.generated.js by bun --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:

  1. Handled early (before interactive/session startup).
  2. Calls exportFromFile(inputPath, outputPath?).
  3. SessionManager.open(inputPath) loads entries, then HTML is generated and written.
  4. Process prints Exported to: ... and exits.

Behavior details:

  • Missing input file surfaces as File not found: <path>.
  • This path does not create an AgentSession and does not mutate any running session.

/dump (interactive clipboard export)

Flow:

  1. CommandController.handleDumpCommand() calls session.formatSessionAsText().
  2. If empty string, reports No messages to dump yet.
  3. 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 excludeFromContext bash/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.ts
  • share.js
  • share.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 => url and/or message shown; url opened
  • undefined/falsy => generic Session 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()):

  1. Builds the session snapshot (header, entries, leafId, plus current systemPrompt and tool descriptions from agent state).
  2. If share.redactSecrets is enabled (default) and secrets are configured (secrets.*), the secret obfuscator deep-walks every string in the snapshot, replacing configured/discovered secrets with placeholders.
  3. The JSON is gzipped and sealed with a fresh AES-256-GCM key ([12B IV][ciphertext+tag]).
  4. Upload target is chosen by share.store:
    • Share server (default, store: "blob") — POST <share.serverUrl> (default https://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") — when gh is installed and authenticated, the sealed blob is pushed base64-encoded as session.ompshare.txt (budget 5 MB sealed; gist raw fetches cap at 10 MB), falling back to the share server when gh is unusable.
  5. 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 onAbort hook that restores editor UI and reports Share 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, /fork is rejected with warning.
  • UI status/loading indicators are cleared before operation.

Session-level flow

AgentSession.fork():

  1. Emits session_before_switch with reason: "fork" (cancellable).
  2. Flushes pending writes.
  3. Calls SessionManager.fork().
  4. Copies artifacts directory from old session namespace to new namespace (best-effort; non-ENOENT copy failures are logged, not fatal).
  5. Updates agent.sessionId.
  6. Emits session_switch with reason: "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
    • cwd unchanged
    • parentSession set to previous session id
  • Keeps all non-header entries unchanged in the new file.

Non-persistent behavior

  • In-memory session manager returns undefined from fork().
  • AgentSession.fork() returns false.
  • UI reports Fork failed (session not persisted or cancelled).

CLI --fork <id|path>

Startup --fork is resolved before normal session creation:

  1. --fork is rejected with --no-session.
  2. Path-like values (/, \, or .jsonl) call SessionManager.forkFrom(path, cwd, sessionDir).
  3. Other values resolve via resolveResumableSession(...): local sessions first, then global search when sessionDir is not forced. Matching accepts lowercased session id prefixes, full JSONL filename prefixes, and timestamp-stripped filename id suffixes.
  4. 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:

  1. 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.
  2. On selection, SelectorController.handleResumeSession(sessionPath) calls session.switchSession(sessionPath).
  3. UI clears/rebuilds chat and todos, then reports Resumed session (or Resumed session in <dir> when the resumed session belongs to another project, in which case the process cwd and cwd-derived caches are re-pointed via applyCwdChange).

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.ts lists sessions for current cwd/sessionDir and opens picker. When the current folder is empty, it falls back to SessionManager.listAll() and opens the picker in all-projects scope; No sessions found is 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:

  1. If value looks like path (/, \, or .jsonl), open directly.
  2. Else resolveResumableSession(...) searches:
    • current scope (SessionManager.list(cwd, sessionDir))
    • global sessions (SessionManager.listAll()) only when no explicit sessionDir was provided
  3. 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 asks Session's directory no longer exists (...). Move (re-root) it into the current directory? [Y/n].
      • On yes (default): SessionManager.open(match.path) then manager.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.
    • 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.

CLI --continue

SessionManager.continueRecent(cwd, sessionDir):

  1. Resolves session dir for current cwd.
  2. Reads the terminal-scoped breadcrumb.
  3. 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 moveTo instead of starting fresh.
  4. Otherwise, if the breadcrumb’s cwd matches the current cwd, uses the breadcrumb session; else falls back to the most recently modified session file.
  5. 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:

  1. Emit session_before_switch with reason: "resume" and targetSessionFile (cancellable).
  2. Disconnect agent event subscription and abort in-flight work.
  3. Flush current session manager writes.
  4. 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.
  5. Clear queued steering/follow-up/next-turn messages.
  6. sessionManager.setSessionFile(sessionPath) and update agent.sessionId.
  7. Build session context from loaded entries.
  8. Restore MCP selections/tools/system prompt for the target session.
  9. Emit session_switch with reason: "resume".
  10. Replace agent messages from context and sync todos.
  11. Close provider sessions when switching files, or when same-file reload changed replay messages.
  12. Restore model (if available in current registry).
  13. Restore or initialize thinking level and service tier.
  14. Reconnect agent event subscription.
  15. Run the registered session-switch reconciler, if any (interactive mode registers #reconcileModeFromSession() via setSessionSwitchReconciler to 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 }
  • 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

  • /fork is blocked while streaming (user must wait/abort current response first).
  • /resume selector can be cancelled by user closing selector.
  • Cross-project --resume <id> can be cancelled by declining fork prompt.
  • /share has 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.
  • /export fails with Cannot export in-memory session to HTML (propagated to command error UI). /share still works: the snapshot is built from live entries.
  • /fork fails because SessionManager.fork() requires persistence.
  • /dump still works because it serializes in-memory agent state.
  • CLI resume/continue semantics are bypassed if --no-session is set, because manager creation returns in-memory immediately.

Known implementation caveats (as of current code)

  • SelectorController.handleResumeSession() does not check the boolean result from session.switchSession(...); a hook-cancelled switch can still proceed through UI “Resumed session” repaint/status path.
  • /share custom-share failures do not degrade to the default encrypted share flow; they terminate the command with error.
  • /export argument 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 id and parentId.
  • The active position is leafId in SessionManager.
  • 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 extraction
  • src/session/session-context.tsbuildSessionContext context reconstruction (resolved root→leaf LLM context, compaction/branch-summary replay)
  • src/session/agent-session.ts/tree navigation flow, summarization, hook/event emission
  • src/modes/components/tree-selector.ts — interactive tree UI behavior and filtering
  • src/modes/controllers/selector-controller.ts — selector orchestration for /tree and /branch
  • src/slash-commands/builtin-registry.ts — command routing (/tree, /branch)
  • src/modes/controllers/input-controller.ts — double-escape behavior and app.session.tree/app.session.fork keybinding wiring
  • src/session/messages.ts — conversion of branch_summary, compaction, and custom_message entries 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 path
  • getTree() returns SessionTreeNode[] (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 children
  • getLabel(id) resolves current label from the index’s #labels map

getTree() is a runtime projection; persistence remains append-only JSONL entries.

Leaf movement semantics

There are three leaf movement primitives:

  1. branch(entryId)

    • Validates entry exists
    • Sets leafId = entryId
    • No new entry is written
  2. resetLeaf()

    • Sets leafId = null
    • Next append creates a new root entry (parentId = null)
  3. branchWithSummary(branchFromId, summary, details?, fromExtension?)

    • Accepts branchFromId: string | null
    • Sets leafId = branchFromId
    • Appends a branch_summary entry as child of that leaf
    • When branchFromId is null, fromId is persisted as "root"

/tree navigation behavior (same session file)

AgentSession.navigateTree() is navigation, not file forking.

Flow:

  1. Validate target and compute abandoned path (collectEntriesForBranchSummary)
  2. Emit session_before_tree with TreePreparation
  3. Optionally summarize abandoned entries (hook-provided summary or built-in summarizer)
  4. 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
  5. Apply leaf move:
    • with summary: branchWithSummary(newLeafId, ...)
    • without summary and newLeafId === null: resetLeaf()
    • otherwise: branch(newLeafId)
  6. 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:

  • /tree navigates within the current session file.
  • /branch creates a new session branch file (or in-memory replacement for non-persistent mode).

User-facing /branch flow (SelectorController.showUserMessageSelectorAgentSession.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 via newSession({ 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 label entries 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 firstKeptEntryId to compaction point
    • then replays post-compaction messages
  • Includes branch_summary and custom_message entries as AgentMessage objects.

session/messages.ts then maps these message types for model input:

  • branchSummary and compactionSummary become user-role templated context messages
  • custom/hookMessage become developer-role content messages (via agent-core’s convertMessageToLlm)

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?) writes label entries on the current leaf chain.
  • #labels (in SessionEntryIndex) 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.
    • default suppresses label, custom, model_change, and thinking_level_change; it is not a complete “hide all internal entries” filter.
  • Supports free-text search over rendered semantic content.
  • Shift+L opens inline label editing and writes via appendLabelChange.

Command routing:

  • /tree always opens tree selector.
  • /branch opens user-message selector unless doubleEscapeAction=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 file
  • navigateTree(targetId, { summarize? }) — move within current tree/file

Events around tree navigation:

  • session_before_tree
    • receives TreePreparation:
      • targetId
      • oldLeafId
      • commonAncestorId
      • entriesToSummarize
      • userWantsSummary
    • may cancel navigation
    • may provide summary payload used instead of built-in summarizer
    • receives abort signal (Escape cancellation path)
  • session_tree
    • emits newLeafId, oldLeafId
    • includes summaryEntry when a summary was created
    • fromExtension indicates summary origin

Adjacent but related lifecycle hooks:

  • session_before_branch / session_branch for /branch flow
  • session_before_compact, session.compacting, session_compact for compaction entries that later affect tree-context reconstruction

Real constraints and edge conditions

  • branch() cannot target null; use resetLeaf() for root-before-first-entry state.
  • branchWithSummary() supports null target and records fromId: "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(...) with options.title populated from the plan-approval details.
  • This runs for every approval choice (Approve and execute, Approve and compact context, Approve and keep context); the synthetic plan-approved prompt 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
  • The humanized name is applied only when the current session has no name (!sessionManager.getSessionName()). It then calls sessionManager.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-loaderMigrate mcp loader
  • fix_session_namingFix session naming
  • foo--bar__bazFoo bar baz
  • RefactorRouterRefactorRouter (no separators to expand)
  • "" / "---""" (no name applied)

Legacy compatibility still present

Session migrations still run on load:

  • v1→v2 adds id/parentId and converts compaction index anchor to id anchor
  • v2→v3 migrates legacy hookMessage role to custom

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 TreeSelectorComponent with 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, /branch command 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 (navigateTree leaf 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.tree action
  • double-escape on empty editor when doubleEscapeAction = "tree" (default)
  • /branch when doubleEscapeAction = "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 down
  • Enter: select node
  • Esc: clear search if active; otherwise close selector
  • Ctrl+C: close selector
  • Type: append to search query
  • Backspace: delete search character
  • Shift+L: edit/clear label on selected entry
  • Ctrl+O: cycle filter forward
  • Shift+Ctrl+O: cycle filter backward
  • Alt+D/T/U/L/A: jump directly to specific filter mode

Filters and search semantics

Filter modes (TreeList):

  1. default
  2. no-tools
  3. user-only
  4. labeled-only
  5. all

default

Shows conversational nodes plus any entry types not explicitly suppressed. It hides these setting/bookkeeping entry types:

  • label
  • custom
  • model_change
  • thinking_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 (stopReason not stop/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 prefill

Summary-on-switch flow

Summary prompt is controlled by branchSummary.enabled (default: false).

When enabled, after picking a node the UI asks:

  • No summary
  • Summarize
  • Summarize 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 Esc to abortBranchSummary()
  • 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 exists
    • branch(newLeafId) for non-root move without summary
    • resetLeaf() 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 label entries
  • tree nodes display resolved label state, not raw label-entry history

/tree vs adjacent operations

OperationScopeResult
/treeCurrent session fileMoves leaf to selected point (same file)
/branchUsually current session file -> new session fileBy default branches from selected user message into a new session file; if doubleEscapeAction = "tree", /branch opens tree navigation UI instead
/forkWhole current sessionDuplicates session into a new persisted session file
/resumeSession listSwitches 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

  1. /tree
  2. search/select earlier user message
  3. choose No summary (or summarize if needed)
  4. edit prefilled text in editor
  5. submit

Effect: new branch grows from selected point within same session file.

Leave current branch with context breadcrumb

  1. enable branchSummary.enabled
  2. /tree and select target node
  3. choose Summarize (or custom prompt)

Effect: a branch_summary entry is appended at the target position before continuing.

Investigate hidden bookkeeping entries

  1. /tree
  2. press Alt+A (all)
  3. search for model, thinking, custom, or labels

Effect: inspect full internal timeline, not just conversational nodes.

Bookmark pivot points for later jumps

  1. /tree
  2. move to entry
  3. Shift+L and set label
  4. 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 /tree navigation.

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.ts
  • packages/agent/src/compaction/pruning.ts
  • packages/agent/src/compaction/utils.ts
  • packages/agent/src/compaction/openai.ts
  • packages/coding-agent/src/session/session-manager.ts
  • packages/coding-agent/src/session/agent-session.ts
  • packages/coding-agent/src/session/messages.ts
  • packages/coding-agent/src/extensibility/hooks/types.ts
  • packages/coding-agent/src/config/settings-schema.ts

Session entry model

Compaction and branch summaries are first-class session entries, not plain assistant/user messages.

  • CompactionEntry
    • type: "compaction"
    • summary, optional shortSummary
    • firstKeptEntryId (compaction boundary)
    • tokensBefore
    • optional details, preserveData, fromExtension
  • BranchSummaryEntry
    • type: "branch_summary"
    • fromId, summary
    • optional details, fromExtension

When context is rebuilt (buildSessionContext):

  1. Latest compaction on the active path is converted to one compactionSummary message.
  2. Kept entries from firstKeptEntryId to the compaction point are re-included.
  3. Later entries on the path are appended.
  4. branch_summary entries are converted to branchSummary messages.
  5. custom_message entries are converted to custom messages.

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.md
  • packages/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:

  1. Manual context compaction: /compact [instructions] calls AgentSession.compact(...).
  2. Automatic overflow recovery: after a same-model assistant error that matches context overflow.
  3. Automatic incomplete-output recovery: after a same-model assistant message ends with stopReason === "length" (OpenAI/Codex response.incomplete).
  4. Automatic threshold maintenance: after a successful turn when context exceeds the resolved threshold.
  5. 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 firstKeptEntryId

Overflow/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" and willRetry: 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" and willRetry: 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.
  • 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" and willRetry: 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 from prompts/system/auto-continue.md.
  • Idle maintenance

    • Trigger: runIdleCompaction() when not streaming or already compacting.
    • Uses reason: "idle" and does not auto-continue afterward.

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 8x13 glyphs 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 reads 8x13 glyphs on a 22px pitch (extra leading, black ink — 8on22-bw at 2048px, since Gemini 3.x bills a fixed 1,120-token budget per image at any pixel size), GPT/Codex read the same 8on22-bw shape at 1568px (patch billing is area-proportional, so larger frames cannot improve chars per token), and Kimi/GLM read 8x13 glyphs on a 16px pitch (8on16-bw at 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’s detail: "original" hint) always follows the API carrying the request, computed for the resolved frame size. The snapcompact.shape setting (default auto) 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-wrapped doc-8on16-bw/-sent/-sent-dim, where dim prints 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.snapcompact as 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’s summary is 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. maxFrames now defaults to MAX_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.input includes "image"); otherwise the run falls back to context-full and emits a warning notice (auto and manual paths). Manual /compact honors 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_000 tool-output tokens.
  • Require at least 20_000 total estimated savings.
  • Never blank a result below 50 tokens (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 skill tool results, read results of skill:// paths, or reads of the active plan reference file (added via AgentSession’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 by compaction.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 receive USELESS_NOTICE instead 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).

  1. Find previous compaction index.
  2. Compute boundaryStart = prevCompactionIndex + 1.
  3. Adapt keepRecentTokens using measured usage ratio when available.
  4. Run findCutPoint() over the boundary window.

Valid cut points include:

  • message entries with roles: user, assistant, bashExecution, hookMessage, branchSummary, compactionSummary
  • custom_message entries
  • branch_summary entries

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_message entry
  • branch_summary entry

Split-turn compaction generates two summaries:

  1. History summary (messagesToSummarize)
  2. 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:

  1. Convert messages via convertToLlm().
  2. Serialize with serializeConversation().
  3. Wrap in <conversation>...</conversation>.
  4. Optionally include <previous-summary>...</previous-summary>.
  5. Optionally inject extension hook context and active memory-backend compaction context as <additional-context> entries.
  6. 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 by generateHandoff(...), not serialized compaction)

Remote summarization modes:

  • If compaction.remoteEndpoint is 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/compact endpoint when remote compaction is enabled. It preserves provider replacement history in preserveData.openaiRemoteCompaction and 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 set
  • write(path) → modified set
  • edit(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.readFiles excludes files also modified; details.modifiedFiles carries 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:

  1. Appends CompactionEntry with appendCompaction(...) for context-full maintenance; handoff strategy creates a new session and injects a handoff custom_message instead.
  2. Rebuilds display context from the active leaf via buildDisplaySessionContext().
  3. Replaces live agent messages with rebuilt context.
  4. Synchronizes active todo phases from the rebuilt branch and closes provider sessions whose history was rewritten.
  5. Emits session_compact hook event.

Branch summarization pipeline

Branch summarization is tied to tree navigation, not token overflow.

Trigger

During navigateTree(...):

  1. Compute abandoned entries from old leaf to common ancestor using collectEntriesForBranchSummary(...).
  2. If caller requested summary (options.summarize), generate summary before switching leaf.
  3. 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:

  1. First pass: collect cumulative file ops from all summarized entries, including prior pi-generated branch_summary details.
  2. Second pass: walk newest → oldest, adding messages until token budget is reached.
  3. Prefer preserving recent context.
  4. 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:

  1. Converts and serializes selected messages.
  2. Wraps in <conversation>.
  3. Uses custom instructions if supplied, otherwise branch-summary.md.
  4. Calls summarization model with SUMMARIZATION_SYSTEM_PROMPT.
  5. Prepends branch-summary-preamble.md.
  6. 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: ...
  • 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 = true
  • compaction.strategy = "snapcompact" ("context-full", "handoff", "shake", and "off" are also supported)
  • compaction.reserveTokens = 16384
  • compaction.keepRecentTokens = 20000
  • compaction.autoContinue = true
  • compaction.remoteEnabled = true
  • compaction.remoteEndpoint = undefined
  • compaction.thresholdPercent = -1 and compaction.thresholdTokens = -1; when no positive override is set, the threshold is contextWindow - max(15% of contextWindow, reserveTokens)
  • compaction.idleEnabled = false
  • compaction.idleThresholdTokens = 200000
  • compaction.idleTimeoutSeconds = 300
  • branchSummary.enabled = false
  • branchSummary.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

CommandEffect
/collabStart 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 viewStart sharing read-only (or re-print the link/QR when already hosting)
/collab statusShow link + participants
/collab stopStop sharing
/join <link>Join a shared session as a guest
/leaveLeave (guest) or stop sharing (host)

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

SettingDefaultMeaning
collab.relayUrlwss://my.omp.shRelay used by /collab when no relay is passed inline
collab.webUrlemptyBrowser UI URL for /collab links; empty derives from relay; explicit http:// is allowed only for localhost
collab.displayNameOS usernameName shown to other participants
share.serverUrlhttps://my.omp.sh/sShare viewer/upload base used by /share (links are <base>/<id>#<key>)
share.redactSecretstrueRun 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 /collab deep link),
  • GET /r/<roomId>?role=host|guest — WebSocket upgrade,
  • POST /s / GET /s/<id> / GET /s/<id>/raw/share blob upload, viewer page, and blob fetch,
  • GET /healthz — liveness.

Architecture notes

Hub topology — the host is authoritative, guests never peer:

  1. entry frames — 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>.jsonl and into the agent’s message array, which is why /dump and context estimates work.
  2. event frames — live agent events, fed straight into the guest’s normal event controller; rendering is events-only to prevent double-render.
  3. state frames — 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.
  4. bus frames — mirrored task-subagent lifecycle/progress EventBus traffic, republished on the guest’s local bus so the subagent HUD and status-line count work natively.
  5. agents frames — 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 /handoff command dispatch
  • AgentSession.handoff() lifecycle and state transitions
  • generateHandoff(...) 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

Trigger path

  1. /handoff is declared in builtin slash command metadata (slash-commands/builtin-registry.ts) with optional inline hint: [focus instructions].
  2. In interactive input handling (InputController), submit text matching /handoff or /handoff ... is intercepted before normal prompt submission.
  3. The editor is cleared and handleHandoffCommand(customInstructions?) is called.
  4. CommandController.handleHandoffCommand performs a preflight guard using current entries:
    • Counts type === "message" entries.
    • If < 2, it warns: Nothing to hand off (no messages yet) and returns.

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 /handoff and RPC handoff command guard on isStreaming before 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 #handoffAbortController and 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):
    1. Renders the handoff prompt (renderHandoffPrompt(...) with optional additionalFocus, after obfuscating any focus instructions) and appends it as a trailing agent-attributed user message to a snapshot of agent.state.messages.
    2. Converts the snapshot with convertMessagesToLlm(...) (applies the session transformContext — extension context + steering wrap — then convertToLlm + obfuscation), exactly as the loop does.
    3. Builds the provider Context with agent.buildSideRequestContext(llmMessages, #baseSystemPrompt) — normalized tools and transformProviderContext (obfuscation + inline snapcompact) matching the loop. The base system prompt is pinned here, not a per-turn before_agent_start hook override, so the new session does not inherit prompt-specific hook state.
    4. Builds stream options with prepareSimpleStreamOptions(...): a stable promptCacheKey (= the live session id) so the oneshot reads the cache the turn populated, a unique side sessionId (<sid>:side:<snowflake>) so OpenAI/Codex append-only state never mixes with the live turn, serviceTier/payload hooks mirrored from the session, and preferWebsockets: false.
  • 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 Context is built by the identical transform + normalization pipeline the loop uses, and routed with the same promptCacheKey the turn used.
  • The handoff instruction is a trailing user message, 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 honor toolChoice: "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 AbortError is normalized to Error("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:

  1. Flush current session writer (sessionManager.flush()).
  2. Cancel session-owned async jobs.
  3. Start a brand-new session with parentSession pointing at the previous session file when one exists.
  4. Reset in-memory agent state (agent.reset()).
  5. Rebind agent.sessionId to the new session id.
  6. Rekey/reset Hindsight and Mnemopi memory session tracking for the new session.
  7. 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 by agent.reset() in step 4.
  8. 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:

  1. buildDisplaySessionContext() resolves message list for current leaf.
  2. agent.replaceMessages(sessionContext.messages) makes the injected handoff message active context.
  3. Todo phases are synchronized from the new branch.
  4. 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 /fork and /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 is AbortError: showError("Handoff cancelled")
    • otherwise: showError("Handoff failed: <message>")
  • 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 #handoffAbortController
  • isGeneratingHandoff → 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: ...

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 < 2 message 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:

  1. Interactive slash command intercepted.
  2. Preflight message-count guard.
  3. #handoffAbortController created (isGeneratingHandoff = true).
  4. generateHandoff(...) issues one instrumentedCompleteSimple(...) request with live system prompt, tools, message history, current thinking level, and trailing handoff prompt.
  5. Assistant response text blocks are joined; tool-call blocks are discarded.
  6. If missing text → return undefined; if aborted → cancellation error path.
  7. 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.handoffSaveToDisk is enabled
  8. Controller rebuilds chat UI and announces success.
  9. #handoffAbortController cleared (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-*.md artifact when compaction.handoffSaveToDisk is enabled; write failure is logged and does not fail the handoff.

Tool approval has two independent inputs:

  1. Tool declaration — every tool may declare an approval tier:
    • 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.
  2. User policytools.approval.<toolName>: allow | deny | prompt overrides 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:

ModeAuto-approvesPrompts for
always-askreadwrite, exec
writeread, writeexec
yolo (default)read, write, execnone

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

Resolution per tool call:

  1. Compute the tool’s approval decision from tool.approval(args); omitted means exec.
  2. Normalize tools.approval.<tool> if present; invalid values are ignored.
  3. In yolo mode, the user policy is used when present; otherwise the call is allowed. Safety override reasons do not force a prompt in yolo.
  4. In non-yolo modes, if the tool sets override: true, deny is blocked and all other cases prompt, even if user policy says allow.
  5. Otherwise, a valid user policy wins.
  6. 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 tool for unannotated mcp__... tools
  • Reason: <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: yolo

Or 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: yolo

Precedence 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.ts
  • src/extensibility/extensions/runner.ts
  • src/extensibility/extensions/wrapper.ts
  • src/extensibility/extensions/index.ts
  • src/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

  1. Extensions are imported and their factory functions run.
  2. During that load phase, registration methods are valid; runtime action methods are not yet initialized.
  3. ExtensionRunner.initialize(...) wires live actions/contexts for the active mode.
  4. Session/agent/tool lifecycle events are emitted to handlers.
  5. 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 throws ExtensionRuntimeNotInitializedError
  • 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, registerFlag
  • registerMessageRenderer, registerAssistantThinkingRenderer
  • setLabel, getFlag
  • sendMessage, sendUserMessage, appendEntry, exec
  • getActiveTools, getAllTools, setActiveTools
  • getCommands
  • getSessionName, setSessionName
  • setModel, getThinkingLevel, setThinkingLevel
  • registerProvider
  • events (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.logger
  • pi.typebox (zod-backed compatibility shim for legacy TypeBox-style schemas)
  • pi.zod (injected zod/v4 module — canonical for tool parameter schemas)
  • pi.pi (package exports)

Message delivery semantics

pi.sendMessage(message, options) supports:

  • deliverAs: "steer" (default) — interrupts current run
  • deliverAs: "followUp" — queued to run after current run
  • deliverAs: "nextTurn" — stored and injected on the next user prompt
  • triggerTurn: true — starts a turn when idle (also honored with deliverAs: "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:

  • ui
  • hasUI
  • cwd
  • sessionManager (read-only)
  • modelRegistry, model
  • models (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 /model switches).
  • 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. Returns undefined when 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_start
  • session_before_switch / session_switch
  • session_before_branch / session_branch
  • session_before_compact / session.compacting / session_compact
  • session_before_tree / session_tree
  • session_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

  • input
  • before_agent_start
  • before_provider_request (may replace provider request payload)
  • after_provider_response
  • context
  • agent_start / agent_end — agent loop lifecycle notification; agent_end remains notification-only
  • session_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 sessions
  • turn_start / turn_end
  • message_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 by wrapper.ts only 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_end
  • auto_retry_start / auto_retry_end
  • ttsr_triggered
  • todo_reminder
  • goal_updated
  • credential_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 (setTheme supports string names)
  • tools expanded toggle

Current no-op methods in this controller:

  • setFooter
  • setHeader

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, setWidget for string arrays, setEditorText; setTitle emits only when PI_RPC_EMIT_TITLE=1)

Unsupported/no-op in RPC implementation:

  • onTerminalInput
  • custom
  • setFooter, setHeader, setEditorComponent
  • setWorkingMessage
  • theme switching/loading (setTheme returns 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:

  1. Persist with pi.appendEntry(customType, data).
  2. Rebuild state from ctx.sessionManager.getBranch() on session_start, session_branch, session_tree.
  3. Keep tool result details structured 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_call errors 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


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:

  1. readDirEntries(root)
  2. keep only direct child directories (entry.isDirectory())
  3. 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 manifest on the capability item.

Name normalization

Extension.name is set to:

  1. manifest.name if it is not null/undefined
  2. 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.path is normalized to an absolute path by createSourceMeta().
  • Registry-level capability validation for extensions only checks presence of name and path.
  • 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>

Not warned (silent skip)

  • extensions directory 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) priority 100
  • gemini (packages/coding-agent/src/discovery/gemini.ts) priority 60

Dedup key is ext.name (extensionCapability.key = ext => ext.name).

Cross-provider precedence

Higher-priority provider wins on duplicate extension names.

  • If native and gemini both emit extension name foo, the native item is kept.
  • Lower-priority duplicate is retained only in result.all with _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/extensions and <cwd>/.gemini/extensions keep 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:

  1. Native .omp locations discovered through the capability system:
    • <cwd>/.omp/extensions/
    • ~/.omp/agent/extensions/
    • legacy extension paths listed in .omp/settings.json#extensions or ~/.omp/agent/settings.json#extensions
  2. Installed plugins under ~/.omp/plugins/node_modules (omp plugin install npm/git specs, or omp plugin link) via their omp.extensions/pi.extensions manifests. Marketplace cache installs do not feed extension modules — they surface skills/commands/hooks/tools/MCP only.
  3. Explicit configured paths passed by the CLI (omp --extension ./my-ext.ts, also -e; --hook is treated as an alias) and by the extensions: 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:

  1. package.json with omp.extensions (or legacy pi.extensions) field
  2. index.ts
  3. index.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):

MethodEffect
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

NeedUse
Tools + commands + events in one moduleExtension (ExtensionAPI)
Pure event interception (policy, redaction)Extension or Hook (both work; extension is preferred)
Legacy hook module already existsHook (HookAPI from @oh-my-pi/pi-coding-agent/extensibility/hooks)
Registering a provider, shortcut, or CLI flagExtension only
Shipping as a marketplace pluginExtension (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-ext

The derived name is the filename stem (or directory name for index.ts-style entries): /path/to/my-ext.tsmy-ext.

Important constraints

  • Do not call runtime actions during load. Methods like pi.sendMessage() throw ExtensionRuntimeNotInitializedError if 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_call errors are fail-closed. If a tool_call handler 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 reference
  • docs/extension-loading.md — detailed path resolution rules
  • docs/hooks.md — hook subsystem internals
  • docs/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. ExtensionAPI supports the hook event model plus extension-only events. Use ExtensionAPI for new work; use HookAPI only 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

EventFiresCan return
tool_callBefore every tool execution{ block?: boolean; reason?: string }
tool_resultAfter every tool execution{ content?; details?; isError?: boolean }

Session lifecycle

EventFiresCan return
session_startOn initial session load
session_before_switchBefore session switch{ cancel?: boolean }
session_switchAfter session switch
session_before_branchBefore session branch{ cancel?: boolean; skipConversationRestore?: boolean }
session_branchAfter session branch
session_before_compactBefore compaction{ cancel?: boolean; compaction?: CompactionResult }
session.compactingDuring compaction (inject context){ context?: string[]; prompt?: string; preserveData?: Record<string, unknown> }
session_compactAfter compaction
session_before_treeBefore tree navigation{ cancel?: boolean; summary?: { summary: string; details?: unknown } }
session_treeAfter tree navigation
session_shutdownOn session shutdown

Agent/turn lifecycle

EventFiresCan return
before_agent_startBefore agent starts a turn{ message?: { customType; content; display; details; attribution? } }
agent_startAgent streaming starts
agent_endAgent streaming ends
turn_startStart of a user→agent turn
turn_endEnd of a user→agent turn
contextBefore each LLM API call{ messages?: Message[] }
auto_compaction_startAuto-compaction begins
auto_compaction_endAuto-compaction ends
auto_retry_startAuto-retry begins
auto_retry_endAuto-retry ends
ttsr_triggeredTTSR (too-short response) triggered
todo_reminderTodo 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.
  • reason is 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: true short-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.
  • content replaces the full content array for the LLM.
  • details replaces the structured details object.
  • isError exists on the shared result type, but HookToolWrapper does 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_result is still emitted with isError: 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.messages is 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:

MethodDescription
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
themeCurrent 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 propagation
  • docs/extensions.mdExtensionAPI (superset of HookAPI)
  • 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

FieldRequiredDescription
nameyesMarketplace name. Lowercase alphanumeric, hyphens, dots. Must start and end with alphanumeric. Max 64 chars.
owneryesObject with at minimum owner.name (string)
owner.nameyesMarketplace owner name
owner.emailnoOwner contact email
pluginsyesArray of plugin entries (see below)
metadata.descriptionnoShort description of the marketplace
metadata.versionnoCatalog metadata version string
metadata.pluginRootnoString prepended to all relative plugin source paths
extra top-level fieldsnoPreserved by the parser but not used by marketplace install/runtime logic

Plugin entry fields

FieldRequiredDescription
nameyesPlugin name (same naming rules as marketplace name)
sourceyesWhere to find the plugin — string or object (see source types below)
descriptionnoShort plugin description
versionnoVersion string
authorno{ name, email? }
homepagenoURL
categorynoe.g. development, productivity, security
tags / keywordsnoArrays of string tags/keywords
repositorynoRepository URL
licensenoLicense string
strictnoBoolean plugin metadata flag
commands, agents, hooks, mcpServers, lspServersnoCapability 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.json omp.extensions are not loaded from marketplace installs — that mechanism only applies to npm-installed or omp 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

  1. Create marketplace.json at .omp-plugin/marketplace.json (omp-only) or .claude-plugin/marketplace.json (shared with Claude Code) in a new Git repo.
  2. Add plugin entries pointing to subdirectories (or external sources).
  3. Push to GitHub.
  4. Share the owner/repo string. Users add it with /marketplace add owner/repo.
  5. When you update the catalog, users run /marketplace update your-marketplace-name to 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 reference
  • docs/skills/authoring-extensions.md — how to author the extension modules inside plugins
  • docs/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:

  1. Tool-call surface (toolName: "bash"): used when the model calls the bash tool.
    • Entry point: BashTool.execute().
    • Parameters include command, optional env, timeout, cwd, pty, and, when async.enabled is true, async.
  2. User bang-command surface (!cmd from interactive input or RPC bash command): session-level helper path.
    • Entry point: AgentSession.executeBash().

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 env names against shell-variable syntax,
  • when bash.stripTrailingHeadTail is enabled (default), applies conservative native fixups that remove safe trailing | head / | tail pipes and redundant trailing 2>&1,
  • extracts a leading single-line cd <path> && ... into cwd when cwd was not supplied,
  • rejects async: true when async.enabled is 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, BashTool throws ToolError with 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 === true and ctx.ui set)

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, … and LESS=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),
  • esc while 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 marks truncated,
  • 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 in dump(),
  • 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 truncated on tail overflow, middle elision, column-cap drops, or file spill.

dump() returns:

  • output (possibly annotated prefix),
  • truncated,
  • totalLines/totalBytes,
  • outputLines/outputBytes,
  • elidedBytes/elidedLines when the middle was elided,
  • columnDroppedBytes/columnTruncatedLines when the per-line cap fired,
  • artifactId if 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:

  1. cancelled handling:
    • if abort signal is aborted -> throw ToolAbortError (abort semantics),
    • else -> throw ToolError (treated as tool failure).
  2. PTY timedOut -> throw ToolError.
  3. empty output becomes (no output).
  4. attach truncation metadata via toolResult(...).truncationFromSummary(result, { direction: "tail" }).
  5. exit-code mapping:
    • missing exit code -> throw ToolError("... missing exit status")
    • non-zero exit -> error result with "Command exited with code N" and details.exitCode
    • zero exit -> success result.

Success payload structure:

  • content: text output,
  • details.meta.truncation when truncated, including:
    • direction, truncatedBy, total/output line+byte counts,
    • shownRange,
    • artifactId when 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

SurfaceEntry pathPTY eligibleLive output UXError surfacing
Interactive tool callBashTool.executeYes, when pty=true and UI exists and PI_NO_PTY!=1PTY overlay (interactive) or streamed tail updatesTool errors become toolResult.isError
Print mode tool callBashTool.executeNo (no UI context)No TUI overlay; output appears in event stream/final assistant text flowSame tool error mapping
RPC tool call (agent tooling)BashTool.executeUsually no UI -> non-PTYStructured tool events/resultsSame tool error mapping
Interactive bang command (!)AgentSession.executeBash + BashExecutionComponentNo (uses executor directly)Dedicated bash execution componentController catches exceptions and shows UI error
RPC bash commandrpc-mode -> session.executeBashNoReturns BashResult directlyConsumer 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 timedOut result field,
    • non-PTY maps timeout into cancelled + annotation summary.

Implementation files

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:

  1. Availability check (checkPythonKernelAvailability) — verifies that a Python interpreter resolves and runs.
  2. Spawn python -u runner.py with filtered env and cwd.
  3. Send an init request that runs os.chdir(cwd), injects env entries, and adds cwd to sys.path.
  4. Execute PYTHON_PRELUDE (idempotent — only initializes once per process).

Kernel shutdown:

  • Send {"type": "exit"} over stdin.
  • Wait for process exit with SHUTDOWN_GRACE_MS budget.
  • Escalate to SIGTERM and finally SIGKILL if 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:

MagicEffect
%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.
%pwdReturns 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 VALUESet os.environ[KEY].
%time <expr> / %timeit <expr>Time the expression; emits status event with elapsed ms.
%who / %whosList user-namespace names.
%resetClear 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 / %%shRun the cell body via bash/sh.
%%capture [name]Run body with stdout/stderr captured into name.
%%timeitTime the cell body.
%%writefile <path>Write body to file.
!cmd / var = !cmdRun command via subprocess shell; returns an SList-style result with .n / .s helpers.
var = %name argsAssignment 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):

  1. Active/located venv (VIRTUAL_ENV, then CONDA_PREFIX, then <cwd>/.venv, <cwd>/venv)
  2. Managed venv at ~/.omp/python-env
  3. python or python3 on 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, or PI_PY=1 PI_JS=0)
  • JavaScript backend only (eval.py=false, eval.js=true, or PI_PY=0 PI_JS=1)
  • both backends (eval.py=true, eval.js=true, or PI_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 KeyboardInterrupt inside the user code.
  • Result includes cancelled=true; a kernel timeout is annotated as eval 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_IGN for 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 exitSIGTERMSIGKILL), 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 chunks
  • display / result → rich display handling (MIME bundle)
  • error → traceback text
  • application/x-omp-status MIME inside display → structured status events

Display MIME precedence:

  1. text/markdown
  2. text/plain
  3. text/html (converted to basic markdown)

Additionally captured as structured outputs:

  • application/json → JSON tree data
  • image/png / image/jpeg → image payloads
  • application/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 from eval.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 that python/python3 is on PATH. If preflight fails and eval.js is enabled, use a js cell.
  • No Python on PATH — Install a system Python 3.8+ or place a venv at ~/.omp/python-env. omp setup python --check reports the resolved interpreter.
  • Execution hangs then times out — Increase tool timeout (max 3600s) if workload is legitimate. For stuck native code, cancellation triggers SIGINT first then escalates; the session restarts on the next request.
  • stdin/input prompts in Python codeinput() is not supported; pass data programmatically.
  • Working directory errors — Tool validates cwd exists and is a directory before execution.

Relevant environment variables

  • PI_PY / PI_JS — eval backend exposure overrides
  • PI_PYTHON_SKIP_CHECK=1 — bypass Python preflight/warm checks
  • PI_PYTHON_INTEGRATION=1 — enable gated integration tests that spawn a real Python
  • PI_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

What resolve does

resolve is a hidden tool that finalizes a pending preview action.

  • action: "apply" executes the queued action’s apply(reason, extra) callback and returns that result with resolve metadata.
  • action: "discard" invokes reject(reason, extra) if provided; otherwise returns Discarded: <label>. Reason: <reason>.
  • extra is 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/reject callbacks,
  • resolve dispatches via peekQueueInvoker() ?? 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 with dryRun: 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; reason is the string passed to resolve
  • reject?(reason: string): Promise<AgentToolResult<unknown> | undefined> (optional) — invoked on discard; return value replaces the default “Discarded” message if provided
  • details?: unknown exists on the public custom-tool type but is not currently forwarded by the loader into resolve metadata
  • sourceToolName?: 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 label concise and specific; it is shown in resolve renderer output.
  • Ensure apply(reason) is deterministic and idempotent enough for one-shot execution; reason is 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 pushPendingAction stack.

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

1) Runtime boundary: editing vs executing

.ipynb file conversion (src/edit/notebook.ts)

  • read treats .ipynb files 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 \n stays 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 source array

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 optional cell:N.
  • If cell:N points at an unused existing cell, that cell is cloned, its cell_type and source are updated, and unrelated metadata is preserved.
  • If no valid unused original index is present, a new cell is created.
  • Code cells ensure execution_count exists and outputs exists.
  • Markdown/raw cells remove execution_count and outputs.

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
  • 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_FILE
  • PI_ARTIFACTS_DIR
  • PI_TOOL_BRIDGE_URL
  • PI_TOOL_BRIDGE_TOKEN
  • PI_TOOL_BRIDGE_SESSION
  • PI_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 to onChunk
  • display / result -> MIME bundle rendering
  • error -> traceback text and structured error metadata
  • done -> final status, execution count, cancellation state

Display text MIME precedence:

  1. text/markdown
  2. text/plain
  3. converted text/html

Structured outputs captured separately include:

  • application/json -> JSON display output
  • image/png / image/jpeg -> image output
  • application/x-omp-status -> status event

Cancellation/timeout:

  • abort/timeout sends SIGINT to 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:

  1. read or edit the .ipynb file through the normal file tools
  2. copy the desired cell source into eval cells with language: "py" to execute it
  3. 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/** and packages/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:

  1. Existing process environment (Bun.env)
  2. Project .env ($PWD/.env) for keys not already set
  3. Agent .env (~/.omp/agent/.env, respecting PI_CONFIG_DIR / PI_CODING_AGENT_DIR) for keys not already set
  4. Config-root .env (~/.omp/.env, respecting PI_CONFIG_DIR) for keys not already set
  5. 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

VariableUsed forRequired whenNotes / precedence
ANTHROPIC_OAUTH_TOKENAnthropic API authUsing Anthropic with OAuth token authTakes precedence over ANTHROPIC_API_KEY for provider auth resolution
ANTHROPIC_API_KEYAnthropic API authUsing Anthropic without OAuth tokenFallback after ANTHROPIC_OAUTH_TOKEN
ANTHROPIC_FOUNDRY_API_KEYAnthropic via Azure Foundry / enterprise gatewayCLAUDE_CODE_USE_FOUNDRY enabledTakes precedence over ANTHROPIC_OAUTH_TOKEN and ANTHROPIC_API_KEY when Foundry mode is enabled
OPENAI_API_KEYOpenAI authUsing OpenAI-family providers without explicit apiKey argumentUsed by OpenAI Completions/Responses providers
GEMINI_API_KEYGoogle Gemini authUsing google provider modelsPrimary key for Gemini provider mapping
GOOGLE_API_KEYGemini image tool auth fallbackUsing gemini_image tool without GEMINI_API_KEYUsed by coding-agent image tool fallback path
GROQ_API_KEYGroq authUsing Groq models
CEREBRAS_API_KEYCerebras authUsing Cerebras models
FIREWORKS_API_KEYFireworks authUsing Fireworks models
FIREPASS_API_KEYFire Pass authUsing Fire Pass models
TOGETHER_API_KEYTogether authUsing together provider
AIMLAPI_API_KEYAIML API authUsing aimlapi providerOpenAI-compatible AIML API endpoint at https://api.aimlapi.com/v1
HUGGINGFACE_HUB_TOKENHugging Face authUsing huggingface providerPrimary Hugging Face token env var
HF_TOKENHugging Face authUsing huggingface providerFallback when HUGGINGFACE_HUB_TOKEN is unset
SYNTHETIC_API_KEYSynthetic authUsing Synthetic models
NVIDIA_API_KEYNVIDIA authUsing nvidia provider
NANO_GPT_API_KEYNanoGPT authUsing nanogpt provider
VENICE_API_KEYVenice authUsing venice provider
LITELLM_API_KEYLiteLLM authUsing litellm providerOpenAI-compatible LiteLLM proxy key
LM_STUDIO_API_KEYLM Studio auth (optional)Using lm-studio provider with authenticated hostsLocal LM Studio usually runs without auth; any non-empty token works when a key is required
OLLAMA_API_KEYOllama auth (optional)Using ollama provider with authenticated hostsLocal Ollama usually runs without auth; any non-empty token works when a key is required
LLAMA_CPP_API_KEYllama.cpp auth (optional)Using llama.cpp provider with authenticated hostsLocal llama.cpp usually runs without auth; any non-empty token works when a key is configured
XIAOMI_API_KEYXiaomi MiMo authUsing xiaomi provider
XIAOMI_TOKEN_PLAN_AMS_API_KEYXiaomi MiMo Token Plan auth (AMS)Using xiaomi-token-plan-ams provider
XIAOMI_TOKEN_PLAN_CN_API_KEYXiaomi MiMo Token Plan auth (CN)Using xiaomi-token-plan-cn provider
XIAOMI_TOKEN_PLAN_SGP_API_KEYXiaomi MiMo Token Plan auth (SGP)Using xiaomi-token-plan-sgp provider
MOONSHOT_API_KEYMoonshot authUsing moonshot provider
XAI_API_KEYxAI authUsing xAI models or as fallback for xai-oauth
XAI_OAUTH_TOKENxAI OAuth/SuperGrok authUsing xai-oauth providerTakes precedence over XAI_API_KEY for xai-oauth
OPENROUTER_API_KEYOpenRouter authUsing OpenRouter modelsAlso used by image tool when preferred/auto provider is OpenRouter
MISTRAL_API_KEYMistral authUsing Mistral models
ZAI_API_KEYz.ai authUsing z.ai modelsAlso used by z.ai web search provider
ZHIPU_API_KEYZhipu Coding Plan authUsing zhipu-coding-plan provider
UMANS_AI_CODING_PLAN_API_KEYUmans AI Coding Plan authUsing umans provider
MINIMAX_API_KEYMiniMax authUsing minimax provider
MINIMAX_CODE_API_KEYMiniMax Code authUsing minimax-code provider
MINIMAX_CODE_CN_API_KEYMiniMax Code CN authUsing minimax-code-cn provider
OPENCODE_API_KEYOpenCode authUsing opencode-go / opencode-zen models
QIANFAN_API_KEYQianfan authUsing qianfan provider
QWEN_OAUTH_TOKENQwen Portal authUsing qwen-portal with OAuth tokenTakes precedence over QWEN_PORTAL_API_KEY
QWEN_PORTAL_API_KEYQwen Portal authUsing qwen-portal with API keyFallback after QWEN_OAUTH_TOKEN
ZENMUX_API_KEYZenMux authUsing zenmux providerUsed for ZenMux OpenAI and Anthropic-compatible routes
VLLM_API_KEYvLLM auth/discovery opt-inUsing vllm provider (local OpenAI-compatible servers)Any non-empty value works for no-auth local servers
CURSOR_ACCESS_TOKENCursor provider authUsing Cursor provider
AI_GATEWAY_API_KEYVercel AI Gateway authUsing vercel-ai-gateway provider
CLOUDFLARE_AI_GATEWAY_API_KEYCloudflare AI Gateway authUsing cloudflare-ai-gateway providerBase URL must be configured as https://gateway.ai.cloudflare.com/v1/<account>/<gateway>/anthropic
ALIBABA_CODING_PLAN_API_KEYAlibaba Coding Plan authUsing alibaba-coding-plan provider
DEEPSEEK_API_KEYDeepSeek authUsing DeepSeek models
KILO_API_KEYKilo authUsing Kilo models
OLLAMA_CLOUD_API_KEYOllama Cloud authUsing ollama-cloud provider
WAFER_SERVERLESS_API_KEYWafer Serverless authUsing wafer-serverless providerPay-as-you-go Wafer SKU; validated against https://pass.wafer.ai/v1/models
GITLAB_TOKENGitLab Duo authUsing gitlab-duo provider

GitHub/Copilot tokens

VariableUsed forNotes
COPILOT_GITHUB_TOKENGitHub Copilot provider authGeneric GitHub tokens are not used here
GH_TOKENGitHub API auth in web scraperWeb scraper fallback after GITHUB_TOKEN
GITHUB_TOKENGitHub API auth in web scraperWeb 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.

VariableUsed forRequired whenNotes / precedence
OMP_AUTH_BROKER_URLBase URL of the remote auth-broker (e.g. https://broker.tailnet:8765); selects broker modeResolving 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_TOKENBearer token sent on every broker endpoint except /v1/healthzOMP_AUTH_BROKER_URL is set and no token is available from auth.broker.token or <config-dir>/auth-broker.tokenResolution: 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_MSFreshness window for the encrypted local broker snapshot cacheOptional in broker modeDefault 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_CACHEPath to the encrypted local broker snapshot cacheOptional in broker modeDefaults 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 anthropic becomes: ANTHROPIC_FOUNDRY_API_KEYANTHROPIC_OAUTH_TOKENANTHROPIC_API_KEY.
  • ANTHROPIC_CUSTOM_HEADERS is parsed as comma/newline-separated key: value pairs and merged into request headers. They are also forwarded when ANTHROPIC_BASE_URL points 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 \n sequences).
VariableValue typeBehavior
CLAUDE_CODE_USE_FOUNDRYBoolean-like string (1, true, yes, on)Enables Foundry mode for Anthropic provider
FOUNDRY_BASE_URLURL stringAnthropic endpoint base URL in Foundry mode
ANTHROPIC_FOUNDRY_API_KEYToken stringUsed for Authorization: Bearer <token>
ANTHROPIC_CUSTOM_HEADERSHeader list stringExtra 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_CERTSPEM path or inline PEMExtra CA chain for server certificate validation
CLAUDE_CODE_CLIENT_CERTPEM path or inline PEMmTLS client certificate
CLAUDE_CODE_CLIENT_KEYPEM path or inline PEMmTLS client private key (must be paired with cert)

Amazon Bedrock

VariableDefault / behavior
AWS_REGIONPrimary region source
AWS_DEFAULT_REGIONFallback if AWS_REGION unset
AWS_PROFILEEnables named profile auth path
AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEYEnables IAM key auth path
AWS_BEARER_TOKEN_BEDROCKHighest-precedence bearer token auth path; skips AWS profile/credential-chain lookup when set
AWS_CONTAINER_CREDENTIALS_RELATIVE_URI / AWS_CONTAINER_CREDENTIALS_FULL_URIMarks 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_ARNMarks Bedrock as available in provider detection (same caveat as the ECS variables above)
AWS_BEDROCK_SKIP_AUTHIf 1, injects dummy credentials (proxy/non-auth scenarios)
HTTPS_PROXY / HTTP_PROXYHonored via Bun’s native fetch proxy support (the provider no longer ships an AWS SDK / proxy-agent transport)
NO_PROXYExcludes matching hosts from Bun’s native proxy routing

Region fallback in provider code: options.regionAWS_REGIONAWS_DEFAULT_REGIONus-east-1.

Azure OpenAI Responses

VariableDefault / behavior
AZURE_OPENAI_API_KEYRequired unless API key passed as option
AZURE_OPENAI_API_VERSIONDefault v1
AZURE_OPENAI_BASE_URLDirect base URL override
AZURE_OPENAI_RESOURCE_NAMEUsed to construct base URL: https://<resource>.openai.azure.com/openai/v1
AZURE_OPENAI_DEPLOYMENT_NAME_MAPOptional 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

VariableRequired?Notes
GOOGLE_CLOUD_PROJECTYes (unless passed in options)Primary project ID source
GCP_PROJECTFallbackAlternate project ID source
GCLOUD_PROJECTFallbackAlternate project ID source
GOOGLE_CLOUD_PROJECT_IDOAuth login helper onlyUsed by Gemini CLI OAuth project discovery
GOOGLE_VERTEX_LOCATIONYes (unless passed in options)Primary Vertex location source
GOOGLE_CLOUD_LOCATIONFallbackAlternate Vertex location source
VERTEX_LOCATIONFallbackAlternate Vertex location source
GOOGLE_CLOUD_API_KEYConditionalDirect Vertex API-key auth; otherwise ADC fallback can authenticate when project and location are set
GOOGLE_APPLICATION_CREDENTIALSConditionalIf set, file must exist; otherwise ADC fallback path is checked (~/.config/gcloud/application_default_credentials.json)

Kimi

VariableDefault / behavior
KIMI_CODE_OAUTH_HOSTPrimary OAuth host override
KIMI_OAUTH_HOSTFallback OAuth host override
KIMI_CODE_BASE_URLOverrides Kimi usage endpoint base URL (usage/kimi.ts)

OAuth host chain: KIMI_CODE_OAUTH_HOSTKIMI_OAUTH_HOSThttps://auth.kimi.com.

Gemini CLI compatibility

VariableDefault / behavior
PI_AI_GEMINI_CLI_VERSIONOverrides Gemini CLI user-agent version tag (0.35.3 if unset)

OpenAI Codex responses (feature/debug controls)

VariableBehavior
PI_CODEX_DEBUG1/true enables Codex provider debug logging
PI_CODEX_WEBSOCKET1/true enables websocket transport preference
PI_OPENAI_STATEFULOverrides 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_MSPositive integer override (default 300000)
PI_CODEX_WEBSOCKET_RETRY_BUDGETNon-negative integer override (default 5)
PI_CODEX_WEBSOCKET_RETRY_DELAY_MSPositive integer base backoff override (default 500)
PI_OPENAI_STREAM_FIRST_EVENT_TIMEOUT_MSPositive integer OpenAI first-event timeout override
PI_OPENAI_STREAM_IDLE_TIMEOUT_MSPositive integer OpenAI stream idle timeout override

Cursor provider debug

VariableBehavior
DEBUG_CURSOREnables provider debug logs; 2/verbose for detailed payload snippets
DEBUG_CURSOR_LOGOptional file path for JSONL debug log output

Prompt cache compatibility switch

VariableBehavior
PI_CACHE_RETENTIONIf long, enables long retention where supported (anthropic, openai-responses, Bedrock retention resolution)

3) Web search subsystem

Search provider credentials

VariableUsed by
EXA_API_KEYExa search provider and Exa MCP tools
BRAVE_API_KEYBrave search provider
PERPLEXITY_API_KEYPerplexity search provider API-key mode
PERPLEXITY_COOKIESPerplexity cookie-auth search mode
TAVILY_API_KEYTavily search provider
ZAI_API_KEYz.ai search provider (also checks stored OAuth in agent.db)
OPENAI_API_KEY / Codex OAuth in DBCodex search provider availability/auth
PI_CODEX_WEB_SEARCH_MODELCodex search provider model override
MOONSHOT_SEARCH_API_KEY / KIMI_SEARCH_API_KEYKimi/Moonshot search provider env auth
MOONSHOT_SEARCH_BASE_URL / KIMI_SEARCH_BASE_URLKimi/Moonshot search endpoint override
KAGI_API_KEYKagi search provider
JINA_API_KEYJina search provider
PARALLEL_API_KEYParallel search provider
SEARXNG_ENDPOINT, SEARXNG_TOKENSearXNG endpoint and optional bearer token
SEARXNG_BASIC_USERNAME, SEARXNG_BASIC_PASSWORDSearXNG 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:

  1. ANTHROPIC_SEARCH_API_KEY
  2. authStorage.getApiKey("anthropic") fallback credentials (runtime/config overrides, stored API-key credentials, stored OAuth credentials, then generic Anthropic env fallback: ANTHROPIC_FOUNDRY_API_KEY in Foundry mode, otherwise ANTHROPIC_OAUTH_TOKEN / ANTHROPIC_API_KEY)

For either credential path, base URL resolution is:

  1. ANTHROPIC_SEARCH_BASE_URL
  2. FOUNDRY_BASE_URL when CLAUDE_CODE_USE_FOUNDRY is enabled
  3. ANTHROPIC_BASE_URL
  4. https://api.anthropic.com

Related vars:

VariableDefault / behavior
ANTHROPIC_SEARCH_API_KEYAPI 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_URLBase 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_MODELSearch model override. Defaults to claude-haiku-4-5.
ANTHROPIC_BASE_URLGeneric 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

VariableBehavior
PI_AUTH_NO_BORROWIf set, disables macOS native-app token borrowing path in Perplexity login flow

4) Python tooling and kernel runtime

VariableDefault / behavior
PI_PYBoolean-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_JSSame boolean-like override for the JavaScript eval backend; unset defers to the eval.js setting (default enabled)
PI_PYTHON_SKIP_CHECKIf 1, skips Python interpreter availability checks (subprocess runner still starts on demand)
PI_PYTHON_INTEGRATIONIf 1, opts gated integration tests in (e.g. python-runner.integration.test.ts) into running against real Python
PI_PYTHON_IPC_TRACEIf 1, logs NDJSON frames exchanged with the Python runner subprocess
VIRTUAL_ENVHighest-priority venv path for Python runtime resolution

Extra conditional behavior:

  • If BUN_ENV=test or NODE_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

VariableDefault / behavior
PI_SMOL_MODELEphemeral model-role override for smol (CLI --smol takes precedence)
PI_SLOW_MODELEphemeral model-role override for slow (CLI --slow takes precedence)
PI_PLAN_MODELEphemeral model-role override for plan (CLI --plan takes precedence)
PI_NO_TITLEIf set (any non-empty value), disables auto session title generation on first user message
PI_TINY_DEVICEONNX 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_DTYPEONNX 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_THINKINGIf 1, disables Anthropic interleaved thinking budget behavior and uses output-token inflation for older thinking mode
NULL_PROMPTIf true, system prompt builder returns empty string
PI_BLOCKED_AGENTBlocks a specific subagent type in task tool
PI_SUBPROCESS_CMDOverrides subagent spawn command (omp / omp.cmd resolution bypass)
PI_TASK_MAX_OUTPUT_BYTESMax captured output bytes per subagent (default 500000)
PI_TASK_MAX_OUTPUT_LINESMax captured output lines per subagent (default 5000)
PI_TIMINGIf 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_STARTUPIf 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_DIROverrides package asset base dir resolution (docs/, examples/, CHANGELOG.md)
PI_DISABLE_LSPMUXIf 1, disables lspmux detection/integration and forces direct LSP server spawning
PI_RPC_EMIT_TITLEBoolean-like flag enabling title events in RPC mode
SMITHERY_URLSmithery web URL override (default https://smithery.ai)
SMITHERY_API_URLSmithery API base URL override (default https://api.smithery.ai)
SMITHERY_API_KEYSmithery API key for managed MCP auth lookup
PUPPETEER_EXECUTABLE_PATHBrowser tool Chromium executable override
LITELLM_BASE_URLLiteLLM proxy base URL fallback (http://localhost:4000/v1 if unset); explicit providers.litellm.baseUrl / models.yml config wins
LM_STUDIO_BASE_URLDefault implicit LM Studio discovery base URL override (http://127.0.0.1:1234/v1 if unset)
OLLAMA_BASE_URLDefault implicit Ollama discovery base URL override (OLLAMA_HOST if unset, then http://127.0.0.1:11434)
OLLAMA_HOSTOllama 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_LENGTHPositive 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_URLDefault implicit Llama.cpp discovery base URL override (http://127.0.0.1:8080 if unset)
PI_EDIT_VARIANTForces edit tool variant when valid (patch, replace, hashline, apply_patch)
PI_FORCE_IMAGE_PROTOCOLForces supported image protocol (kitty, iterm2/iterm, sixel, none) where used
PI_ALLOW_SIXEL_PASSTHROUGHAllows SIXEL passthrough when PI_FORCE_IMAGE_PROTOCOL=sixel
PI_NO_PTYIf 1, disables interactive PTY path for bash tool
OMP_MCP_TIMEOUT_MSOverrides 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.

VariableDefault / behavior
PI_CONFIG_DIRConfig root dirname under home (default .omp)
PI_CODING_AGENT_DIRFull override for agent directory (default ~/<PI_CONFIG_DIR or .omp>/agent)
PWDUsed 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.)

VariableBehavior
PI_BASH_NO_CISuppresses automatic CI=true injection into spawned shell env
CLAUDE_BASH_NO_CILegacy alias fallback for PI_BASH_NO_CI
PI_BASH_NO_LOGINDisables login-shell mode; shell args become ['-c'] instead of ['-l','-c']
CLAUDE_BASH_NO_LOGINLegacy alias fallback for PI_BASH_NO_LOGIN
PI_SHELL_PREFIXOptional command prefix wrapper
CLAUDE_CODE_SHELL_PREFIXLegacy alias fallback for PI_SHELL_PREFIX
VISUALPreferred external editor command
EDITORFallback 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.

VariableUsed for
COLORTERM, TERM, WT_SESSIONColor capability detection (theme color mode)
COLORFGBGTerminal background light/dark auto-detection
TERM_PROGRAM, TERM_PROGRAM_VERSION, TERMINAL_EMULATORTerminal identity in system prompt/context
TMUX_PANE, CMUX_SURFACE_ID, KITTY_WINDOW_ID, TERM_SESSION_ID, WT_SESSIONStable per-terminal session breadcrumb IDs
SHELL, ComSpec, TERM_PROGRAM, TERMSystem info diagnostics
APPDATA, XDG_CONFIG_HOMElspmux config path resolution
HOMEPath shortening in MCP command UI

9) TUI runtime flags (shared package, affects coding-agent UX)

VariableBehavior
PI_NOTIFICATIONSoff / 0 / false suppress desktop notifications
PI_TUI_WRITE_LOGIf set, logs TUI writes to file
PI_HARDWARE_CURSORIf 1, enables hardware cursor mode
PI_NO_SYNC_OUTPUTIf set (any non-empty value), disables DEC 2026 synchronized-output wrappers while keeping TUI autowrap guards
PI_NO_DECCARAIf set (truthy), disables Kitty DECCARA rectangular-SGR background fills (forces padded-string rendering)
PI_DEBUG_REDRAWIf 1, enables redraw debug logging
PI_FORCE_IMAGE_PROTOCOLForces terminal image protocol detection (kitty, iterm2/iterm, sixel, none)
PI_TUI_RESIZE_IN_PLACE1/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

VariableBehavior
PI_COMMIT_TEST_FALLBACKIf true (case-insensitive), force commit fallback generation path
PI_COMMIT_NO_FALLBACKIf true, disables fallback when agent returns no proposal
PI_COMMIT_MAP_REDUCEIf false, disables map-reduce commit analysis path
DEBUGIf 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_CREDENTIALS path 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_CERTS when 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 (for artifact://)
  • subagent output files: <outputId>.md (for agent://)
  • subagent session JSONL sidecars: <outputId>.jsonl when 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:

  1. 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.
  2. Transient field stripping: partialJson and jsonlEvents are removed from persisted entries.
  3. Image externalization to blobs:
    • image blocks in content arrays are externalized when data is not already a blob ref and base64 length is at least threshold (BLOB_EXTERNALIZE_THRESHOLD = 1024),
    • provider-style image_url data URLs are externalized when they start with data:image/ and contain ;base64,,
    • image block data is 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>.

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_url blobs 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_url resolution 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:

  1. Every chunk is sanitized with sanitizeWithOptionalSixelPassthrough(..., sanitizeText) and appended to in-memory accounting.
  2. Optional live onChunk receives sanitized pre-column-cap chunks, throttled if configured.
  3. 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.
  4. 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.
  5. If a file sink is opened, it first writes the current buffer, then all queued/subsequent sanitized chunks.
  6. In-memory buffer is trimmed to a tail window, or to head + elision marker + tail when head retention is configured.
  7. dump() returns summary including artifactId only 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 .log file,
  • 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,
  • /path or ?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 .md output IDs when directory listing succeeds.

Read tool integration:

  • read supports offset/limit pagination for non-extraction internal URL reads,
  • rejects offset/limit when agent:// extraction is used.

Resume, fork, and move semantics

Resume

  • ArtifactManager scans existing {id}.*.log files on first allocation and continues numbering.
  • AgentOutputManager scans existing .md output IDs and continues numbering.
  • SessionManager rehydrates 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 ArtifactManager first 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

CaseBehavior
Blob file missing during image-block rehydrationWarn and keep blob:sha256: ref string in memory
Blob file missing during provider image_url rehydrationWarn and keep blob:sha256: ref string in memory
Blob read ENOENT via BlobStore.getReturns 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 diskThrows explicit No artifacts directory found
Artifact ID not foundThrows with available IDs listing
OutputSink artifact writer init failsContinues with bounded in-memory output only
Non-persistent saveArtifactStores 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

@oh-my-pi/pi-natives is a two-layer package around an ESM loader:

  1. ESM loader/package entrypoint resolves and loads the correct .node addon with createRequire, validates the release sentinel outside workspace-dev loads, and re-exports generated classes/functions plus enum runtime objects as explicit named ESM exports.
  2. 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.js
  • packages/natives/native/index.d.ts
  • packages/natives/native/loader-state.js
  • packages/natives/native/embedded-addon.js
  • packages/natives/scripts/build-native.ts
  • packages/natives/scripts/embed-native.ts
  • packages/natives/scripts/gen-enums.ts
  • packages/natives/package.json
  • crates/pi-natives/src/lib.rs

Package entrypoint and public surface

packages/natives/package.json points at generated native artifacts:

  • main: ./native/index.js
  • types: ./native/index.d.ts
  • exports["."].types: ./native/index.d.ts
  • exports["."].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-x64
    • linux-arm64
    • darwin-x64
    • darwin-arm64
    • win32-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.node or ...-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, then machdep.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:

  1. Check versioned user cache path: <getNativesDir()>/<packageVersion>/....
  2. Check legacy compiled-binary location:
    • Windows: %LOCALAPPDATA%/omp (fallback %USERPROFILE%/AppData/Local/omp)
    • non-Windows: ~/.local/bin
  3. 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#version is rejected with a reinstall hint.

Rust N-API module layer

crates/pi-natives/src/lib.rs declares exported module ownership:

  • appearance
  • ast
  • block
  • clipboard
  • crash_handler
  • fd
  • fs_cache
  • glob
  • glob_util
  • grep
  • highlight
  • html
  • iso
  • keys
  • language (re-exported from pi_ast)
  • power
  • prof
  • ps
  • pty
  • shell
  • sixel
  • snapcompact
  • summary
  • task
  • text
  • tokens
  • utils (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_modules addon 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)

  1. Consumer imports from @oh-my-pi/pi-natives.
  2. native/index.js computes platform/arch/variant and candidate paths.
  3. Optional embedded archive extraction or Windows node_modules staging can prepend a versioned-cache candidate.
  4. Each candidate is require(...)d; install/compiled loads must expose the package-version sentinel.
  5. The loaded addon object is bound to explicit named ESM exports, including generated enum objects.
  6. Caller invokes generated N-API functions/classes directly.

Glossary

  • Native addon: A .node binary loaded via Node-API (N-API).
  • Platform tag: Runtime tuple platform-arch (for example darwin-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 via optionalDependencies; the package manager installs only the host-matching one (os/cpu).
  • Variant: x64 CPU-specific build flavor (modern AVX2, baseline fallback).
  • Generated binding declaration: native/index.d.ts emitted by napi-rs during build-native.ts.
  • Version sentinel: Rust export named from the package version (for example __piNativesV16_0_3) that lets the loader reject a .node from 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.js so compiled binaries can extract matching .node payloads.

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

  1. streamSimple() (packages/ai/src/stream.ts) maps generic options and dispatches to a provider stream function.
  2. Provider stream functions translate provider-native stream events into the unified AssistantMessageEvent sequence. 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.
  3. Each provider pushes events into AssistantMessageEventStream (packages/ai/src/utils/event-stream.ts), which exposes:
    • async iteration for incremental updates
    • result() for final AssistantMessage
  4. agentLoop (packages/agent/src/agent-loop.ts) consumes those events, mutates in-flight assistant state, and emits message_update events carrying the raw assistantMessageEvent.
  5. 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_starttext_delta* → text_end
    • thinking: thinking_startthinking_delta* → thinking_end
    • tool call: toolcall_starttoolcall_delta* → toolcall_end
  • terminal event:
    • done with reason: "stop" | "length" | "toolUse"
    • or error with reason: "aborted" | "error"

AssistantMessageEventStream guarantees:

  • final result is resolved by terminal event (done or error)
  • 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_start initializes usage (input/output/cache tokens)
  • content_block_start maps to text/thinking/toolcall starts
  • content_block_delta maps:
    • text_deltatext_delta
    • thinking_deltathinking_delta
    • input_json_deltatoolcall_delta
    • signature_delta updates thinkingSignature only (no event)
  • content_block_stop emits corresponding *_end
  • message_delta.stop_reason maps via mapStopReason()

Tool-call argument streaming:

  • each tool block carries internal partialJson
  • every JSON delta appends to partialJson
  • arguments are reparsed on appended deltas via parseStreamingJsonThrottled() (re-parse only after ≥256 new bytes)
  • toolcall_end reparses once more, then strips partialJson

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.added starts reasoning/text/function-call/custom-tool blocks
  • reasoning summary events (response.reasoning_summary_text.delta) and raw reasoning events (response.reasoning_text.delta) become thinking_delta
  • output/refusal deltas become text_delta
  • response.function_call_arguments.delta and response.custom_tool_call_input.delta become toolcall_delta
  • response.output_item.done emits thinking_end / text_end / toolcall_end
  • response.completed maps status to stop reason and usage; response.failed / SDK error events throw into the wrapper’s terminal error path

Tool-call argument streaming:

  • same partialJson accumulation 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.done still 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.functionCall is treated as a complete tool call (start/delta/end emitted immediately)
  • finish reason mapped by mapStopReason() from google-shared.ts

Tool-call argument streaming:

  • function call args arrive as structured object, not incremental JSON text
  • implementation emits one synthetic toolcall_delta containing JSON.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):

  1. try JSON.parse
  2. fallback to the in-house RelaxedJson parser (relaxed/repairing) for incomplete fragments
  3. if both fail, return {}

Implications:

  • malformed or truncated argument deltas do not crash stream processing immediately
  • in-progress arguments may 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_end performs one more parse attempt before emission

Stop reasons vs transport/runtime errors

Provider stop reasons are mapped to normalized stopReason:

  • Anthropic: end_turnstop, max_tokenslength, tool_usetoolUse, safety/refusal cases→error
  • OpenAI Responses: completedstop, incompletelength, failed/cancellederror
  • Google: STOPstop, MAX_TOKENSlength, safety/prohibited/malformed-function-call classes→error

Error semantics are split in two stages:

  1. Model completion semantics (provider reported finish reason/status)
  2. 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 wraps formatErrorMessageWithRetryAfter() and appends any captured HTTP-error body / raw-request dump (the cursor wrapper calls formatErrorMessageWithRetryAfter() 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 error event
  • malformed Codex SSE JSON/framing throws from the local SSE reader
  • provider wrapper converts failures into unified terminal error events
  • 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 AgentSession auto-retry logic (message-level retry, not stream-chunk replay)

Cancellation boundaries

Cancellation is layered:

  • AI provider request: options.signal is passed into provider client stream call.
  • Provider wrapper: after stream loop, aborted signal forces error path ("Request was aborted").
  • Agent loop: checks signal.aborted before 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:

  • EventStream uses 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 emits message_start
  • on block events (text_*, thinking_*, toolcall_*): updates last assistant message, emits message_update with raw assistantMessageEvent
  • on terminal (done/error): resolves final message from response.result(), emits message_end

AgentSession then consumes those events for session-level behaviors:

  • TTSR watches message_update.assistantMessageEvent for text_delta, thinking_delta, and toolcall_delta
  • streaming edit guard inspects toolcall_delta/toolcall_end on edit calls and can abort early
  • persistence writes finalized messages at message_end
  • auto-retry examines assistant stopReason === "error" plus errorMessage heuristics

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

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

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) and rule.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.name was 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, and toolcall_delta
  • for tools exposing matcherDigest (edit/write), replace the scoped buffer with the reconstructed source snapshot and call checkSnapshot(snapshot, matchContext); otherwise append the delta into a source/tool scoped manager buffer and call checkDelta(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:

  1. Matched rules are deduplicated into #pendingTtsrInjections.
  2. #ttsrAbortPending = true and a TTSR resume gate is created.
  3. agent.abort() is called immediately.
  4. ttsr_triggered event is emitted asynchronously (fire-and-forget).
  5. 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:

  1. #ttsrAbortPending = false
  2. read ttsrManager.getSettings().contextMode
  3. if contextMode === "discard", drop the targeted partial assistant output with agent.replaceMessages(...slice(0, targetAssistantIndex))
  4. build injection content from pending rules using ttsr-interrupt.md template
  5. append and persist a hidden custom_message/runtime custom message with customType: "ttsr-injection" and details.rules
  6. mark those rule names injected, persist a ttsr_injection entry, and call agent.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’s id. There is no deferred follow-up turn and the stream is not aborted. When the tool actually produces a result, the afterToolCall hook prepends a rendered ttsr-tool-reminder.md block to ctx.result.content (a single text block inserted ahead of the tool’s own content), and persists a ttsr_injection entry 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 #pendingTtsrInjections and, after a successful non-error, non-aborted assistant message, AgentSession injects the hidden ttsr-injection custom 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 toolResult content is preserved verbatim; the reminder is prepended as an additional leading text block. Renderers that assume content[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-injection entry. Transcript readers looking for non-interrupting TTSR activity on tool-source rules MUST inspect tool results (and the persisted ttsr_injection entry 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_message with customType: "ttsr-injection" and append a ttsr_injection entry via appendTtsrInjection(...)
  • 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 the afterToolCall hook when the matched tool’s result is produced
  • createAgentSession() restores existingSession.injectedTtsrRules into ttsrManager

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 condition regex: 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: embedded builtin-defaults rules are dropped before TTSR registration; user/project rules still load.
  • globs on 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’s toolResult content via the afterToolCall hook (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

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 (priority 100)
  • omp-plugins (priority 90) — rules/*.{md,mdc} inside configured extension package roots, normalized via the shared buildRuleFromMarkdown path
  • agents (priority 70)
  • cursor (priority 50)
  • windsurf (priority 50)
  • cline (priority 40)
  • builtin-defaults (priority 1)

Native provider (builtin.ts)

Loads .omp rules from:

  • project: <cwd>/.omp/rules/*.{md,mdc} when the cwd .omp directory exists
  • user: ~/.omp/agent/rules/*.{md,mdc}
  • sticky user rule: ~/.omp/agent/RULES.md
  • sticky project rule: nearest ancestor .omp/RULES.md while 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/legacy ttsr_trigger, astCondition, scope, and interruptMode are parsed by buildRuleFromMarkdown
  • top-level RULES.md is synthesized as rule name RULES and forced to alwaysApply: 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 cwd to 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 string
  • alwaysApply: normalized to a boolean — true only when frontmatter has alwaysApply: true (anything else becomes false)
  • globs: accepts array (string elements only) or single string
  • condition/legacy ttsr_trigger, astCondition, scope, and interruptMode are parsed by shared rule helpers
  • name from filename without extension

Windsurf provider (windsurf.ts)

Loads from:

  • user: ~/.codeium/windsurf/memories/global_rules.md (fixed rule name global_rules)
  • project: <cwd>/.windsurf/rules/*.md

Normalization:

  • globs: array-of-string or single string
  • alwaysApply, description, condition/legacy ttsr_trigger, astCondition, scope, and interruptMode parsed by shared rule helpers
  • name is fixed to global_rules for 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 *.md inside it
  • if file: loads single file as rule named clinerules

Normalization:

  • globs: array-of-string or single string
  • alwaysApply, description, condition/legacy ttsr_trigger, astCondition, scope, and interruptMode parsed by shared rule helpers
  • name is fixed to clinerules for a .clinerules file and derived from filename for .clinerules/*.md

3. Frontmatter parsing behavior and ambiguity

All providers use parseFrontmatter (utils/frontmatter.ts) with these semantics:

  1. Frontmatter is parsed only when content starts with --- and has a closing \n---.
  2. Body is trimmed after frontmatter extraction.
  3. If YAML parse fails:
    • warning is logged,
    • parser falls back to simple key: value line parsing (^([\w-]+):\s*(.*)$).

Ambiguity consequences:

  • Fallback parser does not support arrays, nested objects, or quoting rules.
  • Fallback values become strings (for example alwaysApply: true becomes string "true"), so providers requiring boolean/string types may drop metadata.
  • ttsr_trigger works in fallback (underscore key); hyphenated keys like thinking-level also 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 (cursor before windsurf from discovery/index.ts).
  • Dedup is first-wins: first encountered rule name is kept; later same-name items are marked _shadowed in all and excluded from items.

Effective rule provider order is currently:

  1. native (100)
  2. omp-plugins (90)
  3. agents (70)
  4. cursor (50)
  5. windsurf (50)
  6. cline (40)
  7. 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:

  • native appends project .omp/rules, user ~/.omp/agent/rules, user RULES.md, then nearest project RULES.md.
  • omp-plugins appends rules/ results per configured extension package root.
  • agents appends project-walk .agent/.agents rule dirs before user home dirs.
  • cursor appends user then project results.
  • windsurf appends user global_rules first, then project rules.
  • cline loads only nearest .clinerules source.
  • builtin-defaults uses 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:

  1. Drop rules listed in ttsr.disabledRules.
  2. Drop rules from the builtin-defaults provider when ttsr.builtinRules === false.
  3. Register rules with a non-empty condition or astCondition into TtsrManager; if registration succeeds, the rule is TTSR-only.
  4. Put remaining alwaysApply === true rules into alwaysApplyRules.
  5. Put remaining rules with description into rulebookRules.

Bucket behavior

  • TTSR bucket: any enabled rule with a non-empty parsed condition (regex) or astCondition (ast-grep patterns) that TtsrManager.addRule(...) accepts. Takes priority over other buckets.
  • Always-apply bucket: alwaysApply === true, not TTSR. Full content injected into system prompt. Resolvable via rule://.
  • Rulebook bucket: must have description, must not be TTSR, must not be alwaysApply. Listed in system prompt by name+description; content read on demand via rule://.
  • A rule with both a trigger condition and alwaysApply goes to TTSR only if TTSR registration accepts it; otherwise it can fall through to always-apply.
  • A rule with both alwaysApply and description goes 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 (extensions mode 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

  • condition is the regex TTSR trigger field; legacy ttsr_trigger / ttsrTrigger are accepted as fallback inputs during parsing.
  • astCondition is 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 set condition, astCondition, or both.
  • scope narrows TTSR matching scope. A condition token that looks like a file glob becomes tool:edit(<glob>) and tool:write(<glob>) scope entries plus catch-all condition .*; astCondition tokens never trigger this shorthand.
  • interruptMode can 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 via rule://.
  • Resolution is exact name match.
  • Unknown names return error listing available rule names.
  • Returned content is raw rule.content (frontmatter stripped), content type text/markdown.

9. Known partial / non-enforced semantics

  1. The rule providers currently loaded for rules are native, omp-plugins, agents, cursor, windsurf, cline, and embedded builtin-defaults; provider files for other tools may parse other config formats but do not register rule loaders.
  2. globs metadata 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 for rule://.
  3. 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 nor alwaysApply.
  4. Discovery warnings (loadCapability("rules").warnings) are produced but createAgentSession does 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.rs
    • crates/pi-natives/src/fd.rs (fuzzyFind)
    • crates/pi-natives/src/grep.rs (cached directory mode only)
    • crates/pi-natives/src/ast.rs (astGrep/astEdit file 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 root directory path
  • include_hidden boolean
  • use_gitignore boolean
  • skip_node_modules boolean
  • detail (ScanDetail::Minimal or ScanDetail::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_modules do 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_links is part of ScanOptions used to build the walker, but is not currently part of CacheKey; calls that differ only by follow_links can 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
  • .git is always pruned
  • node_modules is pruned at traversal time when skip_node_modules=true
  • cancellation is checked before the walk and every 128 visited entries per parallel visitor
  • ScanDetail::Minimal records normalized relative path and file type only
  • ScanDetail::Full also 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 (default 1000)
  • FS_SCAN_EMPTY_RECHECK_MS (default 200)
  • FS_SCAN_CACHE_MAX_ENTRIES (default 16)

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
  • force_rescan(..., store=false): remove any matching key, scan fresh, and do not repopulate cache
  • force_rescan(..., store=true): remove any matching key, scan fresh, then store the new entry
  • max entry enforcement is oldest-first eviction by created_at after 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 one force_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 threshold
  • fuzzyFind (fd.rs): rechecks only when query is non-empty and scored matches are empty
  • grep: rechecks when cached directory candidate file list is empty
  • astGrep/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_modules is included only when includeNodeModules=true or the pattern mentions node_modules; full detail is used only when sortByMtime=true
  • fuzzyFind: hidden=false, gitignore=true, cache=false, node_modules is skipped, follow_links=true, minimal detail
  • grep: hidden=true, gitignore=true, cache=false; cached directory mode skips node_modules unless the glob mentions node_modules; minimal detail
  • astGrep/astEdit (file discovery): hidden=true, gitignore=true, always cached; node_modules is skipped unless the glob mentions node_modules; follow_links=false; minimal detail

Current callers:

  • @-mention fuzzy file autocomplete enables cache (fuzzyFind with cache: 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 native grep with cache: 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

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.ts
  • packages/coding-agent/src/edit/hashline/filesystem.ts
  • packages/coding-agent/src/edit/modes/patch.ts
  • packages/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:

  1. Use stable scan policy inputs

    • decide hidden/gitignore/node_modules/detail semantics first
    • pass them consistently to get_or_scan/force_rescan so cache partitions are intentional
  2. 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
  3. 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
  4. 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
  5. 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
  6. 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/cached grep/astGrep share scan entries only when key dimensions (root, hidden, gitignore, skip_node_modules, detail) match.
  • .git is 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.rs
  • crates/pi-natives/src/html.rs
  • crates/pi-natives/src/clipboard.rs
  • crates/pi-natives/src/tokens.rs
  • crates/pi-natives/src/appearance.rs
  • crates/pi-natives/src/power.rs
  • crates/pi-natives/src/prof.rs
  • crates/pi-natives/src/task.rs
  • packages/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 exportRust N-API exportRust module
encodeSixel(bytes, width, height)encode_sixelsixel.rs
htmlToMarkdown(html, options?)html_to_markdownhtml.rs
copyToClipboard(text)copy_to_clipboardclipboard.rs
readImageFromClipboard()read_image_from_clipboardclipboard.rs
countTokens(input, encoding?)count_tokenstokens.rs
detectMacOSAppearance()detect_macos_appearanceappearance.rs
MacAppearanceObserver.start(cb)MacAppearanceObserver::startappearance.rs
MacOSPowerAssertion.start(options?)MacOSPowerAssertion::startpower.rs
getWorkProfile(lastSeconds)get_work_profileprof.rs

Data format boundaries and conversions

SIXEL image encoding (sixel)

  • JS input boundary: Uint8Array containing encoded image bytes.
  • Rust decode boundary: format is guessed with ImageReader::with_guessed_format(), then decoded to DynamicImage.
  • Resize boundary: image is resized with resize_exact(..., FilterType::Lanczos3) only when source dimensions differ from targetWidthPx/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 string promise.

Conversion behavior:

  • cleanContent defaults to false.
  • When cleanContent=true, preprocessing is enabled with PreprocessingPreset::Aggressive, remove_navigation=true, and remove_forms=true.
  • skipImages defaults to false and is passed to html_to_markdown_rs::ConversionOptions.

Clipboard (clipboard)

  • copyToClipboard(text) is a synchronous native call using arboard::Clipboard::set_text. On Linux a single process-lifetime Clipboard instance is kept alive (X11/Wayland selection ownership); macOS/Windows use a transient instance per call.
  • readImageFromClipboard() runs in task::blocking("clipboard.read_image", (), ...).
  • Image read returns null/undefined when arboard reports ContentNotAvailable.
  • 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; Cl100kBase is also exported.
  • The implementation uses encode_ordinary, not special-token handling.
  • BPE tables are initialized once through LazyLock and reused.

macOS appearance and power helpers

  • detectMacOSAppearance() returns "dark", "light", or null on non-macOS.
  • MacAppearanceObserver.start(callback) returns a handle with stop(); 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 with stop(); 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, idle behavior is used by default.

Work profiling (prof)

  • Collection boundary: profiling samples are produced by profile_region(tag) guards in task::blocking and task::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 summary
    • svg: optional flamegraph SVG
    • totalMs, sampleCount

Lifecycle and state transitions

SIXEL lifecycle

  1. encodeSixel(bytes, targetWidthPx, targetHeightPx) validates target dimensions.
  2. Rust guesses and decodes the encoded image.
  3. Image is resized exactly to the target dimensions when needed.
  4. Pixels are converted to RGBA8 and encoded with icy_sixel::sixel_encode.
  5. 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

  1. htmlToMarkdown(html, options) schedules a blocking conversion task.
  2. Conversion runs with defaulted options (cleanContent=false, skipImages=false) unless specified.
  3. Returns markdown string or rejects with Conversion error: ....

Clipboard lifecycle

  • Text copy calls set_text synchronously; macOS/Windows construct a transient arboard::Clipboard per call, while Linux initializes one process-lifetime instance on first copy and reuses it.
  • Image read constructs an arboard::Clipboard, calls get_image, encodes PNG on success, maps ContentNotAvailable to None, and rejects other errors.

Work profiling lifecycle

  1. No explicit start: profiling is active when task helpers execute.
  2. Every instrumented task scope records one sample on guard drop.
  3. Samples overwrite oldest entries after buffer capacity is reached.
  4. getWorkProfile(lastSeconds) reads a time window and derives folded/summary/svg artifacts.

Failure transitions:

  • SVG generation failure is soft (svg omitted/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 iso subsystem.

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.ts runs every MCP inputSchema through this dispatcher.
  • sanitizeSchemaForOpenAIResponses(schema) (alias normalizeSchemaForOpenAIResponses) — rewrites oneOfanyOf for the Responses family.
  • sanitizeSchemaForStrictMode(schema) and enforceStrictSchema(schema) / tryEnforceStrictSchema(schema) — the OpenAI strict-mode pipeline (sanitize → enforce). All three are exported from normalize.ts.
  • adaptSchemaForStrict(schema, strict) from ./adapt — thin composer that upgrades draft-07 inputs to 2020-12 and wraps tryEnforceStrictSchema for provider call sites. ./adapt also exports the NO_STRICT global-bypass flag (env PI_NO_STRICT) honored by every provider that emits strict: true.

Removed in the unified-flow refactor:

  • strict-mode.ts (merged into normalize.ts).
  • sanitize-google.ts and normalize-cca.ts (replaced by normalizeSchemaFor* dispatchers).
  • StringEnum helper — use z.enum([...]) directly; Zod’s emitted JSON Schema is already wire-compatible with Google and other providers.
  • sanitizeSchemaFor{Google,CCA,MCP} / prepareSchemaForCCA — renamed to normalizeSchemaFor{Google,CCA,MCP}.

Dispatcher mapping

Provider transport(s)Dispatcher
openai-completions, openai-responses, openai-codex-responsesadaptSchemaForStrict (sanitize + enforce)
openai-responses family (oneOfanyOf only)normalizeSchemaForOpenAIResponses
google-generative-ai, google-vertex, Gemini CLInormalizeSchemaForGoogle
Cloud Code Assist Claude (Antigravity + GCA, claude-* model ids)normalizeSchemaForCCA
MCP inputSchema ingestionnormalizeSchemaForMCP
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:

  1. Renames snake_case combinator/property keys to camelCase (any_ofanyOf, etc.; collisions follow python-genai pop(from)/set(to) semantics — snake_case wins).
  2. Applies the handle_null_fields collapse for nullable unions before recursing into children.
  3. Strips keys the target provider does not support, optionally lifting human-meaningful keys (pattern, format, min/max, default, examples, …) into the sibling description via the spill formatter (spill.ts). Structural/meta keys ($ref, $defs, additionalProperties) are not spilled.
  4. Normalizes type unions (type: ["T", "null"]type: "T" + nullable marker on Google, plain type: "T" on CCA).
  5. Collapses object-only / same-type combiners, optionally lossy-collapses mixed-type combiners (CCA only), and runs the residual-combiner fixpoint.
  6. Validates with the in-house structural validator (isValidJsonSchema from meta-validator.ts) when validateAndFallback is set (CCA path) and emits the per-tool fallback { "type": "object", "properties": {} } on residual incompatibility — type array, type: "null", nullable key, or any remaining anyOf/oneOf/allOf.

OpenAI strict-mode pipeline

adaptSchemaForStrict(schema, strict) runs tryEnforceStrictSchema, which composes:

  1. Sanitize (sanitizeSchemaForStrictMode): strips non-structural keywords (format, pattern, min/max, examples, default, if/then/else, not, unevaluated*, patternProperties, dependent*, content*, min/maxProperties, $dynamicRef, etc.). The default value is inlined into the sibling description as (default: X) before being dropped, unless description already contains (default: or no description exists.
  2. Enforce (enforceStrictSchema): every object node gets additionalProperties: false, every property goes into required, and optional properties become nullable unions (anyOf: [<original>, { "type": "null" }]). Tuple prefixItems are 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 $ref inlining. 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 as openai-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 (matches openai-python’s _pydantic.py:79-83). Multi- item allOf is 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/required only stay on the object variant, items only on the array variant). The shared description is hoisted onto the anyOf wrapper instead of being duplicated on every branch — so a strict nullable union becomes { anyOf: [T, { type: "null" }], description: "..." }, not anyOf: [{ ..., description }, { ..., description }].
  • Enum/const without a type. Both sanitize and enforce paths call inferStrictPrimitiveTypeFromEnumOrConst to infer the primitive type from enum / const values. Mixed-primitive enums ([1, "two", null]), enums containing objects/arrays, and non-primitive const values ({a:1}, [1,2,3]) cannot be described by a single type keyword 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 calling resolveProviderModels with the same staticModels array 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_fingerprint matches the current one, resolveProviderModels returns the cached models verbatim — the cache already incorporates the same static state, so re-running mergeDynamicModels(static, cache) would just rebuild the same objects.
  • mergeModelSources and mergeDynamicModels short-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.

  • 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 — MCP inputSchema ingestion via normalizeSchemaForMCP.
  • 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

  1. ci:release:build-binaries builds and ad-hoc signs the binary (so it can run on the build runner).
  2. scripts/ci-macos-sign.sh then:
    • 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 --version and --smoke-test under the new signature to fail fast;
    • notarizes the binary via notarytool submit --wait.
  3. release_github_verify re-downloads the published arm64 asset and asserts it is not ad-hoc, passes codesign --verify --strict, and boots cleanly.

Why the entitlements are mandatory

The binary is a Bun single-file executable, so the hardened runtime needs:

EntitlementReason
com.apple.security.cs.allow-jitJavaScriptCore JITs at runtime.
com.apple.security.cs.allow-unsigned-executable-memoryJSC executable memory pages.
com.apple.security.cs.disable-library-validationomp 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 | shcurl sets 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 .pkg or .dmg (xcrun stapler staple works on those). That is a follow-up and is not required for the curl/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.

SecretWhat it is
APPLE_CERTIFICATE_P12base64 of the exported Developer ID Application .p12 (cert + private key).
APPLE_CERTIFICATE_PASSWORDpassword you set when exporting the .p12.
APPLE_API_KEY_IDApp Store Connect API Key ID.
APPLE_API_ISSUER_IDApp Store Connect API Issuer ID (UUID).
APPLE_API_KEYbase64 of the App Store Connect .p8 private key.

Producing the credential files

Drop these into a working directory (default ~/omp-signing):

FileHow
*.p12Keychain 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.txtthe password you just set on the .p12.
AuthKey_<KEYID>.p8App 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.txtthe Issuer ID (UUID) shown above the keys table.
key-id.txtoptional — 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                       # confirm

Re-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-arm64

Historical 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

ModelLeaks in tool argsCallsper million
gpt-5.437226,957163
gpt-5.3-codex17112,243151
gpt-5.5280,75025
gpt-5.2-codex0

Plus 15 hits in assistant visible text / thinking blobs.

2.2 Tool distribution

ToolHits
edit38
eval11
report_tool_issue3
grep/read/search/yield1 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 stringSingle-tokenToken IDHits in corpus
Japgolly199,7451
Jsii114,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:

VariantCallsRecovery
Patch-DSL ([PATH#TAG]/anchor/SWAP DEL INS ops)27Recoverable by op-truncation (§3.3)
JSON-schema ({path,edits:[…]})11Not 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:

  1. 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.
  2. A glitch token g is sampled. By construction g was in the BPE merge corpus but barely in LM/RL training, so its input embedding e_g ≈ near-init noise of small norm.
  3. 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 from e_x steering the residual into different sub-regions — stripped here.
  4. 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.
  5. 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.
  6. 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.
  7. 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 Patch format 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 CLI SYSTEM.md path)
  • 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).

InputSourceEffect
--system-prompt <text-or-file>CLI flagReplaces 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 flagAdds 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.mdSame discovery as SYSTEM.mdSame 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):

  1. --system-prompt
  2. project SYSTEM.md
  3. 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-prompt or SYSTEM.md replaces only block 0. The stable default instructions are removed, but the dynamic project/environment footer from project-prompt.md remains as defaultPrompt.slice(1).
  • Providing --append-system-prompt or APPEND_SYSTEM.md without 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.


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

  • dedupePromptSource drops a systemPromptCustomization block when it already appears in an internally supplied customPrompt or append prompt.
  • dedupeAlwaysApplyRules omits 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 / discoverAppendSystemPromptFile in main.ts, which feeds resolvedSystemPrompt / resolvedAppendPrompt) calls findConfigFile. findConfigFile checks 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.md are ignored when omp is started from a subdirectory.
  • The secondary capability path (loadSystemPromptFiles → builtin discovery) does walk up via findNearestProjectConfigDir and requires the project .omp/ directory to be non-empty. Its result is rendered into the template variable systemPromptCustomization. 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

GoalUse
Add an instruction on top of the full default promptAPPEND_SYSTEM.md or --append-system-prompt
Replace the stable default instructions but keep project/environment contextSYSTEM.md or --system-prompt
Preserve generated skills/rules/tool guidance while customizingAPPEND_SYSTEM.md; SYSTEM.md replaces that generated block
Customize automatic session titlesTITLE_SYSTEM.md; chat-turn SYSTEM.md / APPEND_SYSTEM.md do not affect title generation
Use {{cwd}} / {{date}} / other internals in my fileNot supported. Files are inserted verbatim.
Inherit specific sections from system-prompt.mdNot supported; use append, or copy what you need into SYSTEM.md.
Override at a per-repo levelProject .omp/SYSTEM.md under the cwd you launch omp from
Override globally~/.omp/agent/SYSTEM.md or ~/.omp/agent/APPEND_SYSTEM.md