docs(coding-agent): updated documentation

- Updated documentation to reflect product name change from 'pi' to 'omp' throughout guides and API references.
- Restructured extension and hook documentation to clarify discovery mechanisms, loading behavior, and configuration across multiple config systems (.omp, .pi, .claude, .codex).
- Updated SDK API documentation with new method signatures: discoverHooks() -> discoverExtensions(), SessionManager methods now async, settings format changed to YAML.
- Expanded session architecture documentation with new entry types (TtsrInjectionEntry, SessionInitEntry), updated field names (fromHook -> fromExtension), and clarified session file format versioning.
- Simplified session-tree-plan.md from detailed implementation checklist to architecture summary, removing completed tasks and rollout details.
This commit is contained in:
can1357
2026-02-05 00:54:32 +01:00
parent 1949a09187
commit 7d60a1af85
18 changed files with 1846 additions and 2451 deletions
+73 -58
View File
@@ -8,6 +8,8 @@ Use it for any merge: single file, feature branch, or full release sync.
**Commit:** `82d7da878`
**Date:** 2026-01-30
Update this section after each sync; do not reuse the previous range.
When starting a new sync, generate patches from this commit forward:
```bash
@@ -26,14 +28,15 @@ git format-patch 82d7da878..HEAD --stdout > changes.patch
- Avoid copying built artifacts or generated files.
- If upstream added new files, add them explicitly and review contents.
## 2) Remove `.js` from imports
## 2) Match import extension conventions
We use a bundler and strip `.js` from TypeScript imports.
Most runtime TypeScript sources omit `.js` in internal imports, but some test/bench entrypoints keep `.js` for ESM
runtime compatibility. Follow the local package’s existing style; do not blanket-strip extensions.
- Remove `.js` extensions from all internal imports.
- Keep real file extensions only when required by tooling (e.g., `.json`, `.css`).
- Example:
- `import { x } from "./foo.js";` -> `import { x } from "./foo";`
- In `packages/coding-agent` runtime sources, keep internal imports extensionless unless importing non-TS assets.
- In `packages/tui/test` and `packages/natives/bench`, keep `.js` where surrounding files already use it.
- Keep real file extensions when required by tooling (e.g., `.json`, `.css`, `.md` text embeds).
- Example: `import { x } from "./foo.js";` → `import { x } from "./foo";` (only when the package convention is extensionless).
## 3) Replace import scopes
@@ -41,9 +44,10 @@ Upstream uses different package scopes. Replace them consistently.
- Replace old scopes with the local scope used here.
- Examples (adjust to match the actual packages you are porting):
- `@mariozechner/pi-coding-agent` -> `@oh-my-pi/pi-coding-agent`
- `@mariozechner/pi-agent-core` -> `@oh-my-pi/pi-agent-core`
- `@mariozechner/tui` -> `@oh-my-pi/pi-tui`
- `@mariozechner/pi-coding-agent` → `@oh-my-pi/pi-coding-agent`
- `@mariozechner/pi-agent-core` → `@oh-my-pi/pi-agent-core`
- `@mariozechner/pi-tui` → `@oh-my-pi/pi-tui`
- `@mariozechner/pi-ai` → `@oh-my-pi/pi-ai`
## 4) Use Bun APIs where they improve on Node
@@ -51,7 +55,7 @@ We run on Bun. Replace Node APIs only when Bun provides a better alternative.
**DO replace:**
- Process spawning: `child_process.spawn` → `Bun.spawn` / `Bun.spawnSync`
- Process spawning: `child_process.spawn` → Bun Shell `$` for simple commands, `Bun.spawn`/`Bun.spawnSync` for streaming or long-running work
- File I/O: `fs.readFileSync` → `Bun.file().text()` / `Bun.write()`
- HTTP clients: `node-fetch`, `axios` → native `fetch`
- Crypto hashing: `node:crypto` → Web Crypto or `Bun.hash`
@@ -65,7 +69,14 @@ We run on Bun. Replace Node APIs only when Bun provides a better alternative.
- `fs.mkdtempSync()` — do NOT replace with manual path construction
- `path.join()`, `path.resolve()`, etc. — these are fine
**Import style:** Use `node:` prefix for Node builtins with namespace imports (`import * as os from "node:os"`).
**Import style:** Use the `node:` prefix with namespace imports only (no named imports from `node:fs` or `node:path`).
**Additional Bun conventions:**
- Prefer Bun Shell `$` for short, non-streaming commands; use `Bun.spawn` only when you need streaming I/O or process control.
- Use `Bun.file()`/`Bun.write()` for files and `node:fs/promises` for directories.
- Avoid `Bun.file().exists()` checks; use `isEnoent` handling in try/catch.
- Prefer `Bun.sleep(ms)` over `setTimeout` wrappers.
**Wrong:**
@@ -91,21 +102,22 @@ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "myapp-"));
Do not copy runtime assets or vendor files at build time.
- If upstream copies assets into a dist folder, replace with Bun-friendly embeds.
- Use `import.meta.dir` + `Bun.file` to load adjacent resources.
- Prompts are static `.md` files; use Bun text imports (`with { type: "text" }`) and Handlebars instead of inline prompt strings.
- Use `import.meta.dir` + `Bun.file` to load adjacent non-text resources.
- Keep assets in-repo and let the bundler include them.
- Eliminate copy scripts unless the user explicitly requests them.
- If upstream reads a bundled fallback file at runtime, replace filesystem reads with a Bun text embed import.
- Example (Codex instructions fallback):
- `const FALLBACK_PROMPT_PATH = join(import.meta.dir, "codex-instructions.md");` -> removed
- `import FALLBACK_INSTRUCTIONS from "./codex-instructions.md" with { type: "text" };`
- Use `return FALLBACK_INSTRUCTIONS;` instead of `readFileSync(FALLBACK_PROMPT_PATH, "utf8")`
- Example (Codex instructions fallback):
- `const FALLBACK_PROMPT_PATH = join(import.meta.dir, "codex-instructions.md");` -> removed
- `import FALLBACK_INSTRUCTIONS from "./codex-instructions.md" with { type: "text" };`
- Use `return FALLBACK_INSTRUCTIONS;` instead of `readFileSync(FALLBACK_PROMPT_PATH, "utf8")`
## 6) Port `package.json` carefully
Treat `package.json` as a contract. Merge intentionally.
- Keep existing `name`, `version`, `type`, `exports`, and `bin` unless the port requires changes.
- Replace npm/node scripts with Bun equivalents (e.g., `bun run`, `bun test`).
- Replace npm/node scripts with Bun equivalents (e.g., `bun check`, `bun test`).
- Ensure dependencies use the correct scope.
- Do not downgrade dependencies to fix type errors; upgrade instead.
- Validate workspace package links and `peerDependencies`.
@@ -114,16 +126,19 @@ Treat `package.json` as a contract. Merge intentionally.
- Keep existing formatting conventions.
- Do not introduce `any` unless required.
- Avoid dynamic imports and inline type imports.
- Avoid dynamic imports and inline type imports; use top-level imports only.
- Never build prompts in code; prompts are static `.md` files rendered with Handlebars.
- In coding-agent, never use `console.log`/`console.warn`/`console.error`; use `logger` from `@oh-my-pi/pi-utils`.
- Use `Promise.withResolvers()` instead of `new Promise((resolve, reject) => ...)`.
- Prefer existing helpers and utilities over new ad-hoc code.
- Preserve Bun-first infrastructure changes already made in this repo:
- Runtime is Bun (no Node entry points).
- Package manager is Bun (no npm lockfiles).
- Heavy Node APIs (`child_process`, `readline`) are replaced with Bun equivalents.
- Lightweight Node APIs (`os.homedir`, `os.tmpdir`, `fs.mkdtempSync`, `path.*`) are kept.
- CLI shebangs use `bun` (not `node`, not `tsx`).
- Packages use source files directly (no TypeScript build step).
- CI workflows run Bun for install/check/test.
- Runtime is Bun (no Node entry points).
- Package manager is Bun (no npm lockfiles).
- Heavy Node APIs (`child_process`, `readline`) are replaced with Bun equivalents.
- Lightweight Node APIs (`os.homedir`, `os.tmpdir`, `fs.mkdtempSync`, `path.*`) are kept.
- CLI shebangs use `bun` (not `node`, not `tsx`).
- Packages use source files directly (no TypeScript build step).
- CI workflows run Bun for install/check/test.
## 8) Remove old compatibility layers
@@ -143,7 +158,7 @@ Unless requested, remove upstream compatibility shims.
Run the standard checks after changes:
- `bun run check`
- `bun check`
If the repo already has failing checks unrelated to your changes, call that out.
Tests use Bun's runner (not Vitest), but only run `bun test` when explicitly requested.
@@ -224,20 +239,23 @@ rg "case \"" path/to/file.ts
Use this as a final pass before you finish:
- [ ] No `.js` import extensions in TS files
- [ ] Import extensions follow the local package convention (no blanket `.js` stripping)
- [ ] No Node-only APIs in new/ported code
- [ ] All package scopes updated
- [ ] `package.json` scripts use Bun
- [ ] Prompts are `.md` text imports (no inline prompt strings)
- [ ] No `console.*` in coding-agent (use `logger`)
- [ ] Assets load via Bun embed patterns (no copy scripts)
- [ ] Tests or checks run (or explicitly noted as blocked)
- [ ] No functionality regressions (see sections 11-12)
## 14) Commit message format
When committing a backport, use this format:
When committing a backport, follow the repo format `<type>(scope): <past-tense description>` and keep the commit
range in the title.
```
fix: backport fixes from pi-mono (<from>..<to>)
fix(coding-agent): backported pi-mono changes (<from>..<to>)
packages/<package>:
- <type>: <description>
@@ -250,7 +268,7 @@ packages/<other-package>:
**Example:**
```
fix: backport fixes from pi-mono (9f3eef65f..52532c7c0)
fix(coding-agent): backported pi-mono changes (9f3eef65f..52532c7c0)
packages/ai:
- fix: handle "sensitive" stop reason from Anthropic API
@@ -282,12 +300,12 @@ Our fork has architectural decisions that differ from upstream. **Do not port th
### UI Architecture
| Upstream | Our Fork | Reason |
| ------------------------------------------- | --------------------------- | --------------------------------------------------- |
| `FooterDataProvider` class | `StatusLineComponent` | Simpler, integrated status line |
| `ctx.ui.setHeader()` / `ctx.ui.setFooter()` | Removed | Not implemented; StatusLineComponent handles status |
| `ctx.ui.setEditorComponent()` | Removed | Not implemented |
| `InteractiveModeOptions` interface | Positional constructor args | Existing pattern works fine |
| Upstream | Our Fork | Reason |
| ------------------------------------------- | --------------------------------------------------------- | --------------------------------------------------------------------- |
| `FooterDataProvider` class | `StatusLineComponent` | Simpler, integrated status line |
| `ctx.ui.setHeader()` / `ctx.ui.setFooter()` | Stub in non-TUI modes | Implemented in TUI, no-op elsewhere |
| `ctx.ui.setEditorComponent()` | Stub in non-TUI modes | Implemented in TUI, no-op elsewhere |
| `InteractiveModeOptions` options object | Positional constructor args (options type still exported) | Keep constructor signature; update the type when upstream adds fields |
### Component Naming
@@ -300,18 +318,17 @@ Our fork has architectural decisions that differ from upstream. **Do not port th
### API Naming
| Upstream | Our Fork | Notes |
| ----------------------------------------- | ----------------------------------------- | ------------------------------------------ |
| `sessionManager.appendSessionInfo(name)` | `sessionManager.setSessionName(name)` | We use `sessionName` throughout |
| `sessionManager.getSessionName()` | `sessionManager.getSessionName()` | Same (we unified to match upstream's RPC) |
| `agent.sessionName` / `setSessionName()` | `agent.sessionName` / `setSessionName()` | Same |
| Upstream | Our Fork | Notes |
| ---------------------------------------- | ---------------------------------------- | ----------------------------------------- |
| `sessionManager.appendSessionInfo(name)` | `sessionManager.setSessionName(name)` | We use `sessionName` throughout |
| `sessionManager.getSessionName()` | `sessionManager.getSessionName()` | Same (we unified to match upstream's RPC) |
| `agent.sessionName` / `setSessionName()` | `agent.sessionName` / `setSessionName()` | Same |
### File Consolidation
| Upstream | Our Fork | Reason |
| ------------------------------------- | ------------------------ | ------------------------------------- |
| `clipboard.ts` + `clipboard-image.ts` | `clipboard.ts` only | Merged with Bun-native implementation |
| `@mariozechner/clipboard` dependency | Native platform commands | No external dependency needed |
| Upstream | Our Fork | Reason |
| -------------------------------------------------- | --------------------------------------- | --------------------------------------- |
| `clipboard.ts` + `clipboard-image.ts` (tool files) | `@oh-my-pi/pi-natives` clipboard module | Merged into N-API native implementation |
### Test Framework
@@ -322,18 +339,18 @@ Our fork has architectural decisions that differ from upstream. **Do not port th
### Tool Architecture
| Upstream | Our Fork |
| ----------------------------------- | ----------------------------------------- |
| `createTool(cwd: string, options?)` | `createTool(session: ToolSession)` |
| Per-tool `*Operations` interfaces | Unified `FileOperations` in `ToolSession` |
| Node.js `fs/promises` | Bun APIs (`Bun.file()`, `Bun.write()`) |
| Upstream | Our Fork | Notes |
| ----------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------- |
| `createTool(cwd: string, options?)` | `createTools(session: ToolSession)` via `BUILTIN_TOOLS` registry | Tool factories accept `ToolSession` and can return `null` |
| Per-tool `*Operations` interfaces | Per-tool interfaces remain (`FindOperations`, `GrepOperations`) | Used for SSH/remote overrides |
| Node.js `fs/promises` everywhere | `Bun.file()`/`Bun.write()` for files; `node:fs/promises` for dirs | Prefer Bun APIs when they simplify |
### Auth Storage
| Upstream | Our Fork |
| ------------------------------ | ------------------------------------------- |
| `proper-lockfile` library | Native `O_EXCL` atomic file locking |
| Single credential per provider | Multi-credential with round-robin selection |
| Upstream | Our Fork | Notes |
| ------------------------------- | ------------------------------------------- | ----------------------------------------------------- |
| `proper-lockfile` + `auth.json` | `agent.db` (bun:sqlite) | Legacy `auth.json` is migrated; do not reintroduce it |
| Single credential per provider | Multi-credential with round-robin selection | Session affinity and backoff logic preserved |
### Extensions
@@ -347,8 +364,7 @@ Our fork has architectural decisions that differ from upstream. **Do not port th
When porting, **skip** these files/features entirely:
- `footer-data-provider.ts` — we use StatusLineComponent
- `clipboard-image.ts` — merged into clipboard.ts
- `compaction-extensions.test.ts` — different test architecture
- `clipboard-image.ts` — clipboard is in `@oh-my-pi/pi-natives` N-API module
- GitHub workflow files — we have our own CI
- `models.generated.ts` — auto-generated, regenerate locally
@@ -358,8 +374,7 @@ These exist in our fork but not upstream. **Never overwrite:**
- `StatusLineComponent` in interactive mode
- Multi-credential auth with session affinity
- Capability-based discovery system (`loadSync`, `skillCapability`, etc.)
- Voice mode integration
- Capability-based discovery system (`defineCapability`, `registerProvider`, `loadCapability`, `skillCapability`, etc.)
- MCP/Exa/SSH integrations
- LSP writethrough for format-on-save
- Bash interception (`checkBashInterception`)
+46 -27
View File
@@ -6,82 +6,100 @@ This is a practical guide for moving hot paths into `crates/pi-natives` and wiri
Port when any of these are true:
- The hot path is called in render loops or large batches.
- JS allocates too much (string churn, regex backtracking, large arrays).
- You already have a JS implementation and can write a benchmark for it.
- The hot path runs in render loops, tight UI updates, or large batches.
- JS allocations dominate (string churn, regex backtracking, large arrays).
- You already have a JS baseline and can benchmark both versions side by side.
- The work is CPU-bound or blocking I/O that can run on the libuv thread pool.
- The work is async I/O that can run on Tokio's runtime (e.g., shell execution).
Do not port code that depends on JS-only state, dynamic imports, or anything async-first. N-API favors CPU-bound, synchronous work.
Avoid ports that depend on JS-only state or dynamic imports. N-API exports should be pure, data-in/data-out. Long-running work should go through `task::blocking` (CPU-bound/blocking I/O) or `task::future` (async I/O) with cancellation.
## Anatomy of a native export
**Rust side:**
- Implementation lives in `crates/pi-natives/src/<module>.rs`.
- Export lives either in that module **with** `#[napi]` or in `lib.rs` calling into the module.
- Implementation lives in `crates/pi-natives/src/<module>.rs`. If you add a new module, register it in `crates/pi-natives/src/lib.rs`.
- Export with `#[napi]` and `#[napi(js_name = "...")]` to keep JS-facing camelCase names. Use `#[napi(object)]` for structs.
- Use `task::blocking(tag, cancel_token, work)` (see `crates/pi-natives/src/task.rs`) for CPU-bound or blocking work. Use `task::future(env, tag, work)` for async work that needs Tokio (e.g., shell sessions). Pass a `CancelToken` when you expose `timeoutMs` or `AbortSignal`.
**JS side:**
- `packages/natives/src/native.ts` declares the binding and validates it.
- `packages/natives/src/<module>/index.ts` wraps the binding.
- `packages/natives/src/index.ts` re-exports it.
- Call sites (often in `packages/tui/src/utils.ts`) use the wrapper.
- `packages/natives/src/bindings.ts` holds the base `NativeBindings` interface.
- `packages/natives/src/<module>/types.ts` defines TS types and augments `NativeBindings` via declaration merging.
- `packages/natives/src/native.ts` imports each `<module>/types.ts` file to activate the declarations.
- `packages/natives/src/<module>/index.ts` wraps the `native` binding from `packages/natives/src/native.ts`.
- `packages/natives/src/native.ts` loads the addon and `validateNative` enforces required exports.
- `packages/natives/src/index.ts` re-exports the wrapper for callers in `packages/*`.
## Porting checklist
1) **Add the Rust implementation**
- Put the core logic in a plain Rust function.
- Expose it with `#[napi(js_name = "...")]`.
- Keep signatures simple: `String`, `Vec<String>`, `Uint8Array`, numbers, bools.
- If it’s a new module, add it to `crates/pi-natives/src/lib.rs`.
- Expose it with `#[napi(js_name = "...")]` to keep camelCase names stable.
- Keep signatures owned and simple: `String`, `Vec<String>`, `Uint8Array`, or `Either<JsString, Uint8Array>` for large string/byte inputs.
- For CPU-bound or blocking work, use `task::blocking`; for async work, use `task::future`. Pass a `CancelToken` and call `heartbeat()` inside long loops.
2) **Wire JS bindings**
- Add the method to `NativeBindings` in `packages/natives/src/native.ts`.
- Add `checkFn("newExport")` in `validateNative`.
- Add a wrapper in `packages/natives/src/<module>/index.ts`.
- Add the types and `NativeBindings` augmentation in `packages/natives/src/<module>/types.ts`.
- Import `./<module>/types` in `packages/natives/src/native.ts` to trigger declaration merging.
- Add a wrapper in `packages/natives/src/<module>/index.ts` that calls `native`.
- Re-export from `packages/natives/src/index.ts`.
3) **Add benchmarks**
- Put benchmarks in `packages/tui/bench/*.ts` (see `text-layout.ts`).
3) **Update native validation**
- Add `checkFn("newExport")` in `validateNative` (`packages/natives/src/native.ts`).
4) **Add benchmarks**
- Put benchmarks next to the owning package (`packages/tui/bench`, `packages/natives/bench`, or `packages/coding-agent/bench`).
- Include a JS baseline and native version in the same run.
- Use `performance.now()` and a fixed iteration count.
- Keep the benchmark inputs small and realistic (actual data seen in the hot path).
4) **Build the native binary**
5) **Build the native binary**
- `bun --cwd=packages/natives run build:native`
- Use `bun --cwd=packages/natives run dev:native` for debug builds (`pi_natives.dev.node`) and set `OMP_DEV=1` when loading it.
5) **Run the benchmark**
- `bun run packages/tui/bench/<bench>.ts`
6) **Run the benchmark**
- `bun run packages/<pkg>/bench/<bench>.ts` (or `bun --cwd=packages/natives run bench`)
6) **Decide on usage**
7) **Decide on usage**
- If native is slower, **keep JS** and leave the native export unused.
- If native is faster, switch call sites to the native wrapper.
## Pain points and how to avoid them
### 1) Stale `pi_natives.node` prevents new exports
The build script prefers `target/release/pi_natives.node` if it exists. If it’s stale, the exported symbols won’t update even after a rebuild.
The loader prefers the platform-tagged binary in `packages/natives/native` (`pi_natives.<platform>-<arch>.node`). When `OMP_DEV=1`, it will load `pi_natives.dev.node` instead. There is also a fallback `pi_natives.node`. Compiled binaries extract to `~/.omp/natives/<version>/pi_natives.<platform>-<arch>.node`. If any of these are stale, exports won’t update.
**Fix:** remove the stale file before rebuilding.
```bash
rm /work/pi/target/release/pi_natives.node
rm packages/natives/native/pi_natives.linux-x64.node
rm packages/natives/native/pi_natives.node
bun --cwd=packages/natives run build:native
```
If you’re running a compiled binary, delete the cached addon directory:
```bash
rm -rf ~/.omp/natives/<version>
```
Then verify the export exists in the binary:
```bash
bun -e "const mod = require('./packages/natives/native/pi_natives.linux-x64.node'); console.log(Object.keys(mod).includes('newExport'));"
bun -e 'const tag = `${process.platform}-${process.arch}`; const mod = require(`./packages/natives/native/pi_natives.${tag}.node`); console.log(Object.keys(mod).includes("newExport"));'
```
### 2) “Missing exports” errors from `validateNative`
This is **good** — it prevents silent mismatches. When you see this:
```
Native addon missing exports ... Missing: applyLineResets
Native addon missing exports ... Missing: visibleWidth
```
it means your binary is stale or the `#[napi]` export didn’t compile in. Fix the build, don’t weaken validation.
it means your binary is stale, the Rust `#[napi(js_name = "...")]` doesn’t match the JS name, or the export never compiled in. Fix the build and the naming mismatch, don’t weaken validation.
### 3) Rust signature mismatch
Keep it simple. `Vec<String>` works. Avoid references like `&str` in public exports. If you need complex types, wrap them in `#[napi(object)]` structs.
Keep it simple and owned. `String`, `Vec<String>`, and `Uint8Array` work. Avoid references like `&str` in public exports. If you need structured data, wrap it in `#[napi(object)]` structs.
### 4) Benchmarking mistakes
- Don’t compare different inputs or allocations.
@@ -113,6 +131,7 @@ bench("feature/native", () => {
## Verification checklist
- `validateNative` passes (no missing exports).
- `NativeBindings` is augmented in `packages/natives/src/<module>/types.ts` and the wrapper is re-exported in `packages/natives/src/index.ts`.
- `Object.keys(require(...))` includes your new export.
- Bench numbers recorded in the PR/notes.
- Call site updated **only if** native is faster or equal.
+202 -169
View File
@@ -1,47 +1,67 @@
# Compaction & Branch Summarization
LLMs have limited context windows. When conversations grow too long, omp uses compaction to summarize older content while preserving recent work. This page covers both auto-compaction and branch summarization.
LLMs have limited context windows. OMP uses compaction to summarize older context while keeping recent work intact, and branch summarization to capture work when moving between branches in the session tree.
**Source files:**
- [`src/core/compaction/compaction.ts`](../src/core/compaction/compaction.ts) - Auto-compaction logic
- [`src/core/compaction/branch-summarization.ts`](../src/core/compaction/branch-summarization.ts) - Branch summarization
- [`src/core/compaction/utils.ts`](../src/core/compaction/utils.ts) - Shared utilities (file tracking, serialization)
- [`src/core/session-manager.ts`](../src/core/session-manager.ts) - Entry types (`CompactionEntry`, `BranchSummaryEntry`)
- [`src/core/hooks/types.ts`](../src/core/hooks/types.ts) - Hook event types
- [`src/session/compaction/compaction.ts`](../src/session/compaction/compaction.ts) - Auto-compaction logic
- [`src/session/compaction/branch-summarization.ts`](../src/session/compaction/branch-summarization.ts) - Branch summarization
- [`src/session/compaction/utils.ts`](../src/session/compaction/utils.ts) - Shared utilities (file tracking, serialization)
- [`src/session/compaction/pruning.ts`](../src/session/compaction/pruning.ts) - Tool output pruning
- [`src/session/session-manager.ts`](../src/session/session-manager.ts) - Entry types (`CompactionEntry`, `BranchSummaryEntry`)
- [`src/extensibility/hooks/types.ts`](../src/extensibility/hooks/types.ts) - Hook event types
- [`src/prompts/compaction/*`](../src/prompts/compaction) - Summarization prompts
- [`src/prompts/system/*`](../src/prompts/system) - Summarization system prompt + file op tags
## Overview
OMP has two summarization mechanisms:
| Mechanism | Trigger | Purpose |
| -------------------- | ---------------------------------------- | ----------------------------------------- |
| Compaction | Context exceeds threshold, or `/compact` | Summarize old messages to free up context |
| Branch summarization | `/tree` navigation | Preserve context when switching branches |
| Mechanism | Trigger | Purpose |
| -------------------- | ------------------------------------------------------ | ----------------------------------------- |
| Compaction | Context overflow/threshold, or `/compact` | Summarize old messages to free up context |
| Branch summarization | `/tree` navigation (when branch summaries are enabled) | Preserve context when switching branches |
Both use the same structured summary format and track file operations cumulatively.
Compaction and branch summaries are stored as session entries and injected into LLM context as user messages via `compaction-summary-context.md` and `branch-summary-context.md`.
## Compaction
### When It Triggers
Auto-compaction triggers when:
Auto-compaction runs after a turn completes:
```
contextTokens > contextWindow - reserveTokens
```
- **Overflow recovery**: If the current model returns a context overflow error, OMP compacts and retries automatically.
- **Threshold**: If `contextTokens > contextWindow - reserveTokens`, OMP compacts without retry.
- Tool output pruning runs first and can reduce `contextTokens`.
By default, `reserveTokens` is 16384 tokens (configurable in `~/.omp/agent/settings.json` or `<project-dir>/.omp/settings.json`). This leaves room for the LLM's response.
Manual compaction is available via `/compact [instructions]`.
You can also trigger manually with `/compact [instructions]`, where optional instructions focus the summary.
Auto-compaction is controlled by `compaction.enabled`. After threshold compaction, OMP sends a synthetic "Continue if you have next steps." prompt unless `compaction.autoContinue` is set to `false`.
### How It Works
2. **Find cut point**: Walk backwards from newest message, accumulating token estimates until `keepRecentTokens` (default 20k, configurable in `~/.omp/agent/settings.json` or `<project-dir>/.omp/settings.json`) is reached
2. **Extract messages**: Collect messages from previous compaction (or start) up to cut point
3. **Generate summary**: Call LLM to summarize with structured format
4. **Append entry**: Save `CompactionEntry` with summary and `firstKeptEntryId`
5. **Reload**: Session reloads, using summary + messages from `firstKeptEntryId` onwards
1. **Prepare**: `prepareCompaction()` finds the latest compaction boundary and chooses a cut point that keeps approximately `keepRecentTokens` (adjusted using usage data).
2. **Extract**: Collect messages to summarize, plus a turn prefix if the cut point splits a turn.
3. **Track files**: Gather file ops from `read`/`write`/`edit` tool calls and previous compaction details.
4. **Summarize**:
- Main summary uses `compaction-summary.md` or `compaction-update-summary.md` if there is a previous summary.
- Split turns add a turn-prefix summary from `compaction-turn-prefix.md` and merge with:
```
<history summary>
---
**Turn Context (split turn):**
<turn prefix summary>
```
- Optional custom instructions are appended to the prompt.
- If `compaction.remoteEndpoint` is set, OMP POSTs `{ systemPrompt, prompt }` to the endpoint and expects `{ summary, shortSummary? }`.
5. **Finalize**: Generate a short PR-style summary from recent messages, append file-operation tags, persist `CompactionEntry`, and reload session context.
Compaction rewrites the session like this:
```
Before compaction:
@@ -75,87 +95,65 @@ What the LLM sees:
prompt from cmp messages from firstKeptEntryId
```
Compaction summaries are injected into the LLM context using `compaction-summary-context.md`.
### Split Turns
A "turn" starts with a user message and includes all assistant responses and tool calls until the next user message. Normally, compaction cuts at turn boundaries.
A "turn" starts with a user message and includes all assistant responses and tool calls until the next user message. `bashExecution` messages and `custom_message`/`branch_summary` entries are treated like user messages for turn boundaries.
When a single turn exceeds `keepRecentTokens`, the cut point lands mid-turn at an assistant message. This is a "split turn":
```
Split turn (one huge turn exceeds budget):
entry: 0 1 2 3 4 5 6 7 8
┌─────┬─────┬─────┬──────┬─────┬──────┬──────┬─────┬──────┐
│ hdr │ usr │ ass │ tool │ ass │ tool │ tool │ ass │ tool │
└─────┴─────┴─────┴──────┴─────┴──────┴──────┴─────┴──────┘
↑ ↑
turnStartIndex = 1 firstKeptEntryId = 7
│ │
└──── turnPrefixMessages (1-6) ───────┘
└── kept (7-8)
isSplitTurn = true
messagesToSummarize = [] (no complete turns before)
turnPrefixMessages = [usr, ass, tool, ass, tool, tool]
```
For split turns, omp generates two summaries and merges them:
1. **History summary**: Previous context (if any)
2. **Turn prefix summary**: The early part of the split turn
If a single turn exceeds `keepRecentTokens`, compaction cuts mid-turn at a non-user message (usually an assistant message). OMP produces two summaries (history + turn prefix) and merges them as shown above.
### Cut Point Rules
Valid cut points are:
- User messages
- Assistant messages
- BashExecution messages
- Hook messages (custom_message, branch_summary)
- User, assistant, bashExecution, hookMessage, branchSummary, or compactionSummary messages
- `custom_message` and `branch_summary` entries (treated as user-role messages)
Never cut at tool results (they must stay with their tool call).
Never cut at tool results; they must stay with their tool call. Non-message entries (model changes, labels, etc.) are pulled into the kept region before the cut point until a message or compaction boundary is reached.
### CompactionEntry Structure
Defined in [`src/core/session-manager.ts`](../src/core/session-manager.ts):
Defined in [`src/session/session-manager.ts`](../src/session/session-manager.ts):
```typescript
interface CompactionEntry<T = unknown> {
type: "compaction";
id: string;
parentId: string;
timestamp: number;
parentId: string | null;
timestamp: string;
summary: string;
shortSummary?: string;
firstKeptEntryId: string;
tokensBefore: number;
fromHook?: boolean; // true if hook provided the compaction
details?: T; // hook-specific data
details?: T;
preserveData?: Record<string, unknown>;
fromExtension?: boolean;
}
// Default compaction uses this for details (from compaction.ts):
// Default compaction details:
interface CompactionDetails {
readFiles: string[];
modifiedFiles: string[];
}
```
Hooks can store any JSON-serializable data in `details`. The default compaction tracks file operations, but custom compaction hooks can use their own structure.
See [`prepareCompaction()`](../src/core/compaction/compaction.ts) and [`compact()`](../src/core/compaction/compaction.ts) for the implementation.
`shortSummary` is used in the UI tree. `preserveData` stores hook-provided state across compactions. Entries created by hooks set `fromExtension` and are excluded from default file tracking.
## Branch Summarization
### When It Triggers
When you use `/tree` to navigate to a different branch, omp offers to summarize the work you're leaving. This injects context from the left branch into the new branch.
When you use `/tree` to navigate to a different branch, the UI prompts to summarize the branch you're leaving if `branchSummary.enabled` is true. You can optionally supply custom instructions.
Hooks fire regardless of user choice; a summary is only generated when `preparation.userWantsSummary` is true.
### How It Works
1. **Find common ancestor**: Deepest node shared by old and new positions
2. **Collect entries**: Walk from old leaf back to common ancestor
3. **Prepare with budget**: Include messages up to token budget (newest first)
4. **Generate summary**: Call LLM with structured format
5. **Append entry**: Save `BranchSummaryEntry` at navigation point
1. **Find common ancestor**: Deepest node shared by old and new positions.
2. **Collect entries**: Walk from old leaf back to the common ancestor (including compactions and prior branch summaries).
3. **Budget**: Keep newest messages first under the token budget (`contextWindow - branchSummary.reserveTokens`).
4. **Summarize**: Generate summary with `branch-summary.md`, prepend `branch-summary-preamble.md`, append file-op tags, and store `BranchSummaryEntry`.
```
Tree before navigation:
@@ -174,84 +172,45 @@ After navigation with summary:
└─ E ─ F (new leaf)
```
### Cumulative File Tracking
Both compaction and branch summarization track files cumulatively. When generating a summary, omp extracts file operations from:
- Tool calls in the messages being summarized
- Previous compaction or branch summary `details` (if any)
This means file tracking accumulates across multiple compactions or nested branch summaries, preserving the full history of read and modified files.
Branch summaries are injected into context using `branch-summary-context.md`.
### BranchSummaryEntry Structure
Defined in [`src/core/session-manager.ts`](../src/core/session-manager.ts):
Defined in [`src/session/session-manager.ts`](../src/session/session-manager.ts):
```typescript
interface BranchSummaryEntry<T = unknown> {
type: "branch_summary";
id: string;
parentId: string;
timestamp: number;
parentId: string | null;
timestamp: string;
fromId: string;
summary: string;
fromId: string; // Entry we navigated from
fromHook?: boolean; // true if hook provided the summary
details?: T; // hook-specific data
details?: T;
fromExtension?: boolean;
}
// Default branch summarization uses this for details (from branch-summarization.ts):
// Default branch summary details:
interface BranchSummaryDetails {
readFiles: string[];
modifiedFiles: string[];
}
```
Same as compaction, hooks can store custom data in `details`.
## Cumulative File Tracking
See [`collectEntriesForBranchSummary()`](../src/core/compaction/branch-summarization.ts), [`prepareBranchEntries()`](../src/core/compaction/branch-summarization.ts), and [`generateBranchSummary()`](../src/core/compaction/branch-summarization.ts) for the implementation.
Both compaction and branch summarization track files cumulatively.
## Summary Format
- File ops are extracted from `read`, `write`, and `edit` tool calls in assistant messages.
- Writes and edits are treated as modified files; read-only files exclude those modified.
- Compaction includes file ops from previous compaction details (only when `fromExtension` is false).
- Branch summaries include file ops from previous branch summary details even if those entries aren't within the token budget.
Both compaction and branch summarization use the same structured format:
```markdown
## Goal
[What the user is trying to accomplish]
## Constraints & Preferences
- [Requirements mentioned by user]
## Progress
### Done
- [x] [Completed tasks]
### In Progress
- [ ] [Current work]
### Blocked
- [Issues, if any]
## Key Decisions
- **[Decision]**: [Rationale]
## Next Steps
1. [What should happen next]
## Critical Context
- [Data needed to continue]
File lists are appended to the summary with XML tags:
```
<read-files>
path/to/file1.ts
path/to/file2.ts
path/to/file.ts
</read-files>
<modified-files>
@@ -259,42 +218,105 @@ path/to/changed.ts
</modified-files>
```
### Message Serialization
## Summary Format
Before summarization, messages are serialized to text via [`serializeConversation()`](../src/core/compaction/utils.ts):
### Compaction Summary Format
Prompt: [`compaction-summary.md`](../src/prompts/compaction/compaction-summary.md)
```markdown
## Goal
[User goals]
## Constraints & Preferences
- [Constraints]
## Progress
### Done
- [x] [Completed tasks]
### In Progress
- [ ] [Current work]
### Blocked
- [Issues, if any]
## Key Decisions
- **[Decision]**: [Rationale]
## Next Steps
1. [What should happen next]
## Critical Context
- [Data needed to continue]
## Additional Notes
[Anything else important not covered above]
```
File-operation tags are appended after the summary.
### Branch Summary Format
Prompt: [`branch-summary.md`](../src/prompts/compaction/branch-summary.md)
```markdown
## Goal
[What user trying to accomplish in this branch?]
## Constraints & Preferences
- [Constraints, preferences, requirements mentioned]
- [(none) if none mentioned]
## Progress
### Done
- [x] [Completed tasks/changes]
### In Progress
- [ ] [Work started but not finished]
### Blocked
- [Issues preventing progress]
## Key Decisions
- **[Decision]**: [Brief rationale]
## Next Steps
1. [What should happen next to continue]
```
### Short Summary
Compaction also generates a short PR-style summary (`compaction-short-summary.md`) for UI display. It is 2–3 sentences in first person, describing changes made.
## Message Serialization
Before summarization, messages are serialized to text via [`serializeConversation()`](../src/session/compaction/utils.ts). Messages are first converted with `convertToLlm()` so custom types (bash execution, hook messages, compaction summaries) are represented as user messages.
```
[User]: What they said
[Assistant thinking]: Internal reasoning
[Assistant]: Response text
[Assistant tool calls]: read(path="foo.ts"); edit(path="bar.ts", ...)
[Tool result]: Output from tool
[Tool result]: Output from tool (or "[Output truncated - N tokens]")
```
This prevents the model from treating it as a conversation to continue.
This prevents the model from treating the input as a conversation to continue.
## Custom Summarization via Hooks
Hooks can intercept and customize both compaction and branch summarization. See [`src/core/hooks/types.ts`](../src/core/hooks/types.ts) for event type definitions.
Hooks can customize both compaction and branch summarization. See [`src/extensibility/hooks/types.ts`](../src/extensibility/hooks/types.ts).
### session_before_compact
Fired before auto-compaction or `/compact`. Can cancel or provide custom summary. See `SessionBeforeCompactEvent` and `CompactionPreparation` in the types file.
Fired before auto-compaction or `/compact`. Can cancel or supply a custom summary.
```typescript
pi.on("session_before_compact", async (event, ctx) => {
const { preparation, branchEntries, customInstructions, signal } = event;
// preparation.messagesToSummarize - messages to summarize
// preparation.turnPrefixMessages - split turn prefix (if isSplitTurn)
// preparation.previousSummary - previous compaction summary
// preparation.fileOps - extracted file operations
// preparation.tokensBefore - context tokens before compaction
// preparation.firstKeptEntryId - where kept messages start
// preparation.settings - compaction settings
// branchEntries - all entries on current branch (for custom state)
// signal - AbortSignal (pass to LLM calls)
const { preparation, customInstructions, signal } = event;
// Cancel:
return { cancel: true };
@@ -303,6 +325,7 @@ pi.on("session_before_compact", async (event, ctx) => {
return {
compaction: {
summary: "Your summary...",
shortSummary: "Short summary...",
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
details: {
@@ -323,16 +346,7 @@ import { convertToLlm, serializeConversation } from "@oh-my-pi/pi-coding-agent";
pi.on("session_before_compact", async (event, ctx) => {
const { preparation } = event;
// Convert AgentMessage[] to Message[], then serialize to text
const conversationText = serializeConversation(convertToLlm(preparation.messagesToSummarize));
// Returns:
// [User]: message text
// [Assistant thinking]: thinking content
// [Assistant]: response text
// [Assistant tool calls]: read(path="..."); bash(command="...")
// [Tool result]: output text
// Now send to your model for summarization
const summary = await myModel.summarize(conversationText);
return {
@@ -347,9 +361,25 @@ pi.on("session_before_compact", async (event, ctx) => {
See [examples/hooks/custom-compaction.ts](../examples/hooks/custom-compaction.ts) for a complete example using a different model.
### session.compacting
Fired just before summarization to override the prompt or add extra context.
```typescript
pi.on("session.compacting", async (event, ctx) => {
return {
prompt: "Override the default compaction prompt...",
context: ["Include ticket ABC-123", "Keep recent benchmark results"],
preserveData: { artifactIndex: ["foo.ts"] },
};
});
```
`context` lines are injected as `<additional-context>` in the prompt. `preserveData` is stored on the compaction entry.
### session_before_tree
Fired before `/tree` navigation. Always fires regardless of whether user chose to summarize. Can cancel navigation or provide custom summary.
Fired before `/tree` navigation. Always fires, even if the user opts out of summarization.
```typescript
pi.on("session_before_tree", async (event, ctx) => {
@@ -378,26 +408,29 @@ pi.on("session_before_tree", async (event, ctx) => {
});
```
See `SessionBeforeTreeEvent` and `TreePreparation` in the types file.
## Settings
Configure compaction in `~/.omp/agent/settings.json` or `<project-dir>/.omp/settings.json`:
Global settings are stored in `~/.omp/agent/config.yml`. Project-level overrides are loaded from `settings.json` in config directories (for example `.omp/settings.json` or `.claude/settings.json`).
```json
{
"compaction": {
"enabled": true,
"reserveTokens": 16384,
"keepRecentTokens": 20000
}
}
```yaml
# ~/.omp/agent/config.yml
compaction:
enabled: true
reserveTokens: 16384
keepRecentTokens: 20000
autoContinue: true
remoteEndpoint: "https://example.com/compaction"
branchSummary:
enabled: false
reserveTokens: 16384
```
| Setting | Default | Description |
| ------------------ | ------- | -------------------------------------- |
| `enabled` | `true` | Enable auto-compaction |
| `reserveTokens` | `16384` | Tokens to reserve for LLM response |
| `keepRecentTokens` | `20000` | Recent tokens to keep (not summarized) |
Disable auto-compaction with `"enabled": false`. You can still compact manually with `/compact`.
| Setting | Default | Description |
| ------------------------------ | ------- | ------------------------------------------------------ |
| `compaction.enabled` | `true` | Enable auto-compaction |
| `compaction.reserveTokens` | `16384` | Tokens reserved for prompts + response |
| `compaction.keepRecentTokens` | `20000` | Recent tokens to keep |
| `compaction.autoContinue` | `true` | Auto-send a continuation prompt after compaction |
| `compaction.remoteEndpoint` | unset | Remote summarization endpoint |
| `branchSummary.enabled` | `false` | Prompt to summarize when leaving a branch |
| `branchSummary.reserveTokens` | `16384` | Tokens reserved for branch summary prompts |
+139 -76
View File
@@ -9,105 +9,168 @@ This document shows how each file uses the config module and what subpaths they
│ config.ts exports │
├─────────────────────────────────────────────────────────────────────────────────┤
│ Constants: APP_NAME, CONFIG_DIR_NAME, VERSION │
│ Single paths: getAgentDir, getAuthPath, getModelsPath, getCommandsDir, ... │
│ Single paths: getAgentDir, getAuthPath, getModelsPath, getModelsYamlPath, │
│ getAgentDbPath, getToolsDir, getCommandsDir, getPromptsDir, │
│ getSessionsDir, getDebugLogPath, getCustomThemesDir, │
│ getChangelogPath, getPackageDir │
│ Multi-config: getConfigDirs, getConfigDirPaths, findConfigFile, │
│ readConfigFile, findNearestProjectConfigDir, ... │
│ findConfigFileWithMeta, readConfigFile, readAllConfigFiles, │
│ findNearestProjectConfigDir, findAllNearestProjectConfigDirs │
└─────────────────────────────────────────────────────────────────────────────────┘
```
## Architecture Note
Many modules now use the **capability/discovery system** (`discovery/builtin.ts`) to load configuration files (skills, hooks, tools, MCP servers, etc.) rather than importing config helpers directly. The capability system provides a unified way to load resources from multiple sources (.omp, .pi, .claude, .codex, .gemini) with proper priority ordering.
## Usage by Category
### 1. Display/Branding Only (no file I/O)
| File | Imports | Purpose |
| ----------------------------------------- | ----------------------------- | ------------------------ |
| `cli/args.ts` | `APP_NAME`, `CONFIG_DIR_NAME` | Help text, env var names |
| `cli/plugin-cli.ts` | `APP_NAME` | Command output |
| `cli/update-cli.ts` | `APP_NAME`, `VERSION` | Update messages |
| `core/export-html/index.ts` | `APP_NAME` | HTML export title |
| `modes/interactive/components/welcome.ts` | `APP_NAME` | Welcome banner |
| `utils/tools-manager.ts` | `APP_NAME` | Tool download messages |
| File | Imports | Purpose |
| ---------------------------- | ----------------------------- | ------------------------ |
| `cli/args.ts` | `APP_NAME`, `CONFIG_DIR_NAME` | Help text, env var names |
| `cli/grep-cli.ts` | `APP_NAME` | Grep command output |
| `cli/jupyter-cli.ts` | `APP_NAME` | Jupyter command output |
| `cli/plugin-cli.ts` | `APP_NAME` | Plugin command output |
| `cli/setup-cli.ts` | `APP_NAME` | Setup command output |
| `cli/shell-cli.ts` | `APP_NAME` | Shell command output |
| `cli/stats-cli.ts` | `APP_NAME` | Stats command output |
| `cli/update-cli.ts` | `APP_NAME`, `VERSION` | Update messages |
| `cli.ts` | `APP_NAME` | Process title |
| `export/html/index.ts` | `APP_NAME` | HTML export title |
| `modes/components/welcome.ts`| `APP_NAME` | Welcome banner |
| `debug/system-info.ts` | `VERSION` | System info display |
### 2. Single Fixed Paths (user-level only)
| File | Imports | Path | Purpose |
| --------------------------------------- | ---------------------------------------------------------------- | -------------------------- | ---------------------- |
| `core/logger.ts` | `CONFIG_DIR_NAME` | `~/.omp/logs/` | Log file directory |
| `core/agent-session.ts` | `getAuthPath` | `~/.omp/agent/auth.json` | Error messages |
| `core/session-manager.ts` | `getAgentDir` | `~/.omp/agent/sessions/` | Session storage |
| `modes/interactive/theme/theme.ts` | `getCustomThemesDir` | `~/.omp/agent/themes/` | Custom themes |
| `modes/interactive/interactive-mode.ts` | `getAuthPath`, `getDebugLogPath` | auth.json, debug log | Status messages |
| `utils/changelog.ts` | `getChangelogPath` | Package CHANGELOG.md | Re-exports |
| `core/system-prompt.ts` | `getAgentDir`, `getDocsPath`, `getExamplesPath`, `getReadmePath` | Package assets + AGENTS.md | System prompt building |
| `migrations.ts` | `getAgentDir` | `~/.omp/agent/` | Auth/session migration |
| `core/plugins/installer.ts` | `getAgentDir` | `~/.omp/agent/` | Plugin installation |
| `core/plugins/paths.ts` | `CONFIG_DIR_NAME` | `~/.omp/plugins/` | Plugin directories |
| File | Imports | Path | Purpose |
| ------------------------------------- | -------------------------------- | --------------------------- | ------------------------- |
| `cli/config-cli.ts` | `APP_NAME`, `getAgentDir` | `~/.omp/agent/` | Prints config path |
| `session/agent-session.ts` | `getAgentDbPath` | `~/.omp/agent/agent.db` | Database path |
| `session/session-manager.ts` | `getAgentDir` | `~/.omp/agent/sessions/` | Session storage |
| `session/agent-storage.ts` | `getAgentDbPath` | `~/.omp/agent/agent.db` | Settings/auth storage |
| `session/auth-storage.ts` | `getAgentDbPath`, `getAuthPath` | agent.db, auth.json | Auth credential storage |
| `session/history-storage.ts` | `getAgentDir` | `~/.omp/agent/` | Command history |
| `session/storage-migration.ts` | `getAgentDbPath` | `~/.omp/agent/agent.db` | JSON→SQLite migration |
| `modes/theme/theme.ts` | `getCustomThemesDir` | `~/.omp/agent/themes/` | Custom themes |
| `modes/controllers/selector-controller.ts` | `getAgentDbPath` | `~/.omp/agent/agent.db` | Model selector state |
| `utils/changelog.ts` | `getChangelogPath` | Package CHANGELOG.md | Re-exports path |
| `migrations.ts` | `getAgentDir`, `getAgentDbPath` | `~/.omp/agent/` | Auth/session migration |
| `extensibility/plugins/installer.ts` | `getAgentDir` | `~/.omp/agent/plugins/` | Plugin installation |
| `extensibility/plugins/paths.ts` | `CONFIG_DIR_NAME` | `~/.omp/plugins/` | Plugin directories |
| `config/keybindings.ts` | `getAgentDir` | `~/.omp/agent/keybindings.json` | Keybinding config |
| `config/settings.ts` | `getAgentDir`, `getAgentDbPath` | agent.db, config.yml | Settings management |
| `config/prompt-templates.ts` | `CONFIG_DIR_NAME`, `getPromptsDir` | `~/.omp/agent/prompts/` | Prompt template loading |
| `ipy/executor.ts` | `getAgentDir` | `~/.omp/agent/` | Python executor paths |
| `ipy/gateway-coordinator.ts` | `getAgentDir` | `~/.omp/agent/` | Jupyter gateway socket |
| `export/custom-share.ts` | `getAgentDir` | `~/.omp/agent/share/` | Custom share scripts |
| `debug/index.ts` | `getSessionsDir` | `~/.omp/agent/sessions/` | Debug session browser |
| `ssh/connection-manager.ts` | `CONFIG_DIR_NAME` | `~/.omp/ssh/` | SSH control sockets |
| `ssh/sshfs-mount.ts` | `CONFIG_DIR_NAME` | `~/.omp/remote/` | Remote mount points |
| `tools/read.ts` | `CONFIG_DIR_NAME` | Config dir name reference | Internal URL resolution |
| `utils/tools-manager.ts` | `APP_NAME`, `getToolsDir` | `~/.omp/agent/tools/` | Tool binary management |
### 3. Multi-Config Discovery (with fallbacks)
These use the new helpers to check `.omp`, `.pi`, `.claude` directories:
These use helpers to check `.omp`, `.pi`, `.claude`, `.codex`, `.gemini` directories:
| File | Helper Used | Subpath(s) | Levels |
| ----------------------------------- | ------------------------------------------------------ | ------------------------------------ | ------------ |
| `main.ts` | `findConfigFile` | `SYSTEM.md` | project |
| `core/sdk.ts` | `getConfigDirPaths` | `auth.json`, `models.json` | user |
| `core/settings-manager.ts` | `readConfigFile` | `settings.json` | user+project |
| `core/skills.ts` | `getConfigDirPaths` | `skills/` | user+project |
| `core/slash-commands.ts` | `getConfigDirPaths` | `commands/` | project |
| `core/hooks/loader.ts` | `getConfigDirPaths` | `hooks/` | project |
| `core/custom-tools/loader.ts` | `getConfigDirPaths` | `tools/` | project |
| `core/custom-commands/loader.ts` | `getConfigDirPaths` | `commands/` | project |
| `core/plugins/paths.ts` | `getConfigDirPaths` | `plugin-overrides.json` | project |
| `core/mcp/config.ts` | `getConfigDirPaths` | `mcp.json` | user+project |
| `core/tools/lsp/config.ts` | `getConfigDirPaths` | `lsp.json`, `.lsp.json` | user+project |
| `core/tools/task/commands.ts` | `getConfigDirPaths`, `findAllNearestProjectConfigDirs` | `commands/` | user+project |
| `core/tools/task/discovery.ts` | `getConfigDirs`, `findAllNearestProjectConfigDirs` | `agents/` | user+project |
| `core/tools/task/model-resolver.ts` | `readConfigFile` | `settings.json` | user |
| `core/tools/web-search/auth.ts` | `getConfigDirPaths` | `` (root for models.json, auth.json) | user |
| File | Helper Used | Subpath(s) | Levels |
| ---------------------------------------- | ------------------------------------------------------ | --------------------------- | ------------ |
| `main.ts` | `findConfigFile` | `SYSTEM.md`, `APPEND_SYSTEM.md` | user+project |
| `sdk.ts` | `getConfigDirPaths` | `auth.json`, `models.yml`, `models.json` | user |
| `lsp/config.ts` | `getConfigDirPaths` | `lsp.json`, `.lsp.json` | user+project |
| `task/discovery.ts` | `getConfigDirs`, `findAllNearestProjectConfigDirs` | `agents/` | user+project |
| `extensibility/plugins/paths.ts` | `getConfigDirPaths` | `plugin-overrides.json` | project |
| `extensibility/custom-commands/loader.ts`| `getConfigDirs` | `commands/` | user+project |
| `web/search/auth.ts` | `getConfigDirPaths`, `getAgentDbPath` | auth.json, agent.db | user |
| `web/search/providers/codex.ts` | `getConfigDirPaths`, `getAgentDbPath` | auth config | user |
| `web/search/providers/gemini.ts` | `getConfigDirPaths`, `getAgentDbPath` | auth config | user |
### 4. Via Capability/Discovery System
These modules use `discovery/builtin.ts` which has its own config directory resolution:
| Capability | Config Subpaths | Loaded Via |
| --------------- | ---------------------------------- | --------------------------- |
| skills | `skills/` | `skillCapability` |
| slash-commands | `commands/` | `slashCommandCapability` |
| rules | `rules/` | `ruleCapability` |
| prompts | `prompts/` | `promptCapability` |
| instructions | `instructions/` | `instructionCapability` |
| hooks | `hooks/pre/`, `hooks/post/` | `hookCapability` |
| tools | `tools/` | `toolCapability` |
| extensions | `extensions/` | `extensionCapability` |
| mcp | `mcp.json`, `.mcp.json` | `mcpCapability` |
| settings | `settings.json` | `settingsCapability` |
| system-prompt | `SYSTEM.md` | `systemPromptCapability` |
## Subpath Summary
```
User-level (~/.omp/agent/, ~/.pi/agent/, ~/.claude/):
├── auth.json ← sdk.ts, web-search/auth.ts
├── models.json ← sdk.ts, web-search/auth.ts
├── settings.json ← settings-manager.ts, task/model-resolver.ts
├── commands/ ← slash-commands.ts, custom-commands/loader.ts, task/commands.ts
├── hooks/ ← hooks/loader.ts
├── tools/ ← custom-tools/loader.ts
├── skills/ ← skills.ts
├── themes/ ← theme.ts (user-level only, no fallback)
├── sessions/ ← session-manager.ts (user-level only, no fallback)
├── agents/ ← task/discovery.ts
└── AGENTS.md ← system-prompt.ts
User-level (~/.omp/agent/, ~/.pi/agent/, ~/.claude/, ~/.codex/, ~/.gemini/):
├── agent.db ← SQLite storage (settings, auth)
├── auth.json ← Legacy auth (migrated to agent.db)
├── models.yml ← Model configuration (preferred)
├── models.json ← Model configuration (legacy)
├── config.yml ← Settings (alternative to agent.db)
├── keybindings.json ← Custom keybindings
├── commands/ ← Slash commands (via capability)
├── hooks/ ← Pre/post hooks (via capability)
│ ├── pre/
│ └── post/
├── tools/ ← Custom tools (via capability)
├── skills/ ← Skills (via capability)
├── prompts/ ← Prompt templates
├── themes/ ← Custom themes
├── sessions/ ← Session storage
├── agents/ ← Custom task agents
├── plugins/ ← Installed plugins
├── extensions/ ← Extension modules
├── rules/ ← Rules (via capability)
├── instructions/ ← Instructions (via capability)
├── share/ ← Custom share scripts
└── AGENTS.md ← User-level agent instructions
User-level root (~/.omp/, ~/.pi/, ~/.claude/) - not under agent/:
├── mcp.json ← mcp/config.ts
├── plugins/ ← plugins/paths.ts (primary only)
└── logs/ ← logger.ts (primary only)
├── mcp.json ← MCP server config (via capability)
├── plugins/ ← Plugin storage (primary only)
├── logs/ ← Log files (primary only, via pi-utils)
├── ssh/ ← SSH control sockets
└── remote/ ← SSHFS mount points
Project-level (.omp/, .pi/, .claude/):
├── SYSTEM.md ← main.ts
├── settings.json ← settings-manager.ts
├── commands/ ← slash-commands.ts, custom-commands/loader.ts, task/commands.ts
├── hooks/ ← hooks/loader.ts
├── tools/ ← custom-tools/loader.ts
├── skills/ ← skills.ts
├── agents/ ← task/discovery.ts
├── plugin-overrides.json ← plugins/paths.ts
├── lsp.json ← lsp/config.ts
└── .mcp.json ← mcp/config.ts
Special paths (not under agent/):
├── ~/.omp/plugins/ ← plugins/paths.ts
└── ~/.omp/logs/ ← logger.ts
Project-level (.omp/, .pi/, .claude/, .codex/, .gemini/):
├── SYSTEM.md ← Project system prompt
├── APPEND_SYSTEM.md ← Appended to system prompt
├── settings.json ← Project settings (via capability)
├── commands/ ← Slash commands (via capability)
├── hooks/ ← Pre/post hooks (via capability)
├── tools/ ← Custom tools (via capability)
├── skills/ ← Skills (via capability)
├── agents/ ← Custom task agents
├── extensions/ ← Extension modules (via capability)
├── rules/ ← Rules (via capability)
├── instructions/ ← Instructions (via capability)
├── prompts/ ← Prompt templates (via capability)
├── plugin-overrides.json ← Plugin config overrides
├── lsp.json ← LSP server config
├── .lsp.json ← LSP server config (dotfile)
└── .mcp.json ← MCP server config (via capability)
```
## Files Using Manual Paths (Intentionally)
## Notes
These files construct paths manually because they only use the primary config dir:
### Logger
| File | Current Approach | Reason |
| ----------------------- | --------------------------------- | --------------------------------------------------- |
| `core/logger.ts` | `CONFIG_DIR_NAME` for logs dir | Logs only written to primary (~/.omp/logs/) |
| `core/plugins/paths.ts` | `CONFIG_DIR_NAME` for plugins dir | Plugins only installed in primary (~/.omp/plugins/) |
Logging is handled by `@oh-my-pi/pi-utils`, not by this package. Logs go to `~/.omp/logs/omp.YYYY-MM-DD.log` with automatic rotation.
### Config Priority
When multiple config directories exist, priority order is:
1. `.omp` (highest)
2. `.pi`
3. `.claude`
4. `.codex`
5. `.gemini` (lowest)
For user-level paths, `.omp/agent` and `.pi/agent` have an "agent" subdirectory; others use the root directly (e.g., `~/.claude/` not `~/.claude/agent/`).
+45 -16
View File
@@ -46,7 +46,7 @@ const factory: CustomToolFactory = (pi) => ({
}),
async execute(toolCallId, params, onUpdate, ctx, signal) {
const { name } = params as { name: string };
const { name } = params;
return {
content: [{ type: "text", text: `Hello, ${name}!` }],
details: { greeted: name },
@@ -61,14 +61,25 @@ The tool is automatically discovered and available in your next omp session.
## Tool Locations
Tools must be in a subdirectory with an `index.ts` entry point:
OMP discovers custom tools through the capability system. Native OMP tools live in a subdirectory with an `index.ts`
entry point; `.pi` mirrors the same layout as a compatibility alias.
| Location | Scope | Auto-discovered |
| ----------------------------------- | --------------------- | --------------- |
| `~/.omp/agent/tools/*/index.ts` | Global (all projects) | Yes |
| `.omp/tools/*/index.ts` | Project-local | Yes |
| `settings.json` `customTools` array | Configured paths | Yes |
| `--tool <path>` CLI flag | One-off/debugging | No |
| Location | Scope | Auto-discovered |
| ------------------------------- | -------------- | --------------- |
| `~/.omp/agent/tools/*/index.ts` | User (OMP) | Yes |
| `.omp/tools/*/index.ts` | Project (OMP) | Yes |
| `~/.pi/agent/tools/*/index.ts` | User (alias) | Yes |
| `.pi/tools/*/index.ts` | Project (alias) | Yes |
Compatibility sources load flat modules (no subdirectory):
- `~/.claude/tools/<tool>.ts` (or `.js`, `.sh`, `.bash`, `.py`), `.claude/tools/<tool>.*`
- `~/.codex/tools/<tool>.ts` or `<tool>.js`, `.codex/tools/<tool>.ts` or `<tool>.js`
Tools declared by installed plugins (via `~/.omp/plugins/node_modules` manifests) are also auto-discovered.
Only TypeScript/JavaScript modules are executable. `.md` and `.json` files in tools directories are treated as metadata
and are not loaded as tool modules.
**Example structure:**
@@ -82,9 +93,10 @@ Tools must be in a subdirectory with an `index.ts` entry point:
└── types.ts # Type definitions (not loaded directly)
```
**Priority:** Later sources win on name conflicts. CLI `--tool` takes highest priority.
**Name conflicts:** Duplicate tool names are rejected; the first loaded tool keeps its name and later conflicts are
reported as load errors.
**Reserved names:** Custom tools cannot use built-in tool names (`read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`).
**Reserved names:** Custom tools cannot use built-in tool names (`read`, `write`, `edit`, `bash`, `grep`, `find`, `python`, `fetch`, `task`, `browser`, `web_search`, etc.).
## Available Imports
@@ -96,6 +108,7 @@ Custom tools can import from these packages:
| `@oh-my-pi/pi-coding-agent` | Types and utilities | Via `pi.pi.*` (injected) or direct import for types |
| `@oh-my-pi/pi-ai` | AI utilities (`StringEnum` for Google-compatible enums) | Via `pi.pi.*` (re-exported through coding-agent) |
| `@oh-my-pi/pi-tui` | TUI components (`Text`, `Box`, etc. for custom rendering) | Via `pi.pi.*` (re-exported through coding-agent) |
| `@oh-my-pi/pi-utils` | Logging (`logger`) | Via `pi.logger` (injected) |
Node.js built-in modules (`node:fs`, `node:path`, etc.) are also available.
@@ -152,7 +165,7 @@ const factory: CustomToolFactory = (pi) => {
renderCall(args, theme) {
/* return Component */
},
renderResult(result, options, theme) {
renderResult(result, options, theme, args) {
/* return Component */
},
};
@@ -161,6 +174,8 @@ const factory: CustomToolFactory = (pi) => {
export default factory;
```
Set `hidden: true` to exclude a tool from the default tool list; hidden tools must be explicitly enabled by the session.
**Important:** Use `StringEnum` from `pi.pi` instead of `Type.Union`/`Type.Literal` for string enums. The latter doesn't work with Google's API.
## CustomToolAPI Object
@@ -173,6 +188,7 @@ interface CustomToolAPI {
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
ui: ToolUIContext;
hasUI: boolean; // false in --print or --mode rpc
logger: typeof import("@oh-my-pi/pi-utils").logger; // File logger
typebox: typeof import("@sinclair/typebox"); // Injected @sinclair/typebox
pi: typeof import("@oh-my-pi/pi-coding-agent"); // Injected pi-coding-agent exports
}
@@ -182,22 +198,34 @@ interface ToolUIContext {
confirm(title: string, message: string): Promise<boolean>;
input(title: string, placeholder?: string): Promise<string | undefined>;
notify(message: string, type?: "info" | "warning" | "error"): void;
custom(component: Component & { dispose?(): void }): { close: () => void; requestRender: () => void };
setStatus(key: string, text: string | undefined): void;
custom<T>(
factory: (tui: TUI, theme: Theme, done: (result: T) => void) =>
| (Component & { dispose?(): void })
| Promise<Component & { dispose?(): void }>,
): Promise<T>;
setEditorText(text: string): void;
getEditorText(): string;
editor(title: string, prefill?: string): Promise<string | undefined>;
readonly theme: Theme;
}
interface ExecOptions {
signal?: AbortSignal; // Cancel the process
timeout?: number; // Timeout in milliseconds
cwd?: string; // Working directory
}
interface ExecResult {
stdout: string;
stderr: string;
code: number;
killed?: boolean; // True if process was killed by signal/timeout
killed: boolean; // True if process was killed by signal/timeout
}
```
`TUI` and `Theme` are from `@oh-my-pi/pi-tui` (available via `pi.pi`).
Always check `pi.hasUI` before using UI methods.
### Cancellation Example
@@ -247,7 +275,7 @@ interface CustomToolContext {
sessionManager: ReadonlySessionManager; // Read-only access to session
modelRegistry: ModelRegistry; // For API key resolution
model: Model | undefined; // Current model (may be undefined)
isIdle(): boolean; // Whether agent is streaming
isIdle(): boolean; // Whether agent is idle (not streaming)
hasQueuedMessages(): boolean; // Whether user has queued messages
abort(): void; // Abort current operation (fire-and-forget)
}
@@ -442,6 +470,7 @@ renderResult(result, { expanded, isPartial }, theme) {
- `expanded`: User pressed Ctrl+O to expand
- `isPartial`: Result is from `onUpdate` (streaming), not final
- `spinnerFrame`: Spinner frame index (0-9) during partial updates
### Best Practices
@@ -534,8 +563,8 @@ See [`examples/custom-tools/todo/index.ts`](../examples/custom-tools/todo/index.
- Custom `renderCall` and `renderResult`
- Proper branching support via details storage
Test with:
Test by copying the example into your tools directory and restarting omp:
```bash
omp --tool packages/coding-agent/examples/custom-tools/todo/index.ts
cp -r packages/coding-agent/examples/custom-tools/todo ~/.omp/agent/tools/
```
File diff suppressed because it is too large Load Diff
+192 -51
View File
@@ -1,8 +1,8 @@
> pi can create extensions. Ask it to build one for your use case.
> omp can create extensions. Ask it to build one for your use case.
# Extensions
Extensions are TypeScript modules that extend pi's behavior. They can subscribe to lifecycle events, register custom tools callable by the LLM, add commands, and more.
Extensions are TypeScript modules that extend omp's behavior. They can subscribe to lifecycle events, register custom tools callable by the LLM, add commands, and more.
**Key capabilities:**
@@ -23,7 +23,6 @@ Extensions are TypeScript modules that extend pi's behavior. They can subscribe
- Interactive tools (questions, wizards, custom dialogs)
- Stateful tools (todo lists, connection pools)
- External integrations (file watchers, webhooks, CI triggers)
- Games while you wait (see `snake.ts` example)
See [examples/extensions/](../examples/extensions/) for working implementations.
@@ -38,6 +37,8 @@ See [examples/extensions/](../examples/extensions/) for working implementations.
- [Lifecycle Overview](#lifecycle-overview)
- [Session Events](#session-events)
- [Agent Events](#agent-events)
- [Input Events](#input-events)
- [User Bash/Python Events](#user-bashpython-events)
- [Tool Events](#tool-events)
- [ExtensionContext](#extensioncontext)
- [ExtensionCommandContext](#extensioncommandcontext)
@@ -98,22 +99,24 @@ export default function (pi: ExtensionAPI) {
Test with `--extension` (or `-e`) flag:
```bash
pi -e ./my-extension.ts
omp -e ./my-extension.ts
```
## Extension Locations
Extensions are auto-discovered from:
| Location | Scope |
| ------------------------------------ | ---------------------------- |
| `~/.omp/agent/extensions/*.ts` | Global (all projects) |
| `~/.omp/agent/extensions/*/index.ts` | Global (subdirectory) |
| `.omp/extensions/*.ts` | Project-local |
| `.omp/extensions/*/index.ts` | Project-local (subdirectory) |
| Location | Scope |
| ---------------------------------------- | ---------------------------- |
| `~/.omp/agent/extensions/*.{ts,js}` | Global (all projects) |
| `~/.omp/agent/extensions/*/index.{ts,js}` | Global (subdirectory) |
| `.omp/extensions/*.{ts,js}` | Project-local |
| `.omp/extensions/*/index.{ts,js}` | Project-local (subdirectory) |
Legacy `.pi` directories are supported as aliases for the `.omp` paths above.
`settings.json` lives in `~/.omp/agent/settings.json` (user) or `.omp/settings.json` (project).
Additional paths via `settings.json`:
```json
@@ -125,9 +128,11 @@ Additional paths via `settings.json`:
**Discovery rules:**
1. **Direct files:** `extensions/*.ts` or `*.js` → loaded directly
2. **Subdirectory with index:** `extensions/myext/index.ts` → loaded as single extension
2. **Subdirectory with index:** `extensions/myext/index.ts` or `index.js` → loaded as single extension
3. **Subdirectory with package.json:** `extensions/myext/package.json` with `"omp"` field (legacy `"pi"` supported) → loads declared paths
Discovery only recurses one level under `extensions/`. Deeper entry points must be listed in the manifest.
```
~/.omp/agent/extensions/
├── simple.ts # Direct file (auto-discovered)
@@ -157,7 +162,7 @@ Additional paths via `settings.json`:
The `package.json` approach enables:
- Multiple extensions from one package
- Third-party npm dependencies (resolved via jiti)
- Third-party dependencies resolved via Bun's module loader
- Nested source structure (no depth limit within the package)
- Deployment to and installation from npm
@@ -170,7 +175,13 @@ The `package.json` approach enables:
| `@oh-my-pi/pi-ai` | AI utilities (`StringEnum` for Google-compatible enums) |
| `@oh-my-pi/pi-tui` | TUI components for custom rendering |
npm dependencies work too. Add a `package.json` next to your extension (or in a parent directory), run `npm install`, and imports from `node_modules/` are resolved automatically.
`ExtensionAPI` also exposes:
- `pi.logger` - file logger (preferred over `console.*`)
- `pi.typebox` - injected TypeBox module
- `pi.pi` - access to `@oh-my-pi/pi-coding-agent` exports
Dependencies work like any Bun project. Add a `package.json` next to your extension (or in a parent directory), run `bun install`, and imports from `node_modules/` resolve automatically.
Node.js built-ins (`node:fs`, `node:path`, etc.) are also available.
@@ -199,18 +210,18 @@ export default function (pi: ExtensionAPI) {
}
```
Extensions are loaded via [jiti](https://github.com/unjs/jiti), so TypeScript works without compilation.
Extensions are loaded via Bun's native module loader, so TypeScript works without a build step. Both `.ts` and `.js` entry points are supported.
### Extension Styles
**Single file** - simplest, for small extensions:
**Single file** - simplest, for small extensions (also supports `.js`):
```
~/.omp/agent/extensions/
└── my-extension.ts
```
**Directory with index.ts** - for multi-file extensions:
**Directory with index.ts** - for multi-file extensions (also supports `index.js`):
```
~/.omp/agent/extensions/
@@ -226,8 +237,8 @@ Extensions are loaded via [jiti](https://github.com/unjs/jiti), so TypeScript wo
~/.omp/agent/extensions/
└── my-extension/
├── package.json # Declares dependencies and entry points
├── package-lock.json
├── node_modules/ # After npm install
├── bun.lockb
├── node_modules/ # After bun install
└── src/
└── index.ts
```
@@ -240,26 +251,29 @@ Extensions are loaded via [jiti](https://github.com/unjs/jiti), so TypeScript wo
"zod": "^3.0.0",
"chalk": "^5.0.0"
},
"pi": {
"omp": {
"extensions": ["./src/index.ts"]
}
}
```
Run `npm install` in the extension directory, then imports from `node_modules/` work automatically.
The manifest key can be `omp` (preferred) or `pi` (legacy).
Run `bun install` in the extension directory, then imports from `node_modules/` work automatically.
## Events
### Lifecycle Overview
```
pi starts
omp starts
│
└─► session_start
│
▼
user sends prompt ─────────────────────────────────────────┐
user submits input ────────────────────────────────────────┐
│ │
├─► input (can modify or handle) │
├─► before_agent_start (can inject message, modify system prompt)
├─► agent_start │
│ │
@@ -289,6 +303,7 @@ user sends another prompt ◄─────────────────
/compact or auto-compaction
├─► session_before_compact (can cancel or customize)
├─► session.compacting (add context or override prompt)
└─► session_compact
/tree navigation
@@ -311,16 +326,16 @@ pi.on("session_start", async (_event, ctx) => {
});
```
**Examples:** [claude-rules.ts](../examples/extensions/claude-rules.ts), [custom-header.ts](../examples/extensions/custom-header.ts), [file-trigger.ts](../examples/extensions/file-trigger.ts), [status-line.ts](../examples/extensions/status-line.ts), [todo.ts](../examples/extensions/todo.ts), [tools.ts](../examples/extensions/tools.ts)
**Examples:** [todo.ts](../examples/extensions/todo.ts), [tools.ts](../examples/extensions/tools.ts)
#### session_before_switch / session_switch
Fired when starting a new session (`/new`) or switching sessions (`/resume`).
Fired when starting a new session (`/new`), resuming (`/resume`), or forking a session.
```typescript
pi.on("session_before_switch", async (event, ctx) => {
// event.reason - "new" or "resume"
// event.targetSessionFile - session we're switching to (only for "resume")
// event.reason - "new", "resume", or "fork"
// event.targetSessionFile - session we're switching to ("resume" only)
if (event.reason === "new") {
const ok = await ctx.ui.confirm("Clear?", "Delete all messages?");
@@ -329,12 +344,12 @@ pi.on("session_before_switch", async (event, ctx) => {
});
pi.on("session_switch", async (event, ctx) => {
// event.reason - "new" or "resume"
// event.reason - "new", "resume", or "fork"
// event.previousSessionFile - session we came from
});
```
**Examples:** [confirm-destructive.ts](../examples/extensions/confirm-destructive.ts), [dirty-repo-guard.ts](../examples/extensions/dirty-repo-guard.ts), [status-line.ts](../examples/extensions/status-line.ts), [todo.ts](../examples/extensions/todo.ts)
**Examples:** [todo.ts](../examples/extensions/todo.ts)
#### session_before_branch / session_branch
@@ -353,7 +368,7 @@ pi.on("session_branch", async (event, ctx) => {
});
```
**Examples:** [confirm-destructive.ts](../examples/extensions/confirm-destructive.ts), [dirty-repo-guard.ts](../examples/extensions/dirty-repo-guard.ts), [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [todo.ts](../examples/extensions/todo.ts), [tools.ts](../examples/extensions/tools.ts)
**Examples:** [todo.ts](../examples/extensions/todo.ts), [tools.ts](../examples/extensions/tools.ts)
#### session_before_compact / session_compact
@@ -382,7 +397,21 @@ pi.on("session_compact", async (event, ctx) => {
});
```
**Examples:** [custom-compaction.ts](../examples/extensions/custom-compaction.ts)
#### session.compacting
Fired before compaction summarization to adjust the prompt or inject extra context.
```typescript
pi.on("session.compacting", async (event, ctx) => {
// event.messages - messages being summarized
return {
context: ["Important context line"],
prompt: "Summarize with an emphasis on decisions and follow-ups",
preserveData: { ticketId: "ABC-123" },
};
});
```
#### session_before_tree / session_tree
@@ -401,7 +430,7 @@ pi.on("session_tree", async (event, ctx) => {
});
```
**Examples:** [todo.ts](../examples/extensions/todo.ts), [tools.ts](../examples/extensions/tools.ts)
**Examples:** [tools.ts](../examples/extensions/tools.ts)
#### session_shutdown
@@ -413,8 +442,6 @@ pi.on("session_shutdown", async (_event, ctx) => {
});
```
**Examples:** [auto-commit-on-exit.ts](../examples/extensions/auto-commit-on-exit.ts)
### Agent Events
#### before_agent_start
@@ -440,7 +467,7 @@ pi.on("before_agent_start", async (event, ctx) => {
});
```
**Examples:** [claude-rules.ts](../examples/extensions/claude-rules.ts), [pirate.ts](../examples/extensions/pirate.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts), [preset.ts](../examples/extensions/preset.ts), [ssh.ts](../examples/extensions/ssh.ts)
**Examples:** [pirate.ts](../examples/extensions/pirate.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts)
#### agent_start / agent_end
@@ -454,7 +481,7 @@ pi.on("agent_end", async (event, ctx) => {
});
```
**Examples:** [chalk-logger.ts](../examples/extensions/chalk-logger.ts), [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts)
**Examples:** [chalk-logger.ts](../examples/extensions/chalk-logger.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts)
#### turn_start / turn_end
@@ -470,7 +497,7 @@ pi.on("turn_end", async (event, ctx) => {
});
```
**Examples:** [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts), [status-line.ts](../examples/extensions/status-line.ts)
**Examples:** [plan-mode.ts](../examples/extensions/plan-mode.ts)
#### context
@@ -484,7 +511,53 @@ pi.on("context", async (event, ctx) => {
});
```
**Examples:** [plan-mode.ts](../examples/extensions/plan-mode.ts)
### Input Events
#### input
Fired when the user submits input (interactive, RPC, or extension-triggered). Can rewrite or handle input.
```typescript
pi.on("input", async (event, ctx) => {
// event.text, event.images, event.source
if (event.text.startsWith("/noop")) {
return { handled: true };
}
return { text: event.text.trim() };
});
```
### User Bash/Python Events
#### user_bash
Fired when the user runs a `!`/`!!` command. Return a `result` to override execution.
```typescript
pi.on("user_bash", async (event, ctx) => {
// event.command, event.excludeFromContext, event.cwd
if (event.command === "pwd") {
return {
result: {
stdout: event.cwd,
stderr: "",
code: 0,
killed: false,
},
};
}
});
```
#### user_python
Fired when the user runs a `$`/`$$` block. Return a `result` to override execution.
```typescript
pi.on("user_python", async (event, ctx) => {
// event.code, event.excludeFromContext, event.cwd
});
```
### Tool Events
@@ -504,20 +577,18 @@ pi.on("tool_call", async (event, ctx) => {
});
```
**Examples:** [chalk-logger.ts](../examples/extensions/chalk-logger.ts), [permission-gate.ts](../examples/extensions/permission-gate.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts), [protected-paths.ts](../examples/extensions/protected-paths.ts)
**Examples:** [chalk-logger.ts](../examples/extensions/chalk-logger.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts)
#### tool_result
Fired after tool executes. **Can modify result.**
```typescript
import { isBashToolResult } from "@oh-my-pi/pi-coding-agent";
pi.on("tool_result", async (event, ctx) => {
// event.toolName, event.toolCallId, event.input
// event.content, event.details, event.isError
if (isBashToolResult(event)) {
if (event.toolName === "bash") {
// event.details is typed as BashToolDetails
}
@@ -526,7 +597,7 @@ pi.on("tool_result", async (event, ctx) => {
});
```
**Examples:** [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts)
**Examples:** [plan-mode.ts](../examples/extensions/plan-mode.ts)
## ExtensionContext
@@ -538,7 +609,7 @@ UI methods for user interaction. See [Custom UI](#custom-ui) for full details.
### ctx.hasUI
`false` in print mode (`-p`), JSON mode, and RPC mode. Always check before using `ctx.ui`.
`false` in print mode (`-p`), JSON mode, and RPC mode. UI methods become no-ops, so check before prompting.
### ctx.cwd
@@ -558,6 +629,18 @@ ctx.sessionManager.getLeafId(); // Current leaf entry ID
Access to models and API keys.
### ctx.getContextUsage()
Returns current context usage for the active model, if available.
### ctx.compact(instructionsOrOptions?)
Trigger compaction programmatically (interactive mode shows UI).
### ctx.shutdown()
Gracefully shut down and exit.
### ctx.isIdle() / ctx.abort() / ctx.hasPendingMessages()
Control flow helpers.
@@ -656,7 +739,7 @@ pi.registerTool({
// Optional: Custom rendering
renderCall(args, theme) { ... },
renderResult(result, options, theme) { ... },
renderResult(result, options, theme, args) { ... },
});
```
@@ -684,6 +767,14 @@ pi.sendMessage({
- `"nextTurn"` - Queued for next user prompt. Does not interrupt or trigger anything.
- `triggerTurn: true` - If agent is idle, trigger an LLM response immediately. Only applies to `"steer"` and `"followUp"` modes (ignored for `"nextTurn"`).
### pi.sendUserMessage(content, options?)
Send a user message into the session and trigger a turn immediately:
```typescript
pi.sendUserMessage("Follow up with the latest status", { deliverAs: "followUp" });
```
### pi.appendEntry(customType, data?)
Persist extension state (does NOT participate in LLM context):
@@ -749,6 +840,14 @@ if (pi.getFlag("--plan")) {
}
```
### pi.setLabel(label)
Set a display label for the extension:
```typescript
pi.setLabel("My Extension");
```
### pi.exec(command, args, options?)
Execute a shell command:
@@ -767,6 +866,19 @@ const active = pi.getActiveTools(); // ["read", "bash", "edit", "write"]
pi.setActiveTools(["read", "bash"]); // Switch to read-only
```
### pi.setModel(model) / pi.getThinkingLevel() / pi.setThinkingLevel(level)
Control the active model and thinking level:
```typescript
const model = ctx.modelRegistry.find("anthropic", "claude-sonnet-4-5");
if (model) {
const ok = await pi.setModel(model);
}
const level = pi.getThinkingLevel();
pi.setThinkingLevel(level);
```
### pi.events
Shared event bus for communication between extensions:
@@ -778,7 +890,7 @@ pi.events.emit("my:event", { ... });
## State Management
Extensions with state should store it in tool result `details` for proper branching support:
Extensions with state should store it in tool result `details` for proper branching support. Tools can also implement `onSession` to rebuild or clean up state on start/switch/branch/tree/shutdown:
```typescript
export default function (pi: ExtensionAPI) {
@@ -829,6 +941,10 @@ pi.registerTool({
action: StringEnum(["list", "add"] as const), // Use StringEnum for Google compatibility
text: Type.Optional(Type.String()),
}),
hidden: false, // Optional: set true to hide unless explicitly enabled
onSession(event, ctx) {
// event.reason: "start" | "switch" | "branch" | "tree" | "shutdown"
},
async execute(toolCallId, params, onUpdate, ctx, signal) {
// Check for cancellation
@@ -854,7 +970,7 @@ pi.registerTool({
// Optional: Custom rendering
renderCall(args, theme) { ... },
renderResult(result, options, theme) { ... },
renderResult(result, options, theme, args) { ... },
});
```
@@ -973,17 +1089,31 @@ ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error"
ctx.ui.setStatus("my-ext", "Processing...");
ctx.ui.setStatus("my-ext", undefined); // Clear
// Working message shown during streaming
ctx.ui.setWorkingMessage("Connecting...");
ctx.ui.setWorkingMessage(); // Restore default
// Widget above editor (string array or factory function)
ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]);
ctx.ui.setWidget("my-widget", (tui, theme) => new Text(theme.fg("accent", "Custom"), 0, 0));
ctx.ui.setWidget("my-widget", undefined); // Clear
// Custom header/footer
ctx.ui.setHeader((tui, theme) => new Text(theme.fg("accent", "Header"), 0, 0));
ctx.ui.setFooter((tui, theme) => new Text(theme.fg("accent", "Footer"), 0, 0));
ctx.ui.setHeader(undefined); // Restore default
ctx.ui.setFooter(undefined); // Restore default
// Terminal title
ctx.ui.setTitle("pi - my-project");
ctx.ui.setTitle("omp - my-project");
// Editor text
ctx.ui.setEditorText("Prefill text");
const current = ctx.ui.getEditorText();
// Custom editor component
ctx.ui.setEditorComponent((tui, theme, keybindings) => new MyEditor(tui, theme, keybindings)); // EditorComponent
ctx.ui.setEditorComponent(undefined); // Restore default
```
### Custom Components
@@ -993,7 +1123,7 @@ For complex UI, use `ctx.ui.custom()`. This temporarily replaces the editor with
```typescript
import { Text, Component } from "@oh-my-pi/pi-tui";
const result = await ctx.ui.custom<boolean>((tui, theme, done) => {
const result = await ctx.ui.custom<boolean>((tui, theme, keybindings, done) => {
const text = new Text("Press Enter to confirm, Escape to cancel", 1, 1);
text.onKey = (key) => {
@@ -1003,7 +1133,7 @@ const result = await ctx.ui.custom<boolean>((tui, theme, done) => {
};
return text;
});
}, { overlay: true });
if (result) {
// User pressed Enter
@@ -1014,9 +1144,10 @@ The callback receives:
- `tui` - TUI instance (for screen dimensions, focus management)
- `theme` - Current theme for styling
- `keybindings` - Keybindings manager for resolving bindings
- `done(value)` - Call to close component and return value
See [tui.md](tui.md) for the full component API and [examples/extensions/](../examples/extensions/) for working examples (snake.ts, todo.ts, qna.ts).
See [tui.md](tui.md) for the full component API and [examples/extensions/](../examples/extensions/) for working examples (todo.ts, tools.ts).
### Message Rendering
@@ -1049,6 +1180,15 @@ pi.sendMessage({
});
```
### Themes
```typescript
const themes = await ctx.ui.getAllThemes();
const current = ctx.ui.theme;
const loaded = await ctx.ui.getTheme("celestial");
const result = await ctx.ui.setTheme("celestial");
```
### Theme Colors
All render functions receive a `theme` object:
@@ -1080,7 +1220,8 @@ theme.strikethrough(text);
| Mode | UI Methods | Notes |
| ------------ | ------------- | ------------------------------- |
| Interactive | Full TUI | Normal operation |
| JSON | No-op | `--mode json` output |
| RPC | JSON protocol | Host handles UI |
| Print (`-p`) | No-op | Extensions run but can't prompt |
In print mode, check `ctx.hasUI` before using UI methods.
In print/JSON/RPC modes, check `ctx.hasUI` before using UI methods.
+109 -70
View File
@@ -24,10 +24,10 @@ See [examples/hooks/](../examples/hooks/) for working implementations, including
## Quick Start
Create `~/.omp/agent/hooks/my-hook.ts`:
Create `~/.omp/agent/hooks/pre/my-hook.ts` (or project-local `.omp/hooks/pre/`):
```typescript
import type { HookAPI } from "@oh-my-pi/pi-coding-agent";
import type { HookAPI } from "@oh-my-pi/pi-coding-agent/hooks";
export default function (pi: HookAPI) {
pi.on("session_start", async (_event, ctx) => {
@@ -51,29 +51,30 @@ omp --hook ./my-hook.ts
## Hook Locations
Hooks are auto-discovered from:
Hooks are auto-discovered from config directories under `hooks/`:
| Location | Scope |
| ------------------------- | --------------------- |
| `~/.omp/agent/hooks/*.ts` | Global (all projects) |
| `.omp/hooks/*.ts` | Project-local |
Native (`.omp`, `.pi`) and Claude (`.claude`) use subdirectory structure:
Additional paths via `settings.json`:
- User-level:
- Native: `~/.omp/agent/hooks/{pre,post}/*.ts` (or `~/.pi/agent`)
- Claude: `~/.claude/hooks/{pre,post}/*.ts`
- Project-level: `.omp/hooks/{pre,post}/*.ts` (or `.pi`, `.claude`)
```json
{
"hooks": ["/path/to/hook.ts"]
}
```
Codex (`.codex`) uses flat structure with filename prefixes (`pre-*.ts`, `post-*.ts`):
- User-level: `~/.codex/hooks/*.ts`
- Project-level: `.codex/hooks/*.ts`
Hooks can also be loaded from plugin manifests or explicitly via `--hook`.
## Available Imports
| Package | Purpose |
| --------------------------------- | --------------------------------------------- |
| `@oh-my-pi/pi-coding-agent/hooks` | Hook types (`HookAPI`, `HookContext`, events) |
| `@oh-my-pi/pi-coding-agent` | Additional types if needed |
| `@oh-my-pi/pi-ai` | AI utilities |
| `@oh-my-pi/pi-tui` | TUI components |
| Package | Purpose |
| --------------------------------- | ---------------------------------------------------- |
| `@oh-my-pi/pi-coding-agent/hooks` | Hook types (`HookAPI`, `HookContext`, events) |
| `@oh-my-pi/pi-coding-agent` | Components (`BorderedLoader`), utilities, type re-exports |
| `@oh-my-pi/pi-ai` | AI utilities (`complete`, message types) |
| `@oh-my-pi/pi-tui` | TUI components (`CancellableLoader`, etc.) |
Node.js built-ins (`node:fs`, `node:path`, etc.) are also available.
@@ -82,7 +83,7 @@ Node.js built-ins (`node:fs`, `node:path`, etc.) are also available.
A hook exports a default function that receives `HookAPI`:
```typescript
import type { HookAPI } from "@oh-my-pi/pi-coding-agent";
import type { HookAPI } from "@oh-my-pi/pi-coding-agent/hooks";
export default function (pi: HookAPI) {
// Subscribe to events
@@ -92,7 +93,7 @@ export default function (pi: HookAPI) {
}
```
Hooks are loaded via [jiti](https://github.com/unjs/jiti), so TypeScript works without compilation.
Hooks are loaded via native Bun import, so TypeScript works without compilation.
## Events
@@ -125,9 +126,9 @@ user sends prompt ────────────────────
│
user sends another prompt ◄────────────────────────────────┘
/new (new session) or /resume (switch session)
├─► session_before_switch (can cancel, has reason: "new" | "resume")
└─► session_switch (has reason: "new" | "resume")
/new, /resume, or /fork
├─► session_before_switch (can cancel, has reason: "new" | "resume" | "fork")
└─► session_switch (has reason: "new" | "resume" | "fork")
/branch
├─► session_before_branch (can cancel)
@@ -135,6 +136,7 @@ user sends another prompt ◄─────────────────
/compact or auto-compaction
├─► session_before_compact (can cancel or customize)
├─► session.compacting (customize prompt/context)
└─► session_compact
/tree navigation
@@ -159,11 +161,11 @@ pi.on("session_start", async (_event, ctx) => {
#### session_before_switch / session_switch
Fired when starting a new session (`/new`) or switching sessions (`/resume`).
Fired when starting a new session (`/new`), resuming (`/resume`), or forking (`/fork`).
```typescript
pi.on("session_before_switch", async (event, ctx) => {
// event.reason - "new" (starting fresh) or "resume" (switching to existing)
// event.reason - "new" (starting fresh), "resume" (switching to existing), or "fork" (branch switch)
// event.targetSessionFile - session we're switching to (only for "resume")
if (event.reason === "new") {
@@ -175,7 +177,7 @@ pi.on("session_before_switch", async (event, ctx) => {
});
pi.on("session_switch", async (event, ctx) => {
// event.reason - "new" or "resume"
// event.reason - "new", "resume", or "fork"
// event.previousSessionFile - session we came from
});
```
@@ -200,7 +202,7 @@ pi.on("session_branch", async (event, ctx) => {
The `skipConversationRestore` option is useful for checkpoint hooks that restore code state separately.
#### session_before_compact / session_compact
#### session_before_compact / session.compacting / session_compact
Fired on compaction. See [compaction.md](compaction.md) for details.
@@ -221,9 +223,30 @@ pi.on("session_before_compact", async (event, ctx) => {
};
});
```
#### session.compacting
Fired after preparation but before the default summarizer runs. Use it to customize the prompt or add context
when you are not returning a full compaction result from `session_before_compact`.
```typescript
pi.on("session.compacting", async (event, ctx) => {
// event.sessionId
// event.messages - messages about to be summarized
return {
context: ["Additional context line"],
prompt: "Custom compaction prompt...",
preserveData: { source: "my-hook" },
};
});
```
```typescript
pi.on("session_compact", async (event, ctx) => {
// event.compactionEntry - the saved compaction
// event.fromHook - whether hook provided it
// event.fromExtension - whether hook provided it
});
```
@@ -243,7 +266,7 @@ pi.on("session_before_tree", async (event, ctx) => {
});
pi.on("session_tree", async (event, ctx) => {
// event.newLeafId, oldLeafId, summaryEntry, fromHook
// event.newLeafId, oldLeafId, summaryEntry, fromExtension
});
```
@@ -340,15 +363,21 @@ pi.on("tool_call", async (event, ctx) => {
});
```
Tool inputs:
Tool inputs (common built-ins):
- `bash`: `{ command, timeout? }`
- `read`: `{ path, offset?, limit? }`
- `bash`: `{ command, timeout?, cwd?, head?, tail? }`
- `read`: `{ path, offset?, limit?, lines? }`
- `write`: `{ path, content }`
- `edit`: `{ path, old_text, new_text }`
- `ls`: `{ path?, limit? }`
- `find`: `{ pattern, path?, limit? }`
- `grep`: `{ pattern, path?, glob?, ignore_case?, literal?, context?, limit? }`
- `edit` (replace mode): `{ path, old_text, new_text, all? }`
- `edit` (patch mode): `{ path, op?, rename?, diff? }`
- `find`: `{ pattern, hidden?, limit? }`
- `grep`: `{ pattern, path?, glob?, type?, i?, pre?, post?, multiline?, limit?, offset? }`
The edit input shape depends on the current edit variant (replace vs patch). Inspect `event.input` to
see which schema is active.
Other tools (ask, browser, task, todo_write, fetch, web_search, python, notebook, lsp, ssh, calc) use
their own schemas; inspect the tool prompt or `src/tools/*.ts` for details.
#### tool_result
@@ -372,23 +401,20 @@ pi.on("tool_result", async (event, ctx) => {
});
```
Use type guards for typed details:
Use `event.toolName` to narrow tool-specific details:
```typescript
import { isBashToolResult } from "@oh-my-pi/pi-coding-agent";
pi.on("tool_result", async (event, ctx) => {
if (isBashToolResult(event)) {
if (event.toolName === "bash") {
// event.details is BashToolDetails | undefined
if (event.details?.truncation?.truncated) {
// Full output at event.details.fullOutputPath
const artifactId = event.details?.meta?.truncation?.artifactId;
if (artifactId) {
// Full output is stored under the artifact ID
}
}
});
```
Available guards: `isBashToolResult`, `isReadToolResult`, `isEditToolResult`, `isWriteToolResult`, `isGrepToolResult`, `isFindToolResult`, `isLsToolResult`.
## HookContext
Every handler receives `ctx: HookContext`:
@@ -488,7 +514,8 @@ See [examples/hooks/qna.ts](../examples/hooks/qna.ts) for a loader pattern and [
### ctx.hasUI
`false` in print mode (`-p`), JSON print mode, and RPC mode. Always check before using `ctx.ui`:
`false` in print mode (`-p`) and JSON print mode. RPC mode provides UI via the host, so `ctx.hasUI` is true.
Always check before using `ctx.ui`:
```typescript
if (ctx.hasUI) {
@@ -504,7 +531,7 @@ Current working directory.
### ctx.sessionManager
Read-only access to session state. See `ReadonlySessionManager` in [`src/core/session-manager.ts`](../src/core/session-manager.ts).
Read-only access to session state. See `ReadonlySessionManager` in [`src/session/session-manager.ts`](../src/session/session-manager.ts).
```typescript
// Session info
@@ -538,7 +565,7 @@ Access to models and API keys:
const apiKey = await ctx.modelRegistry.getApiKey(model);
// Get available models
const models = ctx.modelRegistry.getAvailableModels();
const models = ctx.modelRegistry.getAvailable();
```
### ctx.model
@@ -567,7 +594,7 @@ if (ctx.isIdle()) {
Abort the current agent operation (fire-and-forget, does not wait):
```typescript
await ctx.abort();
ctx.abort();
```
### ctx.hasQueuedMessages()
@@ -643,24 +670,28 @@ const result = await ctx.navigateTree("entry-id-456", {
Subscribe to events. See [Events](#events) for all event types.
### pi.sendMessage(message, triggerTurn?)
### pi.sendMessage(message, options?)
Inject a message into the session. Creates a `CustomMessageEntry` that participates in the LLM context.
```typescript
pi.sendMessage({
customType: "my-hook", // Your hook's identifier
content: "Message text", // string or (TextContent | ImageContent)[]
display: true, // Show in TUI
details: { ... }, // Optional metadata (not sent to LLM)
}, triggerTurn); // If true, triggers LLM response
pi.sendMessage(
{
customType: "my-hook", // Your hook's identifier
content: "Message text", // string or (TextContent | ImageContent)[]
display: true, // Show in TUI
details: { ... }, // Optional metadata (not sent to LLM)
},
{ triggerTurn: true }, // Trigger a new LLM response if idle
);
```
**Storage and timing:**
- The message is appended to the session file immediately as a `CustomMessageEntry`
- If the agent is currently streaming, the message is queued and appended after the current turn
- If `triggerTurn` is true and the agent is idle, a new agent loop starts
- If `options.triggerTurn` is true and the agent is idle, a new agent loop starts
- `options.deliverAs` chooses how to enqueue the message (`"steer"` or `"followUp"`)
**LLM context:**
@@ -708,7 +739,7 @@ pi.registerCommand("stats", {
For long-running commands (e.g., LLM calls), use `ctx.ui.custom()` with a loader. See [examples/hooks/qna.ts](../examples/hooks/qna.ts).
To trigger LLM after command, call `pi.sendMessage(..., true)`.
To trigger the LLM after a command, call `pi.sendMessage(..., { triggerTurn: true })`.
### pi.registerMessageRenderer(customType, renderer)
@@ -736,13 +767,13 @@ pi.registerMessageRenderer("my-hook", (message, options, theme) => {
```typescript
type HookMessageRenderer = (
message: CustomMessageEntry,
message: HookMessage,
options: { expanded: boolean },
theme: Theme
) => Component | null;
) => Component | undefined;
```
Return `null` to use default rendering. The returned component is wrapped in a styled Box by the TUI. See [tui.md](tui.md) for component details.
Return `undefined` to use default rendering. The returned component is wrapped in a styled Box by the TUI. See [tui.md](tui.md) for component details.
### pi.exec(command, args, options?)
@@ -757,12 +788,18 @@ const result = await pi.exec("git", ["status"], {
// result.stdout, result.stderr, result.code, result.killed
```
### pi.logger / pi.typebox / pi.pi
- `pi.logger` is the shared logger (avoid `console.*` to keep the TUI clean)
- `pi.typebox` exposes `@sinclair/typebox` for schema definitions
- `pi.pi` exposes `@oh-my-pi/pi-coding-agent` exports (components, helpers)
## Examples
### Permission Gate
```typescript
import type { HookAPI } from "@oh-my-pi/pi-coding-agent";
import type { HookAPI } from "@oh-my-pi/pi-coding-agent/hooks";
export default function (pi: HookAPI) {
const dangerous = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i];
@@ -785,7 +822,7 @@ export default function (pi: HookAPI) {
### Protected Paths
```typescript
import type { HookAPI } from "@oh-my-pi/pi-coding-agent";
import type { HookAPI } from "@oh-my-pi/pi-coding-agent/hooks";
export default function (pi: HookAPI) {
const protectedPaths = [".env", ".git/", "node_modules/"];
@@ -805,7 +842,7 @@ export default function (pi: HookAPI) {
### Git Checkpoint
```typescript
import type { HookAPI } from "@oh-my-pi/pi-coding-agent";
import type { HookAPI } from "@oh-my-pi/pi-coding-agent/hooks";
export default function (pi: HookAPI) {
const checkpoints = new Map<string, string>();
@@ -844,13 +881,15 @@ See [examples/hooks/snake.ts](../examples/hooks/snake.ts) for a complete example
## Mode Behavior
| Mode | UI Methods | Notes |
| ------------ | -------------------------- | -------------------------- |
| Interactive | Full TUI | Normal operation |
| RPC | JSON protocol | Host handles UI |
| Print (`-p`) | No-op (returns null/false) | Hooks run but can't prompt |
| Mode | UI Methods | Notes |
| --------------- | -------------------------- | ------------------------------------------ |
| Interactive | Full TUI | Normal operation |
| RPC | UI via RPC | Host handles UI, `ctx.hasUI` is true |
| Print (`-p`) | No-op (returns undefined/false) | Hooks run but can't prompt (`ctx.hasUI`=false) |
In print mode, `select()` returns `undefined`, `confirm()` returns `false`, `input()` returns `undefined`, `getEditorText()` returns `""`, and `setEditorText()`/`setStatus()` are no-ops. Design hooks to handle this by checking `ctx.hasUI`.
In print mode (including JSON output), `select()` returns `undefined`, `confirm()` returns `false`, `input()` returns
`undefined`, `getEditorText()` returns `""`, and `setEditorText()`/`setStatus()` are no-ops. Design hooks to handle this
by checking `ctx.hasUI`.
## Error Handling
+40 -14
View File
@@ -12,14 +12,16 @@ python -m pip install jupyter_kernel_gateway ipykernel
## How It Works
The Python tool starts a Jupyter Kernel Gateway process locally, which manages an IPython kernel. All code execution goes through the gateway's REST and WebSocket APIs.
The Python tool uses a Jupyter Kernel Gateway and talks to it over REST and WebSocket APIs.
By default it uses a shared local gateway so multiple pi instances reuse the same gateway process.
Startup flow:
1. Spawn `python -m kernel_gateway` on a random available port
2. Wait for gateway to become ready (`GET /api/kernelspecs`)
3. Create a kernel (`POST /api/kernels`)
4. Connect WebSocket for execution messages
5. Run prelude code (helper functions)
Shared-gateway startup flow:
1. Filter the environment and resolve the Python runtime (including venv detection)
2. Acquire the shared gateway (reuse a healthy gateway or spawn `python -m kernel_gateway` on 127.0.0.1:PORT)
3. Wait for gateway readiness (`GET /api/kernelspecs`)
4. Create a kernel (`POST /api/kernels`)
5. Connect WebSocket for execution messages
6. Initialize kernel environment, run prelude helpers, and load extension modules
## External Gateway Support
@@ -47,31 +49,55 @@ This is useful for:
## Environment Propagation
- The kernel inherits a filtered environment (explicit allowlist + denylist)
- `PYTHONPATH` includes the working directory and any existing `PYTHONPATH` value
- Allowlisted prefixes include `LC_`, `XDG_`, and `OMP_`; known API-key vars are removed
- `PYTHONPATH` is passed through if present
- Virtual environments are detected via `VIRTUAL_ENV`, `.venv/`, or `venv/` and preferred when present
## Prelude Extensions
Optional `.py` modules are loaded after the prelude from:
- `~/.omp/agent/modules` and `~/.pi/agent/modules`
- `<project>/.omp/modules` and `<project>/.pi/modules`
Project modules override user modules with the same filename.
## Kernel Modes
Settings under `python` control exposure and reuse:
- `toolMode`: `ipy-only` (default), `bash-only`, `both`
- `kernelMode`: `session` (default, queued), `per-call`
- `toolMode`: `both` (default), `ipy-only`, `bash-only`
- `kernelMode`: `session` (default) or `per-call`
- `sharedGateway`: `true` (default). Setting to `false` throws an error because local (per-process) gateways are not supported; the shared gateway is required.
## Shell Bridge
Mode behavior:
- `session`: reuse kernels per session id, serialize execution, evict after 5 minutes of idle time (max 4 sessions)
- `per-call`: create a fresh kernel per tool call and shut it down afterward
The Python prelude exposes `bash()` which:
- Sources the shell snapshot when `OMP_SHELL_SNAPSHOT` is set
- Runs via `bash -lc` when available, with OS fallbacks
Environment override:
- `OMP_PY=0|bash` → `bash-only`
- `OMP_PY=1|py` → `ipy-only`
- `OMP_PY=mix|both` → `both`
## Shell Helper
The Python prelude exposes `run()` which executes a shell command via `bash -c` (or `sh -c` fallback)
and returns a `ShellResult` with `stdout`, `stderr`, and `code`.
## Output Handling
- Streams `stdout`/`stderr` as text
- `application/x-omp-status` emits structured status events for the TUI
- `image/png` display data renders inline in TUI
- `application/json` display data renders as a collapsible tree
- `text/markdown` is rendered as-is, `text/plain` is used as a fallback
- `text/html` display data is converted to basic markdown
## Troubleshooting
- **Kernel unavailable**: Ensure `python` + `jupyter-kernel-gateway` + `ipykernel` are installed; the session will fall back to bash-only.
- **Python mode override**: Check `python.toolMode` or `OMP_PY` if the Python tool is missing.
- **Shared gateway disabled**: `python.sharedGateway=false` causes the Python tool to error because local (per-process) gateways are not supported.
- **Skip preflight checks**: Set `OMP_PYTHON_SKIP_CHECK=1` to bypass kernel availability checks.
- **External gateway unreachable**: Check the URL is correct and the gateway is running. If auth is required, set `OMP_PYTHON_GATEWAY_TOKEN`.
- **IPC tracing**: Set `OMP_PYTHON_IPC_TRACE=1` to log kernel message flow.
- **Stdin requests**: Interactive input is not supported; refactor code to avoid `input()` or provide data programmatically.
+43 -19
View File
@@ -2,7 +2,7 @@
RPC mode enables headless operation of the coding agent via a JSON protocol over stdin/stdout. This is useful for embedding the agent in other applications, IDEs, or custom UIs.
**Note for Node.js/TypeScript users**: If you're building a Node.js application, consider using `AgentSession` directly from `@oh-my-pi/pi-coding-agent` instead of spawning a subprocess. See [`src/core/agent-session.ts`](../src/core/agent-session.ts) for the API. For a subprocess-based TypeScript client, see [`src/modes/rpc/rpc-client.ts`](../src/modes/rpc/rpc-client.ts).
**Note for Node.js/TypeScript users**: If you're building a Node.js application, consider using `createAgentSession()` from `@oh-my-pi/pi-coding-agent` instead of spawning a subprocess. See [`src/sdk.ts`](../src/sdk.ts) for the SDK API. For a subprocess-based TypeScript client, see [`src/modes/rpc/rpc-client.ts`](../src/modes/rpc/rpc-client.ts).
## Starting RPC Mode
@@ -105,7 +105,7 @@ Response:
#### new_session
Start a fresh session. Can be cancelled by a `session_before_switch` hook.
Start a fresh session. Can be cancelled by a `session_before_switch` extension handler.
```json
{ "type": "new_session" }
@@ -123,7 +123,7 @@ Response:
{ "type": "response", "command": "new_session", "success": true, "data": { "cancelled": false } }
```
If a hook cancelled:
If an extension cancelled:
```json
{ "type": "response", "command": "new_session", "success": true, "data": { "cancelled": true } }
@@ -156,6 +156,7 @@ Response:
"interruptMode": "immediate",
"sessionFile": "/path/to/session.jsonl",
"sessionId": "abc123",
"sessionName": "my-session",
"autoCompactionEnabled": true,
"messageCount": 5,
"queuedMessageCount": 0
@@ -448,12 +449,16 @@ Response:
"output": "total 48\ndrwxr-xr-x ...",
"exitCode": 0,
"cancelled": false,
"truncated": false
"truncated": false,
"totalLines": 48,
"totalBytes": 2048,
"outputLines": 48,
"outputBytes": 2048
}
}
```
If output was truncated, includes `fullOutputPath`:
If output was truncated, includes `artifactId`:
```json
{
@@ -465,7 +470,11 @@ If output was truncated, includes `fullOutputPath`:
"exitCode": 0,
"cancelled": false,
"truncated": true,
"fullOutputPath": "/tmp/omp-bash-abc123.log"
"totalLines": 5000,
"totalBytes": 102400,
"outputLines": 2000,
"outputBytes": 51200,
"artifactId": "abc123"
}
}
```
@@ -568,7 +577,7 @@ Response:
#### switch_session
Load a different session file. Can be cancelled by a `before_switch` hook.
Load a different session file. Can be cancelled by a `session_before_switch` extension handler.
```json
{ "type": "switch_session", "sessionPath": "/path/to/session.jsonl" }
@@ -580,7 +589,7 @@ Response:
{ "type": "response", "command": "switch_session", "success": true, "data": { "cancelled": false } }
```
If a hook cancelled the switch:
If an extension cancelled the switch:
```json
{ "type": "response", "command": "switch_session", "success": true, "data": { "cancelled": true } }
@@ -588,7 +597,7 @@ If a hook cancelled the switch:
#### branch
Create a new branch from a previous user message. Can be cancelled by a `before_branch` hook. Returns the text of the message being branched from.
Create a new branch from a previous user message. Can be cancelled by a `session_before_branch` extension handler. Returns the text of the message being branched from.
```json
{ "type": "branch", "entryId": "abc123" }
@@ -605,7 +614,7 @@ Response:
}
```
If a hook cancelled the branch:
If an extension cancelled the branch:
```json
{
@@ -661,6 +670,22 @@ Response:
Returns `{"text": null}` if no assistant messages exist.
#### set_session_name
Set a display name for the current session.
```json
{ "type": "set_session_name", "name": "my-session" }
```
Response:
```json
{ "type": "response", "command": "set_session_name", "success": true }
```
Returns an error if the name is empty.
## Events
Events are streamed to stdout as JSON lines during agent operation. Events do NOT include an `id` field (only responses do).
@@ -683,7 +708,7 @@ Events are streamed to stdout as JSON lines during agent operation. Events do NO
| `auto_compaction_end` | Auto-compaction completes |
| `auto_retry_start` | Auto-retry begins (after transient error) |
| `auto_retry_end` | Auto-retry completes (success or final failure) |
| `hook_error` | Hook threw an error |
| `extension_error` | Extension threw an error |
### agent_start
@@ -795,7 +820,7 @@ During execution, `tool_execution_update` events stream partial results (e.g., b
"args": { "command": "ls -la" },
"partialResult": {
"content": [{ "type": "text", "text": "partial output so far..." }],
"details": { "truncation": null, "fullOutputPath": null }
"details": {...}
}
}
```
@@ -878,15 +903,15 @@ On final failure (max retries exceeded):
}
```
### hook_error
### extension_error
Emitted when a hook throws an error.
Emitted when an extension throws an error.
```json
{
"type": "hook_error",
"hookPath": "/path/to/hook.ts",
"event": "tool_call",
"type": "extension_error",
"extensionPath": "/path/to/extension.ts",
"event": "turn_start",
"error": "Error message..."
}
```
@@ -921,7 +946,7 @@ Source files:
- [`packages/ai/src/types.ts`](../../ai/src/types.ts) - `Model`, `UserMessage`, `AssistantMessage`, `ToolResultMessage`
- [`packages/agent/src/types.ts`](../../agent/src/types.ts) - `AgentMessage`, `AgentEvent`
- [`src/core/messages.ts`](../src/core/messages.ts) - `BashExecutionMessage`
- [`src/session/messages.ts`](../src/session/messages.ts) - `BashExecutionMessage`
- [`src/modes/rpc/rpc-types.ts`](../src/modes/rpc/rpc-types.ts) - RPC command/response types
### Model
@@ -1011,7 +1036,6 @@ Created by the `bash` RPC command (not by LLM tool calls):
"exitCode": 0,
"cancelled": false,
"truncated": false,
"fullOutputPath": null,
"timestamp": 1733234567890
}
```
+271 -212
View File
@@ -21,14 +21,18 @@ import { createAgentSession, discoverAuthStorage, discoverModels, SessionManager
// Set up credential storage and model registry
const authStorage = await discoverAuthStorage();
const modelRegistry = await discoverModels(authStorage);
const modelRegistry = discoverModels(authStorage);
const { session } = await createAgentSession({
const { session, modelFallbackMessage } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
authStorage,
modelRegistry,
});
if (modelFallbackMessage) {
process.stderr.write(`${modelFallbackMessage}\n`);
}
session.subscribe((event) => {
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
process.stdout.write(event.assistantMessageEvent.delta);
@@ -41,7 +45,7 @@ await session.prompt("What files are in the current directory?");
## Installation
```bash
npm install @oh-my-pi/pi-coding-agent
bun add @oh-my-pi/pi-coding-agent
```
The SDK is included in the main package. No separate installation needed.
@@ -59,15 +63,16 @@ The main factory function. Creates an `AgentSession` with configurable options.
```typescript
import { createAgentSession } from "@oh-my-pi/pi-coding-agent";
import systemPrompt from "./SYSTEM.md" with { type: "text" };
// Minimal: all defaults (discovers everything from cwd and ~/.omp/agent)
// Minimal: all defaults (discovers from cwd + config dirs and ~/.omp/agent)
const { session } = await createAgentSession();
// Custom: override specific options
const { session } = await createAgentSession({
model: myModel,
systemPrompt: "You are helpful.",
tools: [readTool, bashTool],
systemPrompt,
toolNames: ["read", "bash", "edit"], // Filter to specific tools
sessionManager: SessionManager.inMemory(),
});
```
@@ -78,8 +83,14 @@ The session manages the agent lifecycle, message history, and event streaming.
```typescript
interface AgentSession {
// Send a prompt and wait for completion
// Prompting
prompt(text: string, options?: PromptOptions): Promise<void>;
sendUserMessage(
content: string | (TextContent | ImageContent)[],
options?: { deliverAs?: "steer" | "followUp" },
): Promise<void>;
steer(text: string): void;
followUp(text: string): void;
// Subscribe to events (returns unsubscribe function)
subscribe(listener: (event: AgentSessionEvent) => void): () => void;
@@ -87,43 +98,65 @@ interface AgentSession {
// Session info
sessionFile: string | undefined; // undefined for in-memory
sessionId: string;
sessionName: string | undefined;
// Model control
setModel(model: Model): Promise<void>;
setModel(model: Model, role?: ModelRole): Promise<void>;
setModelTemporary(model: Model): Promise<void>;
setThinkingLevel(level: ThinkingLevel): void;
cycleModel(): Promise<ModelCycleResult | undefined>;
cycleModel(direction?: "forward" | "backward"): Promise<ModelCycleResult | undefined>;
cycleRoleModels(
direction?: "forward" | "backward",
): Promise<{ model: Model; thinkingLevel: ThinkingLevel; role: ModelRole } | undefined>;
cycleThinkingLevel(): ThinkingLevel | undefined;
// State access
agent: Agent;
sessionManager: SessionManager;
settings: Settings;
model: Model | undefined;
thinkingLevel: ThinkingLevel;
messages: AgentMessage[];
isStreaming: boolean;
isCompacting: boolean;
isRetrying: boolean;
// Session management
newSession(options?: { parentSession?: string }): Promise<boolean>; // Returns false if cancelled by hook
switchSession(sessionPath: string): Promise<boolean>;
newSession(options?: NewSessionOptions): Promise<boolean>; // Returns false if cancelled by extension
fork(): Promise<boolean>; // Creates a new session file
// Branching
branch(entryId: string): Promise<{ selectedText: string; cancelled: boolean }>; // Creates new session file
branch(entryId: string): Promise<{ selectedText: string; cancelled: boolean }>;
navigateTree(
targetId: string,
options?: { summarize?: boolean }
): Promise<{ editorText?: string; cancelled: boolean }>; // In-place navigation
options?: { summarize?: boolean; customInstructions?: string }
): Promise<{ editorText?: string; cancelled: boolean; aborted?: boolean; summaryEntry?: BranchSummaryEntry }>;
// Hook message injection
sendHookMessage(message: HookMessage, triggerTurn?: boolean): Promise<void>;
// Custom message injection
sendCustomMessage<T>(
message: { customType: string; content: T; display?: boolean; details?: unknown },
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }
): Promise<void>;
// Compaction
compact(customInstructions?: string): Promise<CompactionResult>;
compact(
customInstructions?: string,
options?: { onComplete?: (result: CompactionResult) => void; onError?: (error: Error) => void },
): Promise<CompactionResult>;
abortCompaction(): void;
// Utilities
getSessionStats(): SessionStats;
formatSessionAsText(): string;
formatCompactContext(): string;
exportToHtml(outputPath?: string): Promise<string>;
handoff(customInstructions?: string): Promise<{ document: string } | undefined>;
// Abort current operation
abort(): Promise<void>;
// Cleanup
dispose(): void;
dispose(): Promise<void>;
}
```
@@ -200,11 +233,16 @@ session.subscribe((event) => {
// event.toolResults: tool results from this turn
break;
// Session events (auto-compaction, retry)
// Session events (auto-compaction, retry, TTSR, todo reminders)
case "auto_compaction_start":
case "auto_compaction_end":
case "auto_retry_start":
case "auto_retry_end":
case "ttsr_triggered":
// event.rules
break;
case "todo_reminder":
// event.todos
break;
}
});
@@ -226,24 +264,17 @@ const { session } = await createAgentSession({
`cwd` is used for:
- Project hooks (`.omp/hooks/`)
- Project tools (`.omp/tools/`)
- Project skills (`.omp/skills/`)
- Project commands (`.omp/commands/`)
- Project config discovery (`.omp/`, `.pi/`, `.claude/`, `.codex/`, `.gemini/`)
- Project extensions/tools/skills/commands (via config dirs)
- Context files (`AGENTS.md` walking up from cwd)
- Session directory naming
- Session directory naming (via `SessionManager.create(cwd)`)
`agentDir` is used for:
- Global hooks (`hooks/`)
- Global tools (`tools/`)
- Global skills (`skills/`)
- Global commands (`commands/`)
- Global context file (`AGENTS.md`)
- Settings (`settings.json`)
- Custom models (`models.json`)
- Credentials (`auth.json`)
- Sessions (`sessions/`)
- Global settings (`config.yml` + `agent.db`)
- Primary auth/models locations (`auth.json`, `models.yml`, `models.json`)
- Prompt templates (`prompts/`)
- Custom TS commands (`commands/`)
### Model
@@ -252,18 +283,18 @@ import { getModel } from "@oh-my-pi/pi-ai";
import { discoverAuthStorage, discoverModels } from "@oh-my-pi/pi-coding-agent";
const authStorage = await discoverAuthStorage();
const modelRegistry = await discoverModels(authStorage);
const modelRegistry = discoverModels(authStorage);
// Find specific built-in model (doesn't check if API key exists)
const opus = getModel("anthropic", "claude-opus-4-5");
if (!opus) throw new Error("Model not found");
// Find any model by provider/id, including custom models from models.json
// Find any model by provider/id, including custom models from models.yml
// (doesn't check if API key exists)
const customModel = modelRegistry.find("my-provider", "my-model");
// Get only models that have valid API keys configured
const available = await modelRegistry.getAvailable();
// Get all models that have valid API keys configured
const available = modelRegistry.getAvailable();
const { session } = await createAgentSession({
model: opus,
@@ -293,16 +324,18 @@ If no model is provided:
API key resolution priority (handled by AuthStorage):
1. Runtime overrides (via `setRuntimeApiKey`, not persisted)
2. Stored credentials in `auth.json` (API keys or OAuth tokens)
2. Stored credentials in `agent.db` (API keys or OAuth tokens)
3. Environment variables (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.)
4. Fallback resolver (for custom provider keys from `models.json`)
4. Fallback resolver (for custom provider keys from `models.yml`)
`discoverAuthStorage` also migrates legacy `auth.json` from user config directories (`.pi`, `.claude`, `.codex`, `.gemini`) into `agent.db`.
```typescript
import { AuthStorage, ModelRegistry, discoverAuthStorage, discoverModels } from "@oh-my-pi/pi-coding-agent";
// Default: uses ~/.omp/agent/auth.json and ~/.omp/agent/models.json
// Default: uses agentDir/auth.json → agent.db and agentDir/models.yml (with legacy fallbacks)
const authStorage = await discoverAuthStorage();
const modelRegistry = await discoverModels(authStorage);
const modelRegistry = discoverModels(authStorage);
const { session } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
@@ -313,9 +346,9 @@ const { session } = await createAgentSession({
// Runtime API key override (not persisted to disk)
authStorage.setRuntimeApiKey("anthropic", "sk-my-temp-key");
// Custom auth storage location
const customAuth = new AuthStorage("/my/app/auth.json");
const customRegistry = new ModelRegistry(customAuth, "/my/app/models.json");
// Custom auth storage location (use create(), constructor is private)
const customAuth = await AuthStorage.create("/my/app/auth.json");
const customRegistry = new ModelRegistry(customAuth, "/my/app/models.yml");
const { session } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
@@ -323,7 +356,7 @@ const { session } = await createAgentSession({
modelRegistry: customRegistry,
});
// No custom models.json (built-in models only)
// No custom models.yml (built-in models only)
const simpleRegistry = new ModelRegistry(authStorage);
```
@@ -332,10 +365,14 @@ const simpleRegistry = new ModelRegistry(authStorage);
### System Prompt
```typescript
const { session } = await createAgentSession({
// Replace entirely
systemPrompt: "You are a helpful assistant.",
import systemPrompt from "./SYSTEM.md" with { type: "text" };
const { session } = await createAgentSession({
// Replace entirely with a static prompt
systemPrompt,
});
const { session: modified } = await createAgentSession({
// Or modify default (receives default, returns modified)
systemPrompt: (defaultPrompt) => {
return `${defaultPrompt}\n\n## Additional Rules\n- Be concise`;
@@ -355,8 +392,10 @@ const { session } = await createAgentSession();
// Filter to specific tools
const { session } = await createAgentSession({
toolNames: ["read", "grep", "find", "ls"], // Read-only tools
toolNames: ["read", "grep", "find"], // Read-only tools
});
`toolNames` is an allowlist for built-ins; custom tools are always included even if not listed.
```
#### Available Built-in Tools
@@ -365,32 +404,43 @@ All tools are defined in `BUILTIN_TOOLS`:
- `ask` - Interactive user prompts (requires UI)
- `bash` - Shell command execution
- `python` - Python REPL execution
- `calc` - Calculator
- `ssh` - Remote SSH execution
- `edit` - Surgical file editing
- `find` - File search by glob patterns
- `git` - Git operations (can be disabled via settings)
- `grep` - Content search with regex
- `ls` - Directory listing
- `lsp` - Language server protocol integration
- `notebook` - Jupyter notebook editing
- `output` - Task output retrieval
- `read` - File reading (text and images)
- `browser` - Puppeteer-based web browser
- `task` - Subagent spawning
- `todo_write` - Todo file management
- `fetch` - URL fetching
- `web_search` - Web search
- `write` - File writing
Hidden tools (not in `BUILTIN_TOOLS`) are available but excluded unless requested:
- `submit_result` - Required for subagent structured output (use `requireSubmitResultTool` or include in `toolNames`)
- `report_finding` - Security review reporting
- `exit_plan_mode` - Plan mode control
#### Creating Tools Manually
For advanced use cases, you can create tools directly using `createTools`:
```typescript
import { BUILTIN_TOOLS, createTools, type ToolSession } from "@oh-my-pi/pi-coding-agent";
import { createTools, Settings, type ToolSession } from "@oh-my-pi/pi-coding-agent";
const settingsInstance = await Settings.init({ cwd: "/path/to/project" });
const session: ToolSession = {
cwd: "/path/to/project",
hasUI: false,
getSessionFile: () => null,
getSessionSpawns: () => "*",
settings: settingsInstance,
};
const tools = await createTools(session);
@@ -398,20 +448,18 @@ const tools = await createTools(session);
**When you don't need factories:**
- If you omit `tools`, omp automatically creates them with the correct `cwd`
- If you omit `toolNames`, omp automatically creates them with the correct `cwd`
- If you use `process.cwd()` as your `cwd`, the pre-built instances work fine
**When you must use factories:**
- When you specify both `cwd` (different from `process.cwd()`) AND `tools`
> See [examples/sdk/05-tools.ts](../examples/sdk/05-tools.ts)
- When you specify both `cwd` (different from `process.cwd()`) AND custom tools
### Custom Tools
```typescript
import { Type } from "@sinclair/typebox";
import { createAgentSession, discoverCustomTools, type CustomTool } from "@oh-my-pi/pi-coding-agent";
import { createAgentSession, type CustomTool } from "@oh-my-pi/pi-coding-agent";
// Inline custom tool
const myTool: CustomTool = {
@@ -421,38 +469,36 @@ const myTool: CustomTool = {
parameters: Type.Object({
input: Type.String({ description: "Input value" }),
}),
execute: async (toolCallId, params) => ({
execute: async (toolCallId, params, onUpdate, ctx, signal) => ({
content: [{ type: "text", text: `Result: ${params.input}` }],
details: {},
}),
// Optional session lifecycle handler
onSession: async (event, ctx) => {
if (event.reason === "shutdown") {
// cleanup
}
},
};
// Replace discovery with inline tools
// Add custom tools (merged with built-in tools)
const { session } = await createAgentSession({
customTools: [{ tool: myTool }],
});
// Merge with discovered tools
const discovered = await discoverCustomTools();
const { session } = await createAgentSession({
customTools: [...discovered, { tool: myTool }],
});
// Add paths without replacing discovery
const { session } = await createAgentSession({
additionalCustomToolPaths: ["/extra/tools"],
customTools: [myTool],
});
```
> See [examples/sdk/05-tools.ts](../examples/sdk/05-tools.ts)
### Extensions
### Hooks
Extensions intercept agent events and can register custom tools/commands. Hooks remain for legacy compatibility.
```typescript
import { createAgentSession, discoverHooks, type HookFactory } from "@oh-my-pi/pi-coding-agent";
import {
createAgentSession,
discoverExtensions,
type ExtensionFactory,
} from "@oh-my-pi/pi-coding-agent";
// Inline hook
const loggingHook: HookFactory = (api) => {
// Inline extension
const loggingExtension: ExtensionFactory = (api) => {
// Log tool calls
api.on("tool_call", async (event) => {
console.log(`Tool: ${event.toolName}`);
@@ -470,58 +516,52 @@ const loggingHook: HookFactory = (api) => {
// Register custom slash command
api.registerCommand("stats", {
description: "Show session stats",
handler: async (ctx) => {
handler: async (args, ctx) => {
const entries = ctx.sessionManager.getEntries();
ctx.ui.notify(`${entries.length} entries`, "info");
},
});
// Inject messages
api.sendMessage(
{
customType: "my-hook",
content: "Hook initialized",
display: false, // Hidden from TUI
},
false
); // Don't trigger agent turn
// Persist hook state
api.appendEntry("my-hook", { initialized: true });
};
// Merge with discovery (default behavior)
const { session } = await createAgentSession({
extensions: [loggingExtension],
});
// Replace discovery
const { session } = await createAgentSession({
hooks: [{ factory: loggingHook }],
extensions: [loggingExtension],
disableExtensionDiscovery: true,
});
// Disable all hooks
// Disable all extensions
const { session } = await createAgentSession({
hooks: [],
extensions: [],
disableExtensionDiscovery: true,
});
// Merge with discovered
const discovered = await discoverHooks();
// Use preloaded extensions (skip discovery I/O)
const discovered = await discoverExtensions();
const { session } = await createAgentSession({
hooks: [...discovered, { factory: loggingHook }],
preloadedExtensions: discovered,
extensions: [loggingExtension],
});
// Add paths without replacing
// Add paths without replacing discovery
const { session } = await createAgentSession({
additionalHookPaths: ["/extra/hooks"],
additionalExtensionPaths: ["/extra/extensions"],
});
```
Hook API methods:
Extension API methods:
- `api.on(event, handler)` - Subscribe to events
- `api.sendMessage(message, triggerTurn?)` - Inject message (creates `CustomMessageEntry`)
- `api.appendEntry(customType, data?)` - Persist hook state (not in LLM context)
- `api.registerTool(definition)` - Register a custom tool
- `api.registerCommand(name, options)` - Register custom slash command
- `api.registerMessageRenderer(customType, renderer)` - Custom TUI rendering
- `api.exec(command, args, options?)` - Execute shell commands
> See [examples/sdk/06-hooks.ts](../examples/sdk/06-hooks.ts) and [docs/hooks.md](hooks.md)
> See [examples/sdk/06-extensions.ts](../examples/sdk/06-extensions.ts) and [docs/extensions.md](extensions.md)
### Skills
@@ -529,7 +569,7 @@ Hook API methods:
import { createAgentSession, discoverSkills, type Skill } from "@oh-my-pi/pi-coding-agent";
// Discover and filter
const { skills: allSkills, warnings } = discoverSkills();
const { skills: allSkills, warnings } = await discoverSkills();
const filtered = allSkills.filter((s) => s.name.includes("search"));
// Custom skill
@@ -549,12 +589,6 @@ const { session } = await createAgentSession({
const { session } = await createAgentSession({
skills: [],
});
// Discovery with settings filter
const { skills } = discoverSkills(process.cwd(), undefined, {
ignoredSkills: ["browser-*"], // glob patterns to exclude
includeSkills: ["search-*"], // glob patterns to include (empty = all)
});
```
> See [examples/sdk/04-skills.ts](../examples/sdk/04-skills.ts)
@@ -565,7 +599,7 @@ const { skills } = discoverSkills(process.cwd(), undefined, {
import { createAgentSession, discoverContextFiles } from "@oh-my-pi/pi-coding-agent";
// Discover AGENTS.md files
const discovered = discoverContextFiles();
const discovered = await discoverContextFiles();
// Add custom context
const { session } = await createAgentSession({
@@ -591,7 +625,7 @@ const { session } = await createAgentSession({
```typescript
import { createAgentSession, discoverSlashCommands, type FileSlashCommand } from "@oh-my-pi/pi-coding-agent";
const discovered = discoverSlashCommands();
const discovered = await discoverSlashCommands();
const customCommand: FileSlashCommand = {
name: "deploy",
@@ -624,21 +658,21 @@ const { session } = await createAgentSession({
sessionManager: SessionManager.create(process.cwd()),
});
// Continue most recent
// Continue most recent (async)
const { session, modelFallbackMessage } = await createAgentSession({
sessionManager: SessionManager.continueRecent(process.cwd()),
sessionManager: await SessionManager.continueRecent(process.cwd()),
});
if (modelFallbackMessage) {
console.log("Note:", modelFallbackMessage);
}
// Open specific file
// Open specific file (async)
const { session } = await createAgentSession({
sessionManager: SessionManager.open("/path/to/session.jsonl"),
sessionManager: await SessionManager.open("/path/to/session.jsonl"),
});
// List available sessions
const sessions = SessionManager.list(process.cwd());
// List available sessions (async)
const sessions = await SessionManager.list(process.cwd());
for (const info of sessions) {
console.log(`${info.id}: ${info.firstMessage} (${info.messageCount} messages)`);
}
@@ -650,15 +684,25 @@ const { session } = await createAgentSession({
});
```
**SessionManager static factories:**
- `SessionManager.create(cwd, sessionDir?)` - New persistent session (sync)
- `SessionManager.inMemory(cwd?)` - In-memory session (sync)
- `SessionManager.open(filePath, sessionDir?)` - Open existing file (async)
- `SessionManager.continueRecent(cwd, sessionDir?)` - Most recent session (async)
- `SessionManager.list(cwd, sessionDir?)` - List sessions (async)
- `SessionManager.listAll()` - List all sessions across cwds (async)
- `SessionManager.forkFrom(sourcePath, cwd, sessionDir?)` - Fork from existing (async)
**SessionManager tree API:**
```typescript
const sm = SessionManager.open("/path/to/session.jsonl");
const sm = await SessionManager.open("/path/to/session.jsonl");
// Tree traversal
const entries = sm.getEntries(); // All entries (excludes header)
const tree = sm.getTree(); // Full tree structure
const path = sm.getPath(); // Path from root to current leaf
const branch = sm.getBranch(); // Path from root to current leaf
const leaf = sm.getLeafEntry(); // Current leaf entry
const entry = sm.getEntry(id); // Get entry by ID
const children = sm.getChildren(id); // Direct children of entry
@@ -678,100 +722,112 @@ sm.createBranchedSession(leafId); // Extract path to new file
### Settings Management
```typescript
import { createAgentSession, SettingsManager, SessionManager } from "@oh-my-pi/pi-coding-agent";
import { createAgentSession, Settings, SessionManager } from "@oh-my-pi/pi-coding-agent";
// Default: loads from files (global config.yml + project settings.json merged)
const settingsInstance = await Settings.init();
// Default: loads from files (global + project merged)
const { session } = await createAgentSession({
settingsManager: await SettingsManager.create(),
settingsInstance,
});
// With overrides
const settingsManager = await SettingsManager.create();
settingsManager.applyOverrides({
compaction: { enabled: false },
retry: { enabled: true, maxRetries: 5 },
});
const { session } = await createAgentSession({ settingsManager });
// Read/write settings
const enabled = settingsInstance.get("compaction.enabled");
settingsInstance.set("compaction.enabled", false);
// In-memory (no file I/O, for testing)
const isolated = Settings.isolated({
"compaction.enabled": false,
"retry.enabled": true,
});
const { session } = await createAgentSession({
settingsManager: SettingsManager.inMemory({ compaction: { enabled: false } }),
settingsInstance: isolated,
sessionManager: SessionManager.inMemory(),
});
// Custom directories
const { session } = await createAgentSession({
settingsManager: await SettingsManager.create("/custom/cwd", "/custom/agent"),
cwd: "/custom/cwd",
agentDir: "/custom/agent",
});
```
**Static factories:**
**Settings static factories:**
- `SettingsManager.create(cwd?, agentDir?)` - Load from files (async)
- `SettingsManager.inMemory(settings?)` - No file I/O
- `Settings.init(options?)` - Load from files (async)
- `Settings.isolated(overrides?)` - In-memory, no file I/O (sync)
- `Settings.instance` - Global singleton (throws if not initialized)
**Project-specific settings:**
**Settings file locations:**
Settings load from two locations and merge:
1. Global: `~/.omp/agent/settings.json`
2. Project: `<cwd>/.omp/settings.json`
1. Global: `<agentDir>/config.yml` (default `~/.omp/agent/config.yml`)
2. Project: `settings.json` from the first matching config dir (`.omp/`, `.pi/`, `.claude/`, `.codex/`, `.gemini/`)
Project overrides global. Nested objects merge keys. Setters only modify global (project is read-only for version control).
> See [examples/sdk/10-settings.ts](../examples/sdk/10-settings.ts)
Project overrides global. Nested objects merge keys.
## Discovery Functions
All discovery functions accept optional `cwd` and `agentDir` parameters.
Discovery functions accept optional `cwd` and `agentDir` parameters where applicable.
```typescript
import { getModel } from "@oh-my-pi/pi-ai";
import appendPrompt from "./APPEND_SYSTEM.md" with { type: "text" };
import {
AuthStorage,
ModelRegistry,
discoverAuthStorage,
discoverModels,
discoverSkills,
discoverHooks,
discoverCustomTools,
discoverExtensions,
discoverContextFiles,
discoverSlashCommands,
loadSettings,
discoverPromptTemplates,
discoverCustomTSCommands,
discoverMCPServers,
buildSystemPrompt,
Settings,
} from "@oh-my-pi/pi-coding-agent";
// Auth and Models
const authStorage = await discoverAuthStorage(); // ~/.omp/agent/agent.db
const modelRegistry = await discoverModels(authStorage); // + ~/.omp/agent/models.json
const authStorage = await discoverAuthStorage(); // <agentDir>/auth.json → agent.db (with fallbacks)
const modelRegistry = discoverModels(authStorage); // + <agentDir>/models.yml (or models.json)
const allModels = modelRegistry.getAll(); // All models (built-in + custom)
const available = await modelRegistry.getAvailable(); // Only models with API keys
const available = modelRegistry.getAvailable(); // Only models with API keys
const model = modelRegistry.find("provider", "id"); // Find specific model
const builtIn = getModel("anthropic", "claude-opus-4-5"); // Built-in only
// Skills
const { skills, warnings } = discoverSkills(cwd, agentDir, skillsSettings);
// Skills (async)
const { skills, warnings } = await discoverSkills(cwd, agentDir, skillsSettings);
// Hooks (async - loads TypeScript)
const hooks = await discoverHooks(cwd, agentDir);
// Extensions (async - loads TypeScript)
const extensionsResult = await discoverExtensions(cwd);
// Custom tools (async - loads TypeScript)
const tools = await discoverCustomTools(cwd, agentDir);
// Custom TS commands (async - loads TypeScript)
const customCommands = await discoverCustomTSCommands(cwd, agentDir);
// Context files
const contextFiles = discoverContextFiles(cwd, agentDir);
// Context files (async)
const contextFiles = await discoverContextFiles(cwd, agentDir);
// Slash commands
const commands = discoverSlashCommands(cwd, agentDir);
// Slash commands (async)
const commands = await discoverSlashCommands(cwd);
// Settings (global + project merged)
const settings = await loadSettings(cwd, agentDir);
// Prompt templates (async)
const promptTemplates = await discoverPromptTemplates(cwd, agentDir);
// MCP servers (async)
const mcp = await discoverMCPServers(cwd);
// Settings (async - global + project merged)
const settings = await Settings.init({ cwd, agentDir });
// Build system prompt manually
const prompt = buildSystemPrompt({
const prompt = await buildSystemPrompt({
skills,
contextFiles,
appendPrompt: "Additional instructions",
appendPrompt,
cwd,
});
```
@@ -785,14 +841,20 @@ interface CreateAgentSessionResult {
// The session
session: AgentSession;
// Custom tools (for UI setup)
customToolsResult: {
tools: LoadedCustomTool[];
setUIContext: (ctx, hasUI) => void;
};
// Extensions result (loaded extensions + runtime)
extensionsResult: LoadExtensionsResult;
// Update tool UI context (interactive mode)
setToolUIContext: (uiContext: ExtensionUIContext, hasUI: boolean) => void;
// MCP manager for server lifecycle management (undefined if MCP disabled)
mcpManager?: MCPManager;
// Warning if session model couldn't be restored
modelFallbackMessage?: string;
// LSP servers that were warmed up at startup
lspServers?: Array<{ name: string; status: "ready" | "error"; fileTypes: string[]; error?: string }>;
}
```
@@ -802,30 +864,29 @@ interface CreateAgentSessionResult {
import { getModel } from "@oh-my-pi/pi-ai";
import { Type } from "@sinclair/typebox";
import {
AuthStorage,
createAgentSession,
ModelRegistry,
discoverAuthStorage,
discoverModels,
SessionManager,
SettingsManager,
readTool,
bashTool,
type HookFactory,
Settings,
type ExtensionFactory,
type CustomTool,
} from "@oh-my-pi/pi-coding-agent";
import systemPrompt from "./SYSTEM.md" with { type: "text" };
// Set up auth storage (custom location)
const authStorage = new AuthStorage("/custom/agent/auth.json");
// Set up auth storage
const authStorage = await discoverAuthStorage();
// Runtime API key override (not persisted)
if (process.env.MY_KEY) {
authStorage.setRuntimeApiKey("anthropic", process.env.MY_KEY);
}
// Model registry (no custom models.json)
const modelRegistry = new ModelRegistry(authStorage);
// Model registry
const modelRegistry = discoverModels(authStorage);
// Inline hook
const auditHook: HookFactory = (api) => {
// Inline extension
const auditExtension: ExtensionFactory = (api) => {
api.on("tool_call", async (event) => {
console.log(`[Audit] ${event.toolName}`);
return undefined;
@@ -838,9 +899,8 @@ const statusTool: CustomTool = {
label: "Status",
description: "Get system status",
parameters: Type.Object({}),
execute: async () => ({
execute: async (toolCallId, params, onUpdate, ctx, signal) => ({
content: [{ type: "text", text: `Uptime: ${process.uptime()}s` }],
details: {},
}),
};
@@ -848,9 +908,9 @@ const model = getModel("anthropic", "claude-opus-4-5");
if (!model) throw new Error("Model not found");
// In-memory settings with overrides
const settingsManager = SettingsManager.inMemory({
compaction: { enabled: false },
retry: { enabled: true, maxRetries: 2 },
const settingsInstance = Settings.isolated({
"compaction.enabled": false,
"retry.enabled": true,
});
const { session } = await createAgentSession({
@@ -862,17 +922,17 @@ const { session } = await createAgentSession({
authStorage,
modelRegistry,
systemPrompt: "You are a minimal assistant. Be concise.",
systemPrompt,
tools: [readTool, bashTool],
customTools: [{ tool: statusTool }],
hooks: [{ factory: auditHook }],
toolNames: ["read", "bash"],
customTools: [statusTool],
extensions: [auditExtension],
skills: [],
contextFiles: [],
slashCommands: [],
sessionManager: SessionManager.inMemory(),
settingsManager,
settingsInstance,
});
session.subscribe((event) => {
@@ -899,7 +959,7 @@ The SDK is preferred when:
- You want type safety
- You're in the same Node.js process
- You need direct access to agent state
- You want to customize tools/hooks programmatically
- You want to customize tools/extensions programmatically
RPC mode is preferred when:
@@ -923,62 +983,61 @@ discoverModels
// Discovery
discoverSkills
discoverHooks
discoverCustomTools
discoverExtensions
discoverCustomTSCommands
discoverContextFiles
discoverSlashCommands
discoverPromptTemplates
discoverMCPServers
// Helpers
loadSettings
buildSystemPrompt
Settings
// Session management
SessionManager
SettingsManager
// Tool registry and factory
BUILTIN_TOOLS // Map of tool name to factory
createTools // Create all tools from ToolSession
type ToolSession // Session context for tool creation
// Individual tool factories
createReadTool, createBashTool, EditTool, createWriteTool
createGrepTool, createFindTool, createLsTool, createGitTool
// Individual tool classes
ReadTool, BashTool, EditTool, WriteTool
GrepTool, FindTool, PythonTool
loadSshTool
// Types
type CreateAgentSessionOptions
type CreateAgentSessionResult
type CustomTool
type HookFactory
type ExtensionFactory
type Skill
type FileSlashCommand
type Settings
type SkillsSettings
type Tool
```
For hook types, import from the hooks subpath:
For extension types, import from the main package:
```typescript
import type {
HookAPI,
HookMessage,
HookFactory,
HookEventContext,
HookCommandContext,
ToolCallEvent,
ToolResultEvent,
} from "@oh-my-pi/pi-coding-agent/hooks";
ExtensionAPI,
ExtensionFactory,
ExtensionContext,
ExtensionCommandContext,
ToolDefinition,
} from "@oh-my-pi/pi-coding-agent";
```
For message utilities:
For legacy hook types (deprecated, use extensions instead):
```typescript
import { isHookMessage, createHookMessage } from "@oh-my-pi/pi-coding-agent";
import type { HookAPI, HookFactory, HookContext, HookCommandContext } from "@oh-my-pi/pi-coding-agent/hooks";
```
For config utilities:
```typescript
import { getAgentDir } from "@oh-my-pi/pi-coding-agent/config";
import { getAgentDir } from "@oh-my-pi/pi-coding-agent";
```
+60 -417
View File
@@ -1,441 +1,84 @@
# Session Tree Implementation Plan
# Session Tree Architecture (Current)
Reference: [session-tree.md](./session-tree.md)
Reference: [session.md](./session.md), [tree.md](./tree.md)
## Phase 1: SessionManager Core ✅
This document summarizes the current session tree implementation and extension touchpoints. It replaces the historical rollout checklist.
- [x] Update entry types with `id`, `parentId` fields (using SessionEntryBase)
- [x] Add `version` field to `SessionHeader`
- [x] Change `CompactionEntry.firstKeptEntryIndex` → `firstKeptEntryId`
- [x] Add `BranchSummaryEntry` type
- [x] Add `CustomEntry` type for hooks
- [x] Add `byId: Map<string, SessionEntry>` index
- [x] Add `leafId: string` tracking
- [x] Implement `getPath(fromId?)` tree traversal
- [x] Implement `getTree()` returning `SessionTreeNode[]`
- [x] Implement `getEntry(id)` lookup
- [x] Implement `getLeafUuid()` and `getLeafEntry()` helpers
- [x] Update `_buildIndex()` to populate `byId` map
- [x] Rename `saveXXX()` to `appendXXX()` (returns id, advances leaf)
- [x] Add `appendCustomEntry(customType, data)` for hooks
- [x] Update `buildSessionContext()` to use `getPath()` traversal
## Session file format (v3)
## Phase 2: Migration ✅
- JSONL file with a SessionHeader (version 3). Header is metadata only and does not participate in the tree.
- Every SessionEntry derives from SessionEntryBase: `id`, `parentId`, `timestamp`.
- Entries are append-only; branching only moves the leaf pointer.
- Entry types: `message`, `compaction`, `branch_summary`, `custom`, `custom_message`, `label`, `model_change`, `thinking_level_change`, `ttsr_injection`, `session_init`.
- [x] Add `CURRENT_SESSION_VERSION = 2` constant
- [x] Implement `migrateV1ToV2()` with extensible migration chain
- [x] Update `setSessionFile()` to detect version and migrate
- [x] Implement `_rewriteFile()` for post-migration persistence
- [x] Handle `firstKeptEntryIndex` → `firstKeptEntryId` conversion in migration
## SessionManager core
## Phase 3: Branching ✅
- Tracks `byId`, `labelsById`, `leafId`, and usage statistics.
- Tree APIs:
- `getLeafId()`, `getLeafEntry()`, `getEntry(id)`, `getChildren(id)`
- `getBranch(fromId?)` → root-to-leaf path
- `getTree()` → `SessionTreeNode { entry, children, label }`
- `getLabel(id)`
- `buildSessionContext()` walks from the current leaf and resolves compaction. `custom_message` and `branch_summary` entries are converted to AgentMessage roles and later to user-role LLM messages via `convertToLlm()`.
- Appenders (all return entry id and advance the leaf): `appendMessage`, `appendCompaction`, `appendCustomEntry`, `appendCustomMessageEntry`, `appendLabelChange`, `appendModelChange`, `appendThinkingLevelChange`, `appendSessionInit`, `appendTtsrInjection`.
- `getSessionFile()` returns `string | undefined` for in-memory sessions. `flush()` persists pending writes.
- [x] Implement `branch(id)` - switch leaf pointer
- [x] Implement `branchWithSummary(id, summary)` - create summary entry
- [x] Implement `createBranchedSession(leafId)` - extract path to new file
- [x] Update `AgentSession.branch()` to use new API
## Migration
## Phase 4: Compaction Integration ✅
- `CURRENT_SESSION_VERSION = 3`.
- v1 → v2: assigns `id`/`parentId` and converts compaction `firstKeptEntryIndex` to `firstKeptEntryId`.
- v2 → v3: renames message role `hookMessage` → `custom`.
- `SessionManager.open()` / `setSessionFile()` rewrite the file after migration.
- [x] Update `compaction.ts` to work with IDs
- [x] Update `prepareCompaction()` to return `firstKeptEntryId`
- [x] Update `compact()` to return `CompactionResult` with `firstKeptEntryId`
- [x] Update `AgentSession` compaction methods
- [x] Add `firstKeptEntryId` to `before_compact` hook event
## Branching
## Phase 5: Testing ✅
- `branch(entryId)` moves the leaf pointer to a prior entry.
- `resetLeaf()` sets the leaf to `null` so the next append creates a new root entry.
- `branchWithSummary(branchFromId, summary, details?, fromExtension?)` appends `branch_summary` and switches the leaf.
- `createBranchedSession(leafId)` writes a new session file containing the selected path; `LabelEntry` values are rebuilt from resolved labels. In-memory sessions replace their entries and return `undefined`.
- [x] `migration.test.ts` - v1 to v2 migration, idempotency
- [x] `build-context.test.ts` - context building with tree structure, compaction, branches
- [x] `tree-traversal.test.ts` - append operations, getPath, getTree, branching
- [x] `file-operations.test.ts` - loadEntriesFromFile, findMostRecentSession
- [x] `save-entry.test.ts` - custom entry integration
- [x] Update existing compaction tests for new types
## Compaction integration
---
- `CompactionEntry` / `CompactionResult` are generic with optional `details` and `preserveData`; `firstKeptEntryId` is the compaction anchor.
- `session_before_compact` provides `CompactionPreparation`, `branchEntries`, `customInstructions`, and `signal`.
- `session.compacting` allows overriding the compaction prompt/context.
- `session_compact` emits the final `CompactionEntry` and `fromExtension` flag.
## Remaining Work
## Labels
### Compaction Refactor
- `LabelEntry` stores `targetId` + `label`; `labelsById` maps targetId → label.
- `appendLabelChange(targetId, label?)` sets or clears labels.
- Tree selector shows labels and supports the "labeled-only" filter. Press Shift+L in `/tree` to edit the selected label.
- [x] Use `CompactionResult` type for hook return value
- [x] Make `CompactionEntry<T>` generic with optional `details?: T` field for hook-specific data
- [x] Make `CompactionResult<T>` generic to match
- [x] Update `SessionEventBase` to pass `sessionManager` and `modelRegistry` instead of derived fields
- [x] Update `before_compact` event:
- Pass `preparation: CompactionPreparation` instead of individual fields
- Pass `previousCompactions: CompactionEntry[]` (newest first) instead of `previousSummary?: string`
- Keep: `customInstructions`, `model`, `signal`
- Drop: `resolveApiKey` (use `modelRegistry.getApiKey()`), `cutPoint`, `entries`
- [x] Update hook example `custom-compaction.ts` to use new API
- [x] Update `getSessionFile()` to return `string | undefined` for in-memory sessions
- [x] Update `before_switch` to have `targetSessionFile`, `switch` to have `previousSessionFile`
## Custom messages
Reference: [#314](https://github.com/badlogic/pi-mono/pull/314) - Structured compaction with anchored iterative summarization needs `details` field to store `ArtifactIndex` and version markers.
- `CustomMessageEntry` stores `customType`, `content`, `display`, `details`; converted to AgentMessage role `custom`.
- `buildSessionContext()` includes `custom_message` entries; `convertToLlm()` maps them to user-role LLM messages.
- TUI rendering: `display=false` hides the entry; `display=true` uses `customMessageBg`/`customMessageText`/`customMessageLabel` theme tokens.
- Extensions can override rendering via `registerMessageRenderer(customType, renderer)`.
### Branch Summary Design ✅
## Extension API touchpoints
Current type:
```typescript
export interface BranchSummaryEntry extends SessionEntryBase {
type: "branch_summary";
summary: string;
fromId: string; // References the abandoned leaf
fromHook?: boolean; // Whether summary was generated by a hook
details?: unknown; // File tracking: { readFiles, modifiedFiles }
}
```
- `sendMessage(...)` appends a `CustomMessageEntry`. options: `triggerTurn`, `deliverAs` ("steer" | "followUp" | "nextTurn").
- `sendUserMessage(...)` always triggers a turn with a real user message.
- `appendEntry(customType, data)` persists extension state (`CustomEntry`, not sent to the LLM).
- `registerCommand(name, { description?, handler })` registers `/commands`. Handlers return `void`; trigger turns explicitly with `sendMessage`/`sendUserMessage`.
- `ExtensionContext` exposes `sessionManager` (read-only), `modelRegistry`, `model`, `getContextUsage()`, `compact()`, and abort/idle helpers.
- `ExtensionCommandContext` adds `waitForIdle()`, `newSession()`, `branch()`, `navigateTree()`.
- [x] `fromId` field references the abandoned leaf
- [x] `fromHook` field distinguishes omp-generated vs hook-generated summaries
- [x] `details` field for file tracking
- [x] Branch summarizer implemented with structured output format
- [x] Uses serialization approach (same as compaction) to prevent model confusion
- [x] Tests for `branchWithSummary()` flow
## Agent context events
### Entry Labels ✅
- `context`: called before each LLM call with `AgentMessage[]`; returning `{ messages }` replaces the prompt messages for this call (not persisted).
- `before_agent_start`: fired after the user prompt but before the agent loop; event includes `prompt`, `images`, and `systemPrompt`.
- Result can add a `CustomMessage` and/or replace the `systemPrompt` for the turn. Multiple extensions can contribute messages; `systemPrompt` updates chain in order.
- [x] Add `LabelEntry` type with `targetId` and `label` fields
- [x] Add `labelsById: Map<string, string>` private field
- [x] Build labels map in `_buildIndex()` via linear scan
- [x] Add `getLabel(id)` method
- [x] Add `appendLabelChange(targetId, label)` method (undefined clears)
- [x] Update `createBranchedSession()` to filter out LabelEntry and recreate from resolved map
- [x] `buildSessionContext()` already ignores LabelEntry (only handles message types)
- [x] Add `label?: string` to `SessionTreeNode`, populated by `getTree()`
- [x] Display labels in UI (tree-selector shows labels)
- [x] `/label` command (implemented in tree-selector)
## Tree UI + commands
### CustomMessageEntry<T>
- `/tree`: in-place navigation with search, filter modes (default/no-tools/user-only/labeled-only/all), labels, and active-path highlighting.
- `/branch`: creates a new session file from the current path.
- Tree navigation emits `session_before_tree` with `TreePreparation` (`targetId`, `oldLeafId`, `commonAncestorId`, `entriesToSummarize`, `userWantsSummary`) and `session_tree` with `SessionTreeEvent` (`newLeafId`, `oldLeafId`, `summaryEntry?`, `fromExtension?`).
Hook-injected messages that participate in LLM context. Unlike `CustomEntry<T>` (for hook state only), these are sent to the model.
## HTML export
```typescript
export interface CustomMessageEntry<T = unknown> extends SessionEntryBase {
type: "custom_message";
customType: string; // Hook identifier
content: string | (TextContent | ImageContent)[]; // Message content (same as UserMessage)
details?: T; // Hook-specific data for state reconstruction on reload
display: boolean; // Whether to display in TUI
}
```
Behavior:
- [x] Type definition matching plan
- [x] `appendCustomMessageEntry(customType, content, display, details?)` in SessionManager
- [x] `buildSessionContext()` includes custom_message entries as user messages
- [x] Exported from main index
- [x] TUI rendering:
- `display: false` - hidden entirely
- `display: true` - rendered with purple styling (customMessageBg, customMessageText, customMessageLabel theme colors)
- [x] `registerCustomMessageRenderer(customType, renderer)` in HookAPI for custom renderers
- [x] Renderer returns inner Component, TUI wraps in styled Box
### Hook API Changes ✅
**Renamed:**
- `renderCustomMessage()` → `registerCustomMessageRenderer()`
**New: `sendMessage()` ✅**
Replaces `send()`. Always creates CustomMessageEntry, never user messages.
```typescript
type HookMessage<T = unknown> = Pick<CustomMessageEntry<T>, 'customType' | 'content' | 'display' | 'details'>;
sendMessage(message: HookMessage, triggerTurn?: boolean): void;
```
Implementation:
- Uses agent's queue mechanism with `_hookData` marker on AppMessage
- `message_end` handler routes based on marker presence
- `AgentSession.sendHookMessage()` handles three cases:
- Streaming: queues via `agent.steer()` or `agent.followUp()`, loop processes and emits `message_end`
- Not streaming + triggerTurn: direct append + `agent.continue()`
- Not streaming + no trigger: direct append only
- TUI updates via event (streaming) or explicit rebuild (non-streaming)
**New: `appendEntry()` ✅**
For hook state persistence (NOT in LLM context):
```typescript
appendEntry(customType: string, data?: unknown): void;
```
Calls `sessionManager.appendCustomEntry()` directly.
**New: `registerCommand()` (types ✅, wiring TODO)**
```typescript
// HookAPI (the `pi` object) - utilities available to all hooks:
interface HookAPI {
sendMessage(message: HookMessage, triggerTurn?: boolean): void;
appendEntry(customType: string, data?: unknown): void;
registerCommand(name: string, options: RegisteredCommand): void;
registerCustomMessageRenderer(customType: string, renderer: CustomMessageRenderer): void;
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
}
// HookEventContext - passed to event handlers, has stable context:
interface HookEventContext {
ui: HookUIContext;
hasUI: boolean;
cwd: string;
sessionManager: SessionManager;
modelRegistry: ModelRegistry;
}
// Note: exec moved to HookAPI, sessionManager/modelRegistry moved from SessionEventBase
// HookCommandContext - passed to command handlers:
interface HookCommandContext {
args: string; // Everything after /commandname
ui: HookUIContext;
hasUI: boolean;
cwd: string;
sessionManager: SessionManager;
modelRegistry: ModelRegistry;
}
// Note: exec and sendMessage accessed via `pi` closure
registerCommand(name: string, options: {
description?: string;
handler: (ctx: HookCommandContext) => Promise<void>;
}): void;
```
Handler return:
- `void` - command completed (use `sendMessage()` with `triggerTurn: true` to prompt LLM)
Wiring (all in AgentSession.prompt()):
- [x] Add hook commands to autocomplete in interactive-mode
- [x] `_tryExecuteHookCommand()` in AgentSession handles command execution
- [x] Build HookCommandContext with ui (from hookRunner), exec, sessionManager, etc.
- [x] If handler returns string, use as prompt text
- [x] If handler returns undefined, return early (no LLM call)
- [x] Works for all modes (interactive, RPC, print) via shared AgentSession
**New: `ui.custom()` ✅**
For arbitrary hook UI with keyboard focus:
```typescript
interface HookUIContext {
// ... existing: select, confirm, input, notify
/** Show custom component with keyboard focus. Call done() when finished. */
custom(component: Component, done: () => void): void;
}
```
See also: `CustomEntry<T>` for storing hook state that does NOT participate in context.
**New: `context` event ✅**
Fires before messages are sent to the LLM, allowing hooks to modify context non-destructively.
```typescript
interface ContextEvent {
type: "context";
/** Messages that will be sent to the LLM */
messages: Message[];
}
interface ContextEventResult {
/** Modified messages to send instead */
messages?: Message[];
}
// In HookAPI:
on(event: "context", handler: HookHandler<ContextEvent, ContextEventResult | void>): void;
```
Example use case: **Dynamic Context Pruning** ([discussion #330](https://github.com/badlogic/pi-mono/discussions/330))
Non-destructive pruning of tool results to reduce context size:
```typescript
export default function(pi: HookAPI) {
// Register /prune command
pi.registerCommand("prune", {
description: "Mark tool results for pruning",
handler: async (ctx) => {
// Show UI to select which tool results to prune
// Append custom entry recording pruning decisions:
// { toolResultId, strategy: "summary" | "truncate" | "remove" }
pi.appendEntry("tool-result-pruning", { ... });
}
});
// Intercept context before LLM call
pi.on("context", async (event, ctx) => {
// Find all pruning entries in session
const entries = ctx.sessionManager.getEntries();
const pruningRules = entries
.filter(e => e.type === "custom" && e.customType === "tool-result-pruning")
.map(e => e.data);
// Apply pruning rules to messages
const prunedMessages = applyPruning(event.messages, pruningRules);
return { messages: prunedMessages };
});
}
```
Benefits:
- Original tool results stay intact in session
- Pruning is stored as custom entries, survives session reload
- Works with branching (pruning entries are part of the tree)
- Trade-off: cache busting on first submission after pruning
### Investigate: `context` event vs `before_agent_start` ✅
References:
- [#324](https://github.com/badlogic/pi-mono/issues/324) - `before_agent_start` proposal
- [#330](https://github.com/badlogic/pi-mono/discussions/330) - Dynamic Context Pruning (why `context` was added)
**Current `context` event:**
- Fires before each LLM call within the agent loop
- Receives `AgentMessage[]` (deep copy, safe to modify)
- Returns `Message[]` (inconsistent with input type)
- Modifications are transient (not persisted to session)
- No TUI visibility of what was changed
- Use case: non-destructive pruning, dynamic context manipulation
**Type inconsistency:** Event receives `AgentMessage[]` but result returns `Message[]`:
```typescript
interface ContextEvent {
messages: AgentMessage[]; // Input
}
interface ContextEventResult {
messages?: Message[]; // Output - different type!
}
```
Questions:
- [ ] Should input/output both be `Message[]` (LLM format)?
- [ ] Or both be `AgentMessage[]` with conversion happening after?
- [ ] Where does `AgentMessage[]` → `Message[]` conversion currently happen?
**Proposed `before_agent_start` event:**
- Fires once when user submits a prompt, before `agent_start`
- Allows hooks to inject additional content that gets **persisted** to session
- Injected content is visible in TUI (observability)
- Does not bust prompt cache (appended after user message, not modifying system prompt)
**Key difference:**
| Aspect | `context` | `before_agent_start` |
|--------|-----------|---------------------|
| When | Before each LLM call | Once per user prompt |
| Persisted | No | Yes (as SystemMessage) |
| TUI visible | No | Yes (collapsible) |
| Cache impact | Can bust cache | Append-only, cache-safe |
| Use case | Transient manipulation | Persistent context injection |
**Implementation (completed):**
- Reuses `HookMessage` type (no new message type needed)
- Handler returns `{ message: Pick<HookMessage, "customType" | "content" | "display" | "details"> }`
- Message is appended to agent state AND persisted to session before `agent.prompt()` is called
- Renders using existing `HookMessageComponent` (or custom renderer if registered)
- [ ] How does it interact with compaction? (treated like user messages?)
- [ ] Can hook return multiple messages or just one?
**Implementation sketch:**
```typescript
interface BeforeAgentStartEvent {
type: "before_agent_start";
userMessage: UserMessage; // The prompt user just submitted
}
interface BeforeAgentStartResult {
/** Additional context to inject (persisted as SystemMessage) */
inject?: {
label: string; // Shown in collapsed TUI state
content: string | (TextContent | ImageContent)[];
};
}
```
### HTML Export
- [ ] Add collapsible sidebar showing full tree structure
- [ ] Allow selecting any node in tree to view that path
- [ ] Add "reset to session leaf" button
- [ ] Render full path (no compaction resolution needed)
- [ ] Responsive: collapse sidebar on mobile
### UI Commands ✅
- [x] `/branch` - Creates new session file from current path (uses `createBranchedSession()`)
- [x] `/tree` - In-session tree navigation via tree-selector component
- Shows full tree structure with labels
- Navigate between branches (moves leaf pointer)
- Shows current position
- Generates branch summaries when switching branches
### Tree Selector Improvements ✅
- [x] Active line highlight using `selectedBg` theme color
- [x] Filter modes via `^O` (forward) / `Shift+^O` (backward):
- `default`: hides label/custom entries
- `no-tools`: default minus tool results
- `user-only`: just user messages
- `labeled-only`: just labeled entries
- `all`: everything
### Documentation
Review and update all docs:
- [ ] `docs/hooks.md` - Major update for hook API:
- `pi.send()` → `pi.sendMessage()` with new signature
- New `pi.appendEntry()` for state persistence
- New `pi.registerCommand()` for custom slash commands
- New `pi.registerCustomMessageRenderer()` for custom TUI rendering
- `HookCommandContext` interface and handler patterns
- `HookMessage<T>` type
- Updated event signatures (`SessionEventBase`, `before_compact`, etc.)
- [ ] `docs/hooks-v2.md` - Review/merge or remove if obsolete
- [ ] `docs/sdk.md` - Update for:
- `HookMessage` and `isHookMessage()`
- `Agent.prompt(AppMessage)` overload
- Session v2 tree structure
- SessionManager API changes
- [ ] `docs/session.md` - Update for v2 tree structure, new entry types
- [ ] `docs/custom-tools.md` - Check if hook changes affect custom tools
- [ ] `docs/rpc.md` - Check if hook commands work in RPC mode
- [ ] `docs/skills.md` - Review for any hook-related updates
- [ ] `docs/extension-loading.md` - Review
- [x] `docs/theme.md` - Added selectedBg, customMessageBg/Text/Label color tokens (50 total)
- [ ] `README.md` - Update hook examples if any
### Examples
Review and update examples:
- [ ] `examples/hooks/` - Update existing, add new examples:
- [ ] Review `custom-compaction.ts` for new API
- [ ] Add `registerCommand()` example
- [ ] Add `sendMessage()` example
- [ ] Add `registerCustomMessageRenderer()` example
- [ ] `examples/sdk/` - Update for new session/hook APIs
- [ ] `examples/custom-tools/` - Review for compatibility
---
## Before Release
- [ ] Run full automated test suite: `npm test`
- [ ] Manual testing of tree navigation and branch summarization
- [ ] Verify compaction with file tracking works correctly
---
## Notes
- All append methods return the new entry's ID
- Migration rewrites file on first load if version < CURRENT_VERSION
- Existing sessions become linear chains after migration (parentId = previous entry)
- Tree features available immediately after migration
- SessionHeader does NOT have id/parentId (it's metadata, not part of tree)
- Session is append-only: entries cannot be modified or deleted, only branching changes the leaf pointer
- Session HTML export includes a sidebar tree with search, the same filter modes as `/tree`, and a responsive hamburger toggle.
- URL parameters `leafId`/`targetId` allow deep-linking to a branch and specific entry.
+97 -38
View File
@@ -5,10 +5,12 @@ Sessions are stored as JSONL (JSON Lines) files. Each line is a JSON object with
## File Location
```
~/.omp/agent/sessions/--<path>--/<timestamp>_<uuid>.jsonl
~/.omp/agent/sessions/--<cwd>--/<timestamp>_<sessionId>.jsonl
```
Where `<path>` is the working directory with `/` replaced by `-`.
Default base directory comes from `getAgentDir()` (overridable via `OMP_CODING_AGENT_DIR`).
`<cwd>` is the working directory with the leading slash removed and `/`, `\`, `:` replaced by `-`.
`<timestamp>` is ISO-8601 with `:`/`.` replaced by `-`. `sessionId` is a nanoid.
## Session Version
@@ -16,14 +18,16 @@ Sessions have a version field in the header:
- **Version 1**: Linear entry sequence (legacy, auto-migrated on load)
- **Version 2**: Tree structure with `id`/`parentId` linking
- **Version 3**: Renamed legacy `hookMessage` role to `custom`
Existing v1 sessions are automatically migrated to v2 when loaded.
Existing sessions are automatically migrated to the latest version when loaded.
## Type Definitions
- [`src/core/session-manager.ts`](../src/core/session-manager.ts) - Session entry types
- [`packages/agent/src/types.ts`](../../agent/src/types.ts) - `AgentMessage`, `Attachment`, `ThinkingLevel`
- [`packages/ai/src/types.ts`](../../ai/src/types.ts) - `UserMessage`, `AssistantMessage`, `ToolResultMessage`, `Usage`, `ToolCall`
- [`src/session/session-manager.ts`](../src/session/session-manager.ts) - Session entry types and `SessionManager`
- [`src/session/messages.ts`](../src/session/messages.ts) - Custom message roles and LLM conversion
- [`packages/agent/src/types.ts`](../../agent/src/types.ts) - `AgentMessage`, `ThinkingLevel`
- [`packages/ai/src/types.ts`](../../ai/src/types.ts) - `Message`, content blocks, `Usage`, `ToolCall`
## Entry Base
@@ -32,7 +36,7 @@ All entries (except `SessionHeader`) extend `SessionEntryBase`:
```typescript
interface SessionEntryBase {
type: string;
id: string; // 8-char hex ID
id: string; // Short nanoid (8 chars, URL-safe)
parentId: string | null; // Parent entry ID (null for first entry)
timestamp: string; // ISO timestamp
}
@@ -42,28 +46,32 @@ interface SessionEntryBase {
### SessionHeader
First line of the file. Metadata only, not part of the tree (no `id`/`parentId`).
First line of the file. Metadata only, not part of the tree (no `id`/`parentId`). `version` is absent in v1 sessions.
```json
{ "type": "session", "version": 2, "id": "uuid", "timestamp": "2024-12-03T14:00:00.000Z", "cwd": "/path/to/project" }
{ "type": "session", "version": 3, "id": "nanoid", "timestamp": "2024-12-03T14:00:00.000Z", "cwd": "/path/to/project", "title": "Optional title" }
```
For sessions with a parent (created via `/branch` or `newSession({ parentSession })`):
For sessions with a parent (created via `/branch`, `newSession({ parentSession })`, or fork operations):
```json
{
"type": "session",
"version": 2,
"id": "uuid",
"version": 3,
"id": "nanoid",
"timestamp": "2024-12-03T14:00:00.000Z",
"cwd": "/path/to/project",
"parentSession": "/path/to/original/session.jsonl"
}
```
`parentSession` is an opaque string used for lineage tracking (typically a session file path).
### SessionMessageEntry
A message in the conversation. The `message` field contains an `AgentMessage`.
A message in the conversation. The `message` field contains an `AgentMessage`,
including base LLM messages plus coding-agent custom roles (bash/python execution,
custom/legacy `hookMessage` messages from v2 sessions, file mentions, etc.).
```json
{"type":"message","id":"a1b2c3d4","parentId":"prev1234","timestamp":"2024-12-03T14:00:01.000Z","message":{"role":"user","content":"Hello"}}
@@ -73,7 +81,7 @@ A message in the conversation. The `message` field contains an `AgentMessage`.
### ModelChangeEntry
Emitted when the user switches models mid-session.
Emitted when the user switches models mid-session. `model` is stored as `provider/modelId`.
```json
{
@@ -81,8 +89,8 @@ Emitted when the user switches models mid-session.
"id": "d4e5f6g7",
"parentId": "c3d4e5f6",
"timestamp": "2024-12-03T14:05:00.000Z",
"provider": "openai",
"modelId": "gpt-4o"
"model": "openai/gpt-4o",
"role": "default"
}
```
@@ -100,6 +108,8 @@ Emitted when the user changes the thinking/reasoning level.
}
```
`thinkingLevel` matches `ThinkingLevel` from `packages/agent` (e.g., `off`, `minimal`, `low`, `medium`, `high`, `xhigh`).
### CompactionEntry
Created when context is compacted. Stores a summary of earlier messages.
@@ -111,19 +121,23 @@ Created when context is compacted. Stores a summary of earlier messages.
"parentId": "e5f6g7h8",
"timestamp": "2024-12-03T14:10:00.000Z",
"summary": "User discussed X, Y, Z...",
"shortSummary": "Quick recap...",
"firstKeptEntryId": "c3d4e5f6",
"tokensBefore": 50000
"tokensBefore": 50000,
"fromExtension": false
}
```
Optional fields:
- `details`: Compaction-implementation specific data (e.g., file operations for default implementation, or custom data for custom hook implementations)
- `fromHook`: `true` if generated by a hook, `false`/`undefined` if omp-generated
- `details`: Compaction-implementation specific data (extension data, version markers, etc.)
- `shortSummary`: Short-form summary for UI contexts
- `preserveData`: Hook/extension data to persist across compaction
- `fromExtension`: `true` if generated by an extension, `false`/`undefined` if pi-generated
### BranchSummaryEntry
Created when switching branches via `/tree` with an LLM generated summary of the left branch up to the common ancestor. Captures context from the abandoned path.
Created when switching branches with an LLM-generated summary of the abandoned path. Captures context from the previous branch.
```json
{
@@ -136,14 +150,16 @@ Created when switching branches via `/tree` with an LLM generated summary of the
}
```
`fromId` is the branch point entry id; when branching from the root it is `"root"`.
Optional fields:
- `details`: File tracking data (`{ readFiles: string[], modifiedFiles: string[] }`) for default implementation, arbitrary for custom implementation
- `fromHook`: `true` if generated by a hook
- `details`: Extension-specific data (not sent to LLM)
- `fromExtension`: `true` if generated by an extension
### CustomEntry
Hook state persistence. Does NOT participate in LLM context.
Extension state persistence. Does NOT participate in LLM context.
```json
{
@@ -151,16 +167,16 @@ Hook state persistence. Does NOT participate in LLM context.
"id": "h8i9j0k1",
"parentId": "g7h8i9j0",
"timestamp": "2024-12-03T14:20:00.000Z",
"customType": "my-hook",
"customType": "my-extension",
"data": { "count": 42 }
}
```
Use `customType` to identify your hook's entries on reload.
Use `customType` to identify your extension's entries on reload.
### CustomMessageEntry
Hook-injected messages that DO participate in LLM context.
Extension-injected messages that DO participate in LLM context.
```json
{
@@ -168,7 +184,7 @@ Hook-injected messages that DO participate in LLM context.
"id": "i9j0k1l2",
"parentId": "h8i9j0k1",
"timestamp": "2024-12-03T14:25:00.000Z",
"customType": "my-hook",
"customType": "my-extension",
"content": "Injected context...",
"display": true
}
@@ -177,8 +193,8 @@ Hook-injected messages that DO participate in LLM context.
Fields:
- `content`: String or `(TextContent | ImageContent)[]` (same as UserMessage)
- `display`: `true` = show in TUI with purple styling, `false` = hidden
- `details`: Optional hook-specific metadata (not sent to LLM)
- `display`: `true` = show in TUI with distinct styling, `false` = hidden
- `details`: Optional extension-specific metadata (not sent to LLM)
### LabelEntry
@@ -197,6 +213,37 @@ User-defined bookmark/marker on an entry.
Set `label` to `undefined` to clear a label.
### TtsrInjectionEntry
Tracks which time-traveling stream rules were injected during the session.
```json
{
"type": "ttsr_injection",
"id": "k1l2m3n4",
"parentId": "j0k1l2m3",
"timestamp": "2024-12-03T14:31:00.000Z",
"injectedRules": ["rule-a", "rule-b"]
}
```
### SessionInitEntry
Captures initial context for subagent sessions (debugging/replay). Not used in LLM context building.
```json
{
"type": "session_init",
"id": "l2m3n4o5",
"parentId": "k1l2m3n4",
"timestamp": "2024-12-03T14:32:00.000Z",
"systemPrompt": "...",
"task": "Initial task...",
"tools": ["bash", "read"],
"outputSchema": { "type": "object" }
}
```
## Tree Structure
Entries form a tree:
@@ -217,13 +264,15 @@ Entries form a tree:
`buildSessionContext()` walks from the current leaf to the root, producing the message list for the LLM:
1. Collects all entries on the path
2. Extracts current model and thinking level settings
2. Extracts current model map, thinking level, and injected TTSR rules
3. If a `CompactionEntry` is on the path:
- Emits the summary first
- Then messages from `firstKeptEntryId` to compaction
- Then messages after compaction
4. Converts `BranchSummaryEntry` and `CustomMessageEntry` to appropriate message formats
Return value is a `SessionContext` containing `messages`, `models`, `thinkingLevel`, and `injectedTtsrRules`.
## Parsing Example
```typescript
@@ -249,13 +298,19 @@ for (const entry of entries) {
console.log(`[${entry.id}] Custom (${entry.customType}): ${JSON.stringify(entry.data)}`);
break;
case "custom_message":
console.log(`[${entry.id}] Hook message (${entry.customType}): ${entry.content}`);
console.log(`[${entry.id}] Custom message (${entry.customType}): ${entry.content}`);
break;
case "ttsr_injection":
console.log(`[${entry.id}] TTSR rules: ${entry.injectedRules.join(", ")}`);
break;
case "session_init":
console.log(`[${entry.id}] Init: ${entry.tools.join(", ")}`);
break;
case "label":
console.log(`[${entry.id}] Label "${entry.label}" on ${entry.targetId}`);
break;
case "model_change":
console.log(`[${entry.id}] Model: ${entry.provider}/${entry.modelId}`);
console.log(`[${entry.id}] Model: ${entry.model} (${entry.role ?? "default"})`);
break;
case "thinking_level_change":
console.log(`[${entry.id}] Thinking: ${entry.thinkingLevel}`);
@@ -279,22 +334,26 @@ Key methods for working with sessions programmatically:
- `appendMessage(message)` - Add message
- `appendThinkingLevelChange(level)` - Record thinking change
- `appendModelChange(provider, modelId)` - Record model change
- `appendCompaction(summary, firstKeptEntryId, tokensBefore, details?, fromHook?)` - Add compaction
- `appendCustomEntry(customType, data?)` - Hook state (not in context)
- `appendCustomMessageEntry(customType, content, display, details?)` - Hook message (in context)
- `appendModelChange(model, role?)` - Record model change (`model` = `provider/modelId`)
- `appendSessionInit(init)` - Record initial subagent context
- `appendCompaction(summary, shortSummary, firstKeptEntryId, tokensBefore, details?, fromExtension?, preserveData?)`
- `appendCustomEntry(customType, data?)` - Extension state (not in context)
- `appendCustomMessageEntry(customType, content, display, details?)` - Extension message (in context)
- `appendTtsrInjection(ruleNames)` - Record injected TTSR rules
- `appendLabelChange(targetId, label)` - Set/clear label
### Tree Navigation
- `getLeafId()` - Current position
- `getLeafEntry()` - Current leaf entry
- `getEntry(id)` - Get entry by ID
- `getPath(fromId?)` - Walk from entry to root
- `getBranch(fromId?)` - Walk from entry to root
- `getTree()` - Get full tree structure
- `getChildren(parentId)` - Get direct children
- `getLabel(id)` - Get label for entry
- `branch(entryId)` - Move leaf to earlier entry
- `branchWithSummary(entryId, summary, details?, fromHook?)` - Branch with context summary
- `branchWithSummary(entryId | null, summary, details?, fromExtension?)` - Branch with context summary
- `resetLeaf()` - Move leaf to before first entry
### Context
+57 -94
View File
@@ -4,7 +4,7 @@
Skills are self-contained capability packages that the agent loads on-demand. A skill provides specialized workflows, setup instructions, helper scripts, and reference documentation for specific tasks.
OMP implements the [Agent Skills standard](https://agentskills.io/specification).
OMP follows the [Agent Skills](https://agentskills.io/specification) SKILL.md format (YAML frontmatter + markdown body) and exposes skills via `skill://` URLs.
**Example use cases:**
- Web search and content extraction (Brave Search API)
@@ -87,99 +87,77 @@ cd /path/to/skill && npm install
### Frontmatter Fields
Per the [Agent Skills specification](https://agentskills.io/specification#frontmatter-required):
| Field | Required | Notes |
|-------|----------|-------|
| `name` | No | Defaults to the skill directory name. Use lowercase + hyphens for compatibility. |
| `description` | Yes (OMP/custom), recommended everywhere | Used for skill matching and shown in the system prompt. |
| Field | Required | Constraints |
|-------|----------|-------------|
| `name` | Yes | Max 64 chars. Lowercase a-z, 0-9, hyphens only. Must match parent directory name. |
| `description` | Yes | Max 1024 chars. What the skill does and when to use it. |
| `license` | No | License name or reference to bundled license file. |
| `compatibility` | No | Max 500 chars. Environment requirements (system packages, network access, etc.). |
| `metadata` | No | Arbitrary key-value mapping for additional metadata. |
| `allowed-tools` | No | Space-delimited list of pre-approved tools (experimental). |
OMP ignores additional frontmatter fields, but other tooling may use them.
#### Name Validation
#### Naming Guidance
The `name` field must:
- Be 1-64 characters
- Contain only lowercase letters (a-z), numbers (0-9), and hyphens
- Not start or end with a hyphen
- Not contain consecutive hyphens (--)
- Match the parent directory name exactly
Valid: `pdf-processing`, `data-analysis`, `code-review`
Invalid: `PDF-Processing`, `-pdf`, `pdf--processing`
#### Description Best Practices
The `description` is critical. It determines when the agent loads the skill. Be specific about both what it does and when to use it.
Good:
```yaml
description: Extracts text and tables from PDF files, fills PDF forms, and merges multiple PDFs. Use when working with PDF documents or when the user mentions PDFs, forms, or document extraction.
```
Poor:
```yaml
description: Helps with PDFs.
```
OMP does not enforce naming rules, but skill names must match exactly for `skill://<name>` and `/skill:<name>` lookups. For cross-tool compatibility, keep names lowercase, hyphenated, and aligned with the directory name.
### File References
Use relative paths from the skill directory:
Use `skill://` URLs to reference files inside a skill directory:
```markdown
See [the reference guide](references/REFERENCE.md) for details.
Read the full skill:
\`\`\`
skill://my-skill
\`\`\`
Run the extraction script:
\`\`\`bash
./scripts/extract.py input.pdf
Read a reference file:
\`\`\`
skill://my-skill/references/api-reference.md
\`\`\`
```
## Skill Locations
Skills are discovered from these locations (later wins on name collision):
Skills are discovered from these sources (first match wins on name collisions):
1. `~/.codex/skills/**/SKILL.md` (Codex CLI, recursive)
2. `~/.claude/skills/*/SKILL.md` (Claude Code user, one level)
3. `<cwd>/.claude/skills/*/SKILL.md` (Claude Code project, one level)
4. `~/.omp/agent/skills/**/SKILL.md` (OMP user, recursive)
5. `<cwd>/.omp/skills/**/SKILL.md` (OMP project, recursive)
- OMP user: `~/.omp/agent/skills/<skill>/SKILL.md` (legacy alias: `~/.pi/agent/skills/...`)
- OMP project: `<cwd>/.omp/skills/<skill>/SKILL.md` (legacy alias: `<cwd>/.pi/skills/...`)
- Claude Code: `~/.claude/skills/<skill>/SKILL.md` and `<cwd>/.claude/skills/<skill>/SKILL.md`
- Codex CLI: `~/.codex/skills/<skill>/SKILL.md` and `<cwd>/.codex/skills/<skill>/SKILL.md`
- Custom directories from `skills.customDirectories` (scanned recursively)
Discovery skips hidden directories and `node_modules`, and respects `.gitignore`, `.ignore`, and `.fdignore` rules.
## Configuration
Configure skill loading in `~/.omp/agent/settings.json`:
Global settings live in `~/.omp/agent/config.yml` (migrated from legacy `settings.json`). Project overrides live in `<cwd>/.omp/settings.json` or `<cwd>/.pi/settings.json`.
```json
{
"skills": {
"enabled": true,
"enableCodexUser": true,
"enableClaudeUser": true,
"enableClaudeProject": true,
"enablePiUser": true,
"enablePiProject": true,
"customDirectories": ["~/my-skills-repo"],
"ignoredSkills": ["deprecated-skill"],
"includeSkills": ["git-*", "docker"]
}
}
```yaml
skills:
enabled: true
enableSkillCommands: true
enableCodexUser: true
enableClaudeUser: true
enableClaudeProject: true
enablePiUser: true
enablePiProject: true
customDirectories: []
ignoredSkills: []
includeSkills: []
```
| Setting | Default | Description |
|---------|---------|-------------|
| `enabled` | `true` | Master toggle for all skills |
| `enableSkillCommands` | `true` | Register `/skill:<name>` commands in interactive mode |
| `enableCodexUser` | `true` | Load from `~/.codex/skills/` |
| `enableClaudeUser` | `true` | Load from `~/.claude/skills/` |
| `enableClaudeProject` | `true` | Load from `<cwd>/.claude/skills/` |
| `enableOmpUser` | `true` | Load from `~/.omp/agent/skills/` |
| `enableOmpProject` | `true` | Load from `<cwd>/.omp/skills/` |
| `customDirectories` | `[]` | Additional directories to scan (supports `~` expansion) |
| `ignoredSkills` | `[]` | Glob patterns to exclude (e.g., `["deprecated-*", "test-skill"]`) |
| `includeSkills` | `[]` | Glob patterns to include (empty = all; e.g., `["git-*", "docker"]`) |
| `enablePiUser` | `true` | Load from `~/.omp/agent/skills/` (or `~/.pi/agent/skills/`) |
| `enablePiProject` | `true` | Load from `<cwd>/.omp/skills/` (or `<cwd>/.pi/skills/`) |
| `customDirectories` | `[]` | Additional directories to scan recursively (use absolute paths) |
| `ignoredSkills` | `[]` | Glob patterns to exclude (e.g., `"deprecated-*"`) |
| `includeSkills` | `[]` | Glob patterns to include (empty = all) |
**Note:** `ignoredSkills` takes precedence over both `includeSkills` in settings and the `--skills` CLI flag. A skill matching any ignore pattern will be excluded regardless of include patterns.
**Note:** `ignoredSkills` takes precedence over both `includeSkills` and the `--skills` CLI flag.
### CLI Filtering
@@ -200,25 +178,19 @@ This overrides the `includeSkills` setting for the current session.
## How Skills Work
1. At startup, omp scans skill locations and extracts names + descriptions
2. The system prompt includes available skills in XML format
3. When a task matches, the agent uses `read` to load the full SKILL.md
4. The agent follows the instructions, using relative paths to reference scripts/assets
1. At startup, omp scans enabled skill locations and filters them by settings and CLI flags.
2. If the `read` tool is available, skill names + descriptions are injected into the system prompt as XML.
3. When a task matches a skill, the agent loads it with `read skill://<name>` or `skill://<name>/<path>`.
4. When skills are preloaded (e.g., Task tool with explicit skills), their full contents are inlined under `<preloaded_skills>` and no `read` call is needed.
This is progressive disclosure: only descriptions are always in context, full instructions load on-demand.
This is progressive disclosure: only descriptions are always in context, full instructions load on-demand unless explicitly preloaded.
## Validation Warnings
## Warnings
OMP validates skills against the Agent Skills standard and warns (but still loads) non-compliant skills:
OMP emits warnings when:
- Name doesn't match parent directory
- Name exceeds 64 characters
- Name contains invalid characters
- Name starts/ends with hyphen or has consecutive hyphens
- Description missing or exceeds 1024 characters
- Unknown frontmatter fields
Name collisions (same name from different locations) warn and keep the first skill found.
- A skill directory or file cannot be read
- Two skills share the same name (the first loaded wins; later ones are skipped)
## Example: Web Search Skill
@@ -258,12 +230,6 @@ cd /path/to/brave-search && npm install
\`\`\`
```
## Compatibility
**Claude Code**: OMP reads skills from `~/.claude/skills/*/SKILL.md`. The `allowed-tools` and `model` frontmatter fields are ignored.
**Codex CLI**: OMP reads skills from `~/.codex/skills/` recursively. Hidden files/directories and symlinks are skipped.
## Skill Repositories
For inspiration and ready-to-use skills:
@@ -278,13 +244,10 @@ CLI:
omp --no-skills
```
Settings (`~/.omp/agent/settings.json`):
```json
{
"skills": {
"enabled": false
}
}
Settings:
```yaml
skills:
enabled: false
```
Use the granular `enable*` flags to disable individual sources (e.g., `enableClaudeUser: false` to skip `~/.claude/skills`).
+131 -100
View File
@@ -8,7 +8,7 @@ Themes allow you to customize the colors used throughout the coding agent TUI.
Every theme must define all color tokens. There are no optional colors.
### Core UI (10 colors)
### Core UI (11 colors)
| Token | Purpose | Examples |
| -------------- | --------------------- | ------------------------------------ |
@@ -67,7 +67,7 @@ Note: Diff colors are specific to tool execution boxes and must work with tool b
### Syntax Highlighting (9 colors)
Future-proofing for syntax highlighting support:
Used for native syntax highlighting in tool output and editors:
| Token | Purpose |
| ------------------- | -------------------------------- |
@@ -92,17 +92,37 @@ Editor border colors that indicate the current thinking/reasoning level:
| `thinkingLow` | Border for low thinking |
| `thinkingMedium` | Border for medium thinking |
| `thinkingHigh` | Border for high thinking |
| `thinkingXhigh` | Border for xhigh thinking (most prominent, OpenAI codex-max only) |
| `thinkingXhigh` | Border for xhigh thinking (most prominent) |
These create a visual hierarchy: off → minimal → low → medium → high → xhigh
### Bash Mode (1 color)
### Mode Borders (2 colors)
| Token | Purpose |
| ---------- | ------------------------------------------------ |
| `bashMode` | Editor border color when in bash mode (! prefix) |
| Token | Purpose |
| ------------ | ------------------------------------------------ |
| `bashMode` | Editor border color when in bash mode (! prefix) |
| `pythonMode` | Editor border color when in python mode (>>>) |
**Total: 50 color tokens** (all required)
### Status Line (14 colors)
| Token | Purpose |
| ------------------- | --------------------------------------- |
| `statusLineBg` | Status line background |
| `statusLineSep` | Separators between status line segments |
| `statusLineModel` | Model segment text |
| `statusLinePath` | Working directory segment |
| `statusLineGitClean` | Git segment (clean) |
| `statusLineGitDirty` | Git segment (dirty) |
| `statusLineContext` | Context window usage segment |
| `statusLineSpend` | Token input/total segment |
| `statusLineStaged` | Git staged count |
| `statusLineDirty` | Git unstaged count |
| `statusLineUntracked` | Git untracked count |
| `statusLineOutput` | Token output/cache output segment |
| `statusLineCost` | Cost segment |
| `statusLineSubagents` | Subagent count segment |
**Total: 66 color tokens** (all required)
### HTML Export Colors (optional)
@@ -132,7 +152,7 @@ Themes are defined in JSON files with the following structure:
```json
{
"$schema": "https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/theme-schema.json",
"$schema": "https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/modes/theme/theme-schema.json",
"name": "my-theme",
"vars": {
"blue": "#0066cc",
@@ -151,7 +171,7 @@ Themes are defined in JSON files with the following structure:
## Symbols
Themes can also customize specific UI symbols (icons, separators, bullets, etc.). Use `symbols.preset` to set a theme default (overridden by user settings), and `symbols.overrides` to override individual keys.
Themes can also customize specific UI symbols (icons, separators, bullets, etc.). Use `symbols.preset` (`unicode`, `nerd`, `ascii`) to set a theme default (overridden by the `symbolPreset` setting), and `symbols.overrides` to override individual keys.
Example:
@@ -175,12 +195,14 @@ Symbol keys by category:
- Tree: `tree.branch`, `tree.last`, `tree.vertical`, `tree.horizontal`, `tree.hook`
- Boxes (rounded): `boxRound.topLeft`, `boxRound.topRight`, `boxRound.bottomLeft`, `boxRound.bottomRight`, `boxRound.horizontal`, `boxRound.vertical`
- Boxes (sharp): `boxSharp.topLeft`, `boxSharp.topRight`, `boxSharp.bottomLeft`, `boxSharp.bottomRight`, `boxSharp.horizontal`, `boxSharp.vertical`, `boxSharp.cross`, `boxSharp.teeDown`, `boxSharp.teeUp`, `boxSharp.teeRight`, `boxSharp.teeLeft`
- Separators: `sep.powerline`, `sep.powerlineThin`, `sep.powerlineLeft`, `sep.powerlineRight`, `sep.powerlineThinLeft`, `sep.powerlineThinRight`, `sep.dot`, `sep.slash`, `sep.pipe`
- Icons: `icon.model`, `icon.folder`, `icon.file`, `icon.git`, `icon.branch`, `icon.tokens`, `icon.context`, `icon.cost`, `icon.time`, `icon.pi`, `icon.agents`, `icon.cache`, `icon.input`, `icon.output`, `icon.host`, `icon.session`, `icon.package`, `icon.warning`, `icon.rewind`, `icon.auto`, `icon.extensionSkill`, `icon.extensionTool`, `icon.extensionSlashCommand`, `icon.extensionMcp`, `icon.extensionRule`, `icon.extensionHook`, `icon.extensionPrompt`, `icon.extensionContextFile`, `icon.extensionInstruction`
- Separators: `sep.powerline`, `sep.powerlineThin`, `sep.powerlineLeft`, `sep.powerlineRight`, `sep.powerlineThinLeft`, `sep.powerlineThinRight`, `sep.block`, `sep.space`, `sep.asciiLeft`, `sep.asciiRight`, `sep.dot`, `sep.slash`, `sep.pipe`
- Icons: `icon.model`, `icon.plan`, `icon.folder`, `icon.file`, `icon.git`, `icon.branch`, `icon.tokens`, `icon.context`, `icon.cost`, `icon.time`, `icon.pi`, `icon.agents`, `icon.cache`, `icon.input`, `icon.output`, `icon.host`, `icon.session`, `icon.package`, `icon.warning`, `icon.rewind`, `icon.auto`, `icon.extensionSkill`, `icon.extensionTool`, `icon.extensionSlashCommand`, `icon.extensionMcp`, `icon.extensionRule`, `icon.extensionHook`, `icon.extensionPrompt`, `icon.extensionContextFile`, `icon.extensionInstruction`
- Thinking: `thinking.minimal`, `thinking.low`, `thinking.medium`, `thinking.high`, `thinking.xhigh`
- Checkboxes: `checkbox.checked`, `checkbox.unchecked`
- Formatting: `format.bullet`, `format.dash`
- Formatting: `format.bullet`, `format.dash`, `format.bracketLeft`, `format.bracketRight`
- Markdown: `md.quoteBorder`, `md.hrChar`, `md.bullet`
- Language icons: `lang.default`, `lang.typescript`, `lang.javascript`, `lang.python`, `lang.rust`, `lang.go`, `lang.java`, `lang.c`, `lang.cpp`, `lang.csharp`, `lang.ruby`, `lang.php`, `lang.swift`, `lang.kotlin`, `lang.shell`, `lang.html`, `lang.css`, `lang.json`, `lang.yaml`, `lang.markdown`, `lang.sql`, `lang.docker`, `lang.lua`, `lang.text`, `lang.env`, `lang.toml`, `lang.xml`, `lang.ini`, `lang.conf`, `lang.log`, `lang.csv`, `lang.tsv`, `lang.image`, `lang.pdf`, `lang.archive`, `lang.binary`
- Settings tabs: `tab.display`, `tab.agent`, `tab.input`, `tab.tools`, `tab.config`, `tab.services`, `tab.bash`, `tab.lsp`, `tab.ttsr`, `tab.status`
### Color Values
@@ -238,55 +260,47 @@ This is useful for:
## Built-in Themes
OMP comes with two built-in themes:
OMP ships with `dark` (default), `light`, and 90+ curated themes under `src/modes/theme/defaults/`. Examples include:
### `dark` (default)
Optimized for dark terminal backgrounds with bright, saturated colors.
### `light`
Optimized for light terminal backgrounds with darker, muted colors.
- **Dark themes**: `dark-aurora`, `dark-gruvbox`, `dark-nord`, `dark-tokyo-night`, `dark-catppuccin`, `dark-dracula`, `dark-solarized`, `dark-github`, `dark-monokai`, `dark-synthwave`
- **Light themes**: `light-solarized`, `light-gruvbox`, `light-github`, `light-catppuccin`, `light-paper`, `light-dawn`, `light-frost`
- **Neutral/material**: `graphite`, `obsidian`, `onyx`, `titanium`, `marble`, `pearl`, `alabaster`, `anthracite`
## Selecting a Theme
Themes are configured in the settings (accessible via `/settings`):
Themes are configured in the Settings UI (Display → Theme) or via the config CLI:
```json
{
"theme": "dark"
}
```bash
omp config set theme dark
```
Or use the `/theme` command interactively.
On first run, OMP detects your terminal's background and sets a sensible default (`dark` or `light`).
On first run, OMP uses the terminal background reported by `COLORFGBG` and falls back to `dark` if unavailable.
## Custom Themes
### Theme Locations
Custom themes are loaded from `~/.omp/agent/themes/*.json`.
Custom themes are loaded from `~/.omp/agent/themes/*.json` by default, or from `$OMP_CODING_AGENT_DIR/themes` if that environment variable is set.
### Creating a Custom Theme
1. **Create theme directory:**
```bash
mkdir -p ~/.omp/agent/themes
mkdir -p "${OMP_CODING_AGENT_DIR:-~/.omp/agent}/themes"
```
2. **Create theme file:**
```bash
vim ~/.omp/agent/themes/my-theme.json
vim "${OMP_CODING_AGENT_DIR:-~/.omp/agent}/themes/my-theme.json"
```
3. **Define all colors:**
3. **Define all colors (see the schema for the full list; snippet below shows structure):**
```json
{
"$schema": "https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/theme-schema.json",
"$schema": "https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/modes/theme/theme-schema.json",
"name": "my-theme",
"vars": {
"primary": "#00aaff",
@@ -309,7 +323,9 @@ Custom themes are loaded from `~/.omp/agent/themes/*.json`.
"toolPendingBg": "#1e1e2e",
"toolSuccessBg": "#1e2e1e",
"toolErrorBg": "#2e1e1e",
"toolText": "",
"toolTitle": "",
"toolOutput": "",
// ...
"mdHeading": "#ffaa00",
"mdLink": "primary",
@@ -339,14 +355,16 @@ Custom themes are loaded from `~/.omp/agent/themes/*.json`.
"thinkingMinimal": "primary",
"thinkingLow": "#00aaff",
"thinkingMedium": "#00ffff",
"thinkingHigh": "#ff00ff"
"thinkingHigh": "#ff00ff",
"thinkingXhigh": "#ff88ff"
// ... plus bashMode, pythonMode, statusLine* colors
}
}
```
4. **Select your theme:**
- Use `/settings` command and set `"theme": "my-theme"`
- Or use `/theme` command interactively
- Use the Settings UI (Display → Theme)
- Or run `omp config set theme my-theme`
## Tips
@@ -367,7 +385,7 @@ Custom themes are loaded from `~/.omp/agent/themes/*.json`.
### Color Harmony
- Start with a base palette (e.g., Nord, Gruvbox, Tokyo Night)
- Define your palette in `defs`
- Define your palette in `vars`
- Reference colors consistently
### Testing
@@ -444,51 +462,56 @@ Example usage:
### Terminal Compatibility
OMP uses 24-bit RGB colors (`\x1b[38;2;R;G;Bm`). Most modern terminals support this:
OMP prefers 24-bit RGB colors (`\x1b[38;2;R;G;Bm`) and assumes truecolor on modern terminals.
- ✅ iTerm2, Alacritty, Kitty, WezTerm
- ✅ Windows Terminal
- ✅ VS Code integrated terminal
- ✅ Modern GNOME Terminal, Konsole
Color mode detection:
For older terminals with only 256-color support, OMP automatically falls back to the nearest 256-color approximation.
- `COLORTERM=truecolor|24bit` or `WT_SESSION` → truecolor
- `TERM=dumb`, `TERM=linux`, or empty `TERM` → 256-color fallback
- Otherwise → truecolor
To check if your terminal supports truecolor:
If you need to confirm terminal hints:
```bash
echo $COLORTERM # Should output "truecolor" or "24bit"
echo $COLORTERM
```
## Example Themes
See the built-in themes for complete examples:
- [Dark theme](../src/themes/dark.json)
- [Light theme](../src/themes/light.json)
- [Dark theme](../src/modes/theme/dark.json)
- [Light theme](../src/modes/theme/light.json)
- [Defaults library](../src/modes/theme/defaults)
## Schema Validation
Themes are validated on load using [TypeBox](https://github.com/sinclairzx81/typebox) + [Ajv](https://ajv.js.org/).
Themes are validated on load using [TypeBox](https://github.com/sinclairzx81/typebox) and the TypeBox compiler.
Invalid themes will show an error with details about what's wrong:
```
Error loading theme 'my-theme':
- colors.accent: must be string or number
- colors.mdHeading: required property missing
Invalid theme "my-theme":
Missing required color tokens:
- mdHeading
- mdLink
Other errors:
- /colors/accent: Expected union value
```
For editor support, the JSON schema is available at:
```
https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/theme-schema.json
https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/modes/theme/theme-schema.json
```
Add to your theme file for auto-completion and validation:
```json
{
"$schema": "https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/theme-schema.json",
"$schema": "https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/modes/theme/theme-schema.json",
...
}
```
@@ -511,6 +534,30 @@ class Theme {
bold(text: string): string;
italic(text: string): string;
underline(text: string): string;
strikethrough(text: string): string;
inverse(text: string): string;
// Raw ANSI codes (for composing with other formatters)
getFgAnsi(color: ThemeColor): string;
getBgAnsi(color: ThemeBg): string;
// Symbol access
symbol(key: SymbolKey): string;
styledSymbol(key: SymbolKey, color: ThemeColor): string;
getSymbolPreset(): SymbolPreset;
// Category accessors (return grouped symbol objects)
get status(): { success, error, warning, ... };
get nav(): { cursor, selected, expand, collapse, back };
get icon(): { model, folder, file, git, ... };
get boxRound(): { topLeft, topRight, ... };
get boxSharp(): { topLeft, topRight, ... };
get sep(): { powerline, dot, slash, pipe, ... };
get thinking(): { minimal, low, medium, high, xhigh };
get spinnerFrames(): string[];
// Language icon lookup
getLangIcon(lang: string | undefined): string;
}
```
@@ -522,9 +569,16 @@ The active theme is available as a global singleton in `coding-agent`:
// theme.ts
export let theme: Theme;
export function setTheme(name: string) {
theme = loadTheme(name);
}
export async function initTheme(
themeName?: string,
enableWatcher?: boolean,
symbolPreset?: SymbolPreset,
colorBlindMode?: boolean,
): Promise<void>;
export async function setTheme(
name: string,
enableWatcher?: boolean,
): Promise<{ success: boolean; error?: string }>;
// Usage throughout coding-agent
import { theme } from "./theme.js";
@@ -554,51 +608,44 @@ export interface MarkdownTheme {
italic: (text: string) => string;
strikethrough: (text: string) => string;
underline: (text: string) => string;
highlightCode?: (code: string, lang?: string) => string[];
getMermaidImage?: (sourceHash: string) => MermaidImage | null;
symbols: SymbolTheme;
}
```
The `coding-agent` provides themed functions when creating components:
The `coding-agent` bridges the theme to TUI components via exported helpers:
```typescript
// In coding-agent
import { theme } from "./theme.js";
import { Markdown } from "@oh-my-pi/pi-tui";
// Helper to create markdown theme functions
function getMarkdownTheme(): MarkdownTheme {
// Exported helper in theme.ts
export function getMarkdownTheme(): MarkdownTheme {
return {
heading: (text) => theme.fg("mdHeading", text),
link: (text) => theme.fg("mdLink", text),
linkUrl: (text) => theme.fg("mdLinkUrl", text),
code: (text) => theme.fg("mdCode", text),
codeBlock: (text) => theme.fg("mdCodeBlock", text),
codeBlockBorder: (text) => theme.fg("mdCodeBlockBorder", text),
quote: (text) => theme.fg("mdQuote", text),
quoteBorder: (text) => theme.fg("mdQuoteBorder", text),
hr: (text) => theme.fg("mdHr", text),
listBullet: (text) => theme.fg("mdListBullet", text),
// ... all color mappings ...
bold: (text) => theme.bold(text),
italic: (text) => theme.italic(text),
underline: (text) => theme.underline(text),
strikethrough: (text) => chalk.strikethrough(text),
symbols: getSymbolTheme(),
getMermaidImage,
highlightCode: (code, lang) => { /* uses native syntax highlighter */ },
};
}
// Create markdown with theme
const md = new Markdown(text, 1, 1, getMarkdownTheme(), { bgColor: theme.bg("userMessageBg") });
```
This approach:
- Keeps TUI components theme-agnostic (reusable in other projects)
- Maintains type safety via interfaces
- Allows components to have sensible defaults if no theme provided
- Centralizes theme access in `coding-agent`
Similar helpers exist for other TUI components: `getSelectListTheme()`, `getEditorTheme()`, `getSettingsListTheme()`, `getSymbolTheme()`.
**Example usage:**
```typescript
const theme = loadTheme("dark");
await initTheme("dark");
// Apply foreground colors
theme.fg("accent", "Selected");
@@ -620,9 +667,9 @@ const userMsg = theme.bg("userMessageBg", theme.fg("userMessageText", "Hello"));
**Color resolution:**
1. **Detect terminal capabilities:**
- Check `$COLORTERM` env var (`truecolor` or `24bit` → truecolor support)
- Check `$TERM` env var (`*-256color` → 256-color support)
- Fallback to 256-color mode if detection fails
- `COLORTERM=truecolor|24bit` or `WT_SESSION` → truecolor
- `TERM=dumb`, `TERM=linux`, or empty `TERM` → 256-color
- Otherwise → truecolor
2. **Load JSON theme file**
@@ -642,25 +689,9 @@ const userMsg = theme.bg("userMessageBg", theme.fg("userMessageText", "Hello"));
4. **Convert colors to ANSI codes based on terminal capability:**
**Truecolor mode (24-bit):**
- Hex (`"#ff0000"`) → `\x1b[38;2;255;0;0m`
- 256-color (`42`) → `\x1b[38;5;42m` (keep as-is)
- Empty string (`""`) → `\x1b[39m`
**256-color mode:**
- Hex (`"#ff0000"`) → convert to nearest RGB cube color → `\x1b[38;5;196m`
- 256-color (`42`) → `\x1b[38;5;42m` (keep as-is)
- Empty string (`""`) → `\x1b[39m`
**Hex to 256-color conversion:**
```typescript
// Convert RGB to 6x6x6 cube (colors 16-231)
r_index = Math.round((r / 255) * 5);
g_index = Math.round((g / 255) * 5);
b_index = Math.round((b / 255) * 5);
color_index = 16 + 36 * r_index + 6 * g_index + b_index;
```
- Empty string (`""`) → terminal default (foreground/background reset)
- 256-color (`42`) → `\x1b[38;5;42m` / `\x1b[48;5;42m`
- Hex or resolved vars → `Bun.color(value, "ansi-16m" | "ansi-256")`
5. **Cache as `Theme` instance**
+42 -33
View File
@@ -20,10 +20,9 @@ Sessions are stored as trees where each entry has an `id` and `parentId`. The "l
```
├─ user: "Hello, can you help..."
│ └─ assistant: "Of course! I can..."
│ ├─ user: "Let's try approach A..."
│ │ └─ assistant: "For approach A..."
│ │ └─ [compaction: 12k tokens]
│ │ └─ user: "That worked..." ← active
│ ├─ • user: "Let's try approach A..."
│ │ └─ • assistant: "For approach A..."
│ │ └─ • [label-name] user: "That worked..."
│ └─ user: "Actually, approach B..."
│ └─ assistant: "For approach B..."
```
@@ -32,18 +31,25 @@ Sessions are stored as trees where each entry has an `id` and `parentId`. The "l
| Key | Action |
|-----|--------|
| ↑/↓ | Navigate (depth-first order) |
| ↑/↓ | Move selection |
| ←/→ | Page up/down |
| Enter | Select node |
| Escape/Ctrl+C | Cancel |
| Ctrl+U | Toggle: user messages only |
| Ctrl+O | Toggle: show all (including custom/label entries) |
| Escape | Clear search (if active) or cancel |
| Ctrl+C | Cancel |
| Ctrl+O / Shift+Ctrl+O | Cycle filter forward/back |
| Alt+D/T/U/L/A | Set filter: default / no-tools / user-only / labeled-only / all |
| Shift+L | Edit label for selected entry |
| Type | Search (space-separated tokens) |
| Backspace | Remove last search character |
### Display
- Height: half terminal height
- Current leaf marked with `← active`
- Labels shown inline: `[label-name]`
- Default filter hides `label` and `custom` entries (shown in Ctrl+O mode)
- Tree list height: `max(5, floor(terminalHeight / 2))` lines
- Active path marked with a bullet (`•`) before each entry (current leaf is last node on the path)
- Labels shown inline: `[label-name]` before the entry text
- Default filter hides `label`, `custom`, `model_change`, and `thinking_level_change` entries
- Assistant messages with only tool calls are hidden unless they contain errors/aborts (current leaf is always shown)
- `no-tools` filter hides tool result messages
- Children sorted by timestamp (oldest first)
## Selection Behavior
@@ -66,7 +72,11 @@ If user selects the very first message (has no parent):
## Branch Summarization
When switching, user is prompted: "Summarize the branch you're leaving?"
If branch summaries are enabled (`branchSummary.enabled`), the user is prompted:
- No summary
- Summarize
- Summarize with custom prompt (passed as `customInstructions`)
### What Gets Summarized
@@ -79,9 +89,8 @@ A → B → C → D → E → F ← old leaf
Abandoned path: D → E → F (summarized)
Summarization stops at:
1. Common ancestor (always)
2. Compaction node (if encountered first)
Summarization stops at the common ancestor only.
Compaction and branch summary entries are included; tool results are ignored.
### Summary Storage
@@ -91,11 +100,12 @@ Stored as `BranchSummaryEntry`:
interface BranchSummaryEntry {
type: "branch_summary";
id: string;
parentId: string; // New leaf position
parentId: string | null; // New leaf position (null when navigating to root)
timestamp: string;
fromId: string; // Old leaf we abandoned
fromId: string; // Entry the summary is attached to ("root" if null)
summary: string; // LLM-generated summary
details?: unknown; // Optional hook data
fromExtension?: boolean;
}
```
@@ -107,34 +117,33 @@ interface BranchSummaryEntry {
async navigateTree(
targetId: string,
options?: { summarize?: boolean; customInstructions?: string }
): Promise<{ editorText?: string; cancelled: boolean }>
): Promise<{ editorText?: string; cancelled: boolean; aborted?: boolean; summaryEntry?: BranchSummaryEntry }>
```
Flow:
1. Validate target, check no-op (target === current leaf)
2. Find common ancestor between old leaf and target
3. Collect entries to summarize (if requested)
3. Collect entries to summarize (if requested, includes compaction entries)
4. Fire `session_before_tree` event (hook can cancel or provide summary)
5. Run default summarizer if needed
5. Run default summarizer if needed (respects `customInstructions`)
6. Switch leaf via `branch()` or `branchWithSummary()`
7. Update agent: `agent.replaceMessages(sessionManager.buildSessionContext().messages)`
8. Fire `session_tree` event
9. Notify custom tools via session event
10. Return result with `editorText` if user message was selected
8. Fire `session_tree` event (includes `summaryEntry`/`fromExtension` when applicable)
9. Return result with `editorText` if user message was selected
### SessionManager
- `getLeafUuid(): string | null` - Current leaf (null if empty)
- `getLeafId(): string | null` - Current leaf (null if empty)
- `resetLeaf(): void` - Set leaf to null (for root user message navigation)
- `getTree(): SessionTreeNode[]` - Full tree with children sorted by timestamp
- `branch(id)` - Change leaf pointer
- `branchWithSummary(id, summary)` - Change leaf and create summary entry
- `branchWithSummary(id: string | null, summary, details?, fromExtension?)` - Change leaf and create summary entry
### InteractiveMode
`/tree` command shows `TreeSelectorComponent`, then:
1. Prompt for summarization
2. Call `session.navigateTree()`
1. If `branchSummary.enabled`, prompt for summary type (including custom prompt)
2. Call `session.navigateTree()` with `summarize`/`customInstructions`
3. Clear and re-render chat
4. Set editor text if applicable
@@ -154,7 +163,6 @@ interface TreePreparation {
interface SessionBeforeTreeEvent {
type: "session_before_tree";
preparation: TreePreparation;
model: Model;
signal: AbortSignal;
}
@@ -172,7 +180,7 @@ interface SessionTreeEvent {
newLeafId: string | null;
oldLeafId: string | null;
summaryEntry?: BranchSummaryEntry;
fromHook?: boolean;
fromExtension?: boolean;
}
```
@@ -192,6 +200,7 @@ export default function(pi: HookAPI) {
## Error Handling
- Summarization failure: cancels navigation, shows error
- User abort (Escape): cancels navigation
- Hook returns `cancel: true`: cancels navigation silently
- Summarization failure: navigation is cancelled and the caller shows the error
- Escape during summarization: returns `{ cancelled: true, aborted: true }` and the selector reopens
- Hook returns `cancel: true`: navigation is cancelled (caller decides UI)
- Escape in the tree selector clears search first, then cancels if empty
+227 -81
View File
@@ -4,7 +4,7 @@
Hooks and custom tools can render custom TUI components for interactive user interfaces. This page covers the component system and available building blocks.
**Source:** [`@oh-my-pi/pi-tui`](https://github.com/badlogic/pi-mono/tree/main/packages/tui)
**Source:** [`packages/tui`](../../tui)
## Component Interface
@@ -14,15 +14,19 @@ All components implement:
interface Component {
render(width: number): string[];
handleInput?(data: string): void;
invalidate?(): void;
wantsKeyRelease?: boolean;
getCursorPosition?(width: number): { row: number; col: number } | null;
invalidate(): void;
}
```
| Method | Description |
| -------------------- | ------------------------------------------------------------------------------ |
| `render(width)` | Return array of strings (one per line). Each line **must not exceed `width`**. |
| `handleInput?(data)` | Receive keyboard input when component has focus. |
| `invalidate?()` | Clear cached render state. |
| Member | Description |
| ---------------------------- | --------------------------------------------------------------------------------------------------------- |
| `render(width)` | Return array of strings (one per line). Each line **must not exceed `width`**. |
| `handleInput?(data)` | Receive keyboard input when component has focus. |
| `wantsKeyRelease?` | Opt-in to key release events (Kitty protocol). Default is `false` (release events are filtered out). |
| `getCursorPosition?(width)` | Optional cursor position within the rendered output (0-based row/col) for hardware cursor placement. |
| `invalidate()` | Clear cached render state (called when themes change or the component needs a full re-render). |
## Using Components
@@ -30,28 +34,59 @@ interface Component {
```typescript
pi.on("session_start", async (_event, ctx) => {
const handle = ctx.ui.custom(myComponent);
// handle.requestRender() - trigger re-render
// handle.close() - restore normal UI
const result = await ctx.ui.custom((tui, theme, keybindings, done) => {
const component = new MySelector(items);
component.onSelect = (item) => done(item);
component.onCancel = () => done(null);
return component;
});
if (result) {
ctx.ui.notify(`Selected: ${result}`, "info");
}
});
```
**In custom tools** via `pi.ui.custom()`:
**In extensions/custom tools** via `pi.ui.custom()`:
```typescript
async execute(toolCallId, params, onUpdate, ctx, signal) {
const handle = pi.ui.custom(myComponent);
// ...
handle.close();
const result = await pi.ui.custom((tui, theme, keybindings, done) => {
const component = new MyComponent(theme);
component.onFinish = (value) => done(value);
return component;
});
return { content: [{ type: "text", text: `Result: ${result}` }] };
}
```
The factory receives `tui`, `theme`, `keybindings`, and a `done()` callback. Call `done(value)` to close the component and
resolve the promise with `value`.
(timers, watchers), implement `dispose()`; it is called when `done()` closes the UI. For floating modals, call
`tui.showOverlay(component, options)` inside the factory.
The factory receives `tui`, `theme`, and a `done()` callback. Call `done(value)` to close the component and resolve the promise with `value`.
## Built-in Components
Import from `@oh-my-pi/pi-tui`:
```typescript
import { Text, Box, Container, Spacer, Markdown } from "@oh-my-pi/pi-tui";
import {
Box,
CancellableLoader,
Container,
Editor,
Image,
Input,
Loader,
Markdown,
SelectList,
SettingsList,
Spacer,
TabBar,
Text,
TruncatedText,
} from "@oh-my-pi/pi-tui";
```
### Text
@@ -66,6 +101,15 @@ const text = new Text(
(s) => bgGray(s) // optional background function
);
text.setText("Updated");
text.setCustomBgFn((s) => bgBlue(s));
```
### TruncatedText
Single-line text truncated to fit the viewport width.
```typescript
const truncated = new TruncatedText("Long status line...", 0, 0);
```
### Box
@@ -91,6 +135,7 @@ const container = new Container();
container.addChild(component1);
container.addChild(component2);
container.removeChild(component1);
container.clear();
```
### Spacer
@@ -99,6 +144,30 @@ Empty vertical space.
```typescript
const spacer = new Spacer(2); // 2 empty lines
spacer.setLines(3);
```
### Input
Single-line input with editor-style keybindings.
```typescript
const input = new Input();
input.onSubmit = (value) => {
// ...
};
input.setValue("Prefill");
```
### Editor
Multi-line editor with autocomplete and paste handling. Provide an `EditorTheme`.
```typescript
const editor = new Editor(editorTheme);
editor.onSubmit = (value) => {
// ...
};
```
### Markdown
@@ -106,15 +175,73 @@ const spacer = new Spacer(2); // 2 empty lines
Renders markdown with syntax highlighting.
```typescript
import { getMarkdownTheme } from "@oh-my-pi/pi-coding-agent";
const md = new Markdown(
"# Title\n\nSome **bold** text",
1, // paddingX
1, // paddingY
theme // MarkdownTheme (see below)
0, // paddingY
getMarkdownTheme(),
defaultTextStyle, // optional DefaultTextStyle
2 // codeBlockIndent (default: 2)
);
md.setText("Updated markdown");
```
### Loader
Spinner component that auto-renders.
```typescript
const loader = new Loader(tui, theme.fg("accent"), theme.fg("muted"), "Working...");
```
### CancellableLoader
Loader with `AbortSignal` and Escape-to-cancel.
```typescript
const loader = new CancellableLoader(tui, theme.fg("accent"), theme.fg("muted"), "Working...");
loader.onAbort = () => {
// ...
};
```
### SelectList
Interactive list with selection support.
```typescript
import { getSelectListTheme } from "@oh-my-pi/pi-coding-agent";
const list = new SelectList(items, getSelectListTheme());
list.onSelect = (item) => {
// ...
};
```
### SettingsList
Settings list with labels, values, and hints.
```typescript
import { getSettingsListTheme } from "@oh-my-pi/pi-coding-agent";
const settings = new SettingsList(items, getSettingsListTheme());
```
### TabBar
Horizontal tab switcher.
```typescript
const tabs = [
{ id: "one", label: "One" },
{ id: "two", label: "Two" },
];
const tabBar = new TabBar("Mode", tabs, tabTheme); // TabBarTheme
```
### Image
Renders images in supported terminals (Kitty, iTerm2, Ghostty, WezTerm).
@@ -123,44 +250,66 @@ Renders images in supported terminals (Kitty, iTerm2, Ghostty, WezTerm).
const image = new Image(
base64Data, // base64-encoded image
"image/png", // MIME type
theme, // ImageTheme
{ maxWidthCells: 80, maxHeightCells: 24 }
{ fallbackColor: (text) => theme.fg("muted", text) },
{ maxWidthCells: 80, maxHeightCells: 24 }, // ImageOptions
dimensions // optional: { widthPx, heightPx }
);
```
## Keyboard Input
Use key detection helpers:
Use `matchesKey()` for key detection:
```typescript
import {
isEnter, isEscape, isTab,
isArrowUp, isArrowDown, isArrowLeft, isArrowRight,
isCtrlC, isCtrlO, isBackspace, isDelete,
// ... and more
} from "@oh-my-pi/pi-tui";
import { isKeyRelease, isKeyRepeat, matchesKey, parseKey } from "@oh-my-pi/pi-tui";
handleInput(data: string) {
if (isArrowUp(data)) {
this.selectedIndex--;
} else if (isEnter(data)) {
this.onSelect?.(this.selectedIndex);
} else if (isEscape(data)) {
this.onCancel?.();
}
if (matchesKey(data, "up")) {
this.selectedIndex--;
} else if (matchesKey(data, "enter")) {
this.onSelect?.(this.selectedIndex);
} else if (matchesKey(data, "escape")) {
this.onCancel?.();
} else if (matchesKey(data, "ctrl+c")) {
this.onCancel?.();
}
const parsed = parseKey(data);
if (parsed && parsed.startsWith("alt+")) {
// ...
}
}
```
## Line Width
**Critical:** Each line from `render()` must not exceed the `width` parameter. Width calculations and wrapping follow Bun’s built-ins (`Bun.stringWidth`, `Bun.wrapAnsi`).
To honor coding-agent keybindings, use the `keybindings` argument from `ctx.ui.custom()`:
```typescript
import { visibleWidth, truncateToWidth } from "@oh-my-pi/pi-tui";
if (keybindings.matches(data, "interrupt")) {
this.onCancel?.();
}
```
To receive key release/repeat events, set `wantsKeyRelease = true` on your component and
filter with `isKeyRelease()` / `isKeyRepeat()`.
Supported key identifiers:
- **Letters**: `"a"` through `"z"`
- **Specials**: `"escape"`, `"enter"`, `"tab"`, `"space"`, `"backspace"`, `"delete"`, `"home"`, `"end"`, `"pageUp"`, `"pageDown"`
- **Arrows**: `"up"`, `"down"`, `"left"`, `"right"`
- **Function keys**: `"f1"` through `"f12"`
- **Modifiers**: `"ctrl+c"`, `"shift+tab"`, `"alt+enter"`, `"ctrl+shift+p"`
## Line Width
**Critical:** Each line from `render()` must not exceed the `width` parameter. Use these utilities:
```typescript
import { visibleWidth, truncateToWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
render(width: number): string[] {
// Truncate long lines
return [truncateToWidth(this.text, width)];
// Truncate long lines
return [truncateToWidth(this.text, width)];
}
```
@@ -168,16 +317,17 @@ Utilities:
- `visibleWidth(str)` - Get display width (ANSI-safe, Unicode-width aware)
- `truncateToWidth(str, width, ellipsis?)` - Truncate with optional ellipsis
- `wrapTextWithAnsi(str, width)` - Word wrap preserving ANSI codes (Bun.wrapAnsi)
- `wrapTextWithAnsi(str, width)` - Word wrap preserving ANSI codes
## Creating Custom Components
Example: Interactive selector
```typescript
import { isEnter, isEscape, isArrowUp, isArrowDown, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
import { matchesKey, truncateToWidth } from "@oh-my-pi/pi-tui";
import type { Component } from "@oh-my-pi/pi-tui";
class MySelector {
class MySelector implements Component {
private items: string[];
private selected = 0;
private cachedWidth?: number;
@@ -191,15 +341,15 @@ class MySelector {
}
handleInput(data: string): void {
if (isArrowUp(data) && this.selected > 0) {
if (matchesKey(data, "up") && this.selected > 0) {
this.selected--;
this.invalidate();
} else if (isArrowDown(data) && this.selected < this.items.length - 1) {
} else if (matchesKey(data, "down") && this.selected < this.items.length - 1) {
this.selected++;
this.invalidate();
} else if (isEnter(data)) {
} else if (matchesKey(data, "enter")) {
this.onSelect?.(this.items[this.selected]);
} else if (isEscape(data)) {
} else if (matchesKey(data, "escape")) {
this.onCancel?.();
}
}
@@ -231,22 +381,17 @@ pi.registerCommand("pick", {
description: "Pick an item",
handler: async (args, ctx) => {
const items = ["Option A", "Option B", "Option C"];
const selector = new MySelector(items);
let handle: { close: () => void; requestRender: () => void };
await new Promise<void>((resolve) => {
selector.onSelect = (item) => {
ctx.ui.notify(`Selected: ${item}`, "info");
handle.close();
resolve();
};
selector.onCancel = () => {
handle.close();
resolve();
};
handle = ctx.ui.custom(selector);
const selected = await ctx.ui.custom((tui, theme, done) => {
const selector = new MySelector(items);
selector.onSelect = (item) => done(item);
selector.onCancel = () => done(null);
return selector;
});
if (selected) {
ctx.ui.notify(`Selected: ${selected}`, "info");
}
},
});
```
@@ -259,32 +404,33 @@ Components accept theme objects for styling.
```typescript
renderResult(result, options, theme) {
// Use theme.fg() for foreground colors
return new Text(theme.fg("success", "Done!"), 0, 0);
// Use theme.fg() for foreground colors
return new Text(theme.fg("success", "Done!"), 0, 0);
// Use theme.bg() for background colors
const styled = theme.bg("toolPendingBg", theme.fg("accent", "text"));
// Use theme.bg() for background colors
const styled = theme.bg("toolPendingBg", theme.fg("accent", "text"));
}
```
**Foreground colors** (`theme.fg(color, text)`):
| Category | Colors |
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| General | `text`, `accent`, `muted`, `dim` |
| Status | `success`, `error`, `warning` |
| Borders | `border`, `borderAccent`, `borderMuted` |
| Messages | `userMessageText`, `customMessageText`, `customMessageLabel` |
| Tools | `toolTitle`, `toolOutput` |
| Diffs | `toolDiffAdded`, `toolDiffRemoved`, `toolDiffContext` |
| Markdown | `mdHeading`, `mdLink`, `mdLinkUrl`, `mdCode`, `mdCodeBlock`, `mdCodeBlockBorder`, `mdQuote`, `mdQuoteBorder`, `mdHr`, `mdListBullet` |
| Syntax | `syntaxComment`, `syntaxKeyword`, `syntaxFunction`, `syntaxVariable`, `syntaxString`, `syntaxNumber`, `syntaxType`, `syntaxOperator`, `syntaxPunctuation` |
| Thinking | `thinkingOff`, `thinkingMinimal`, `thinkingLow`, `thinkingMedium`, `thinkingHigh`, `thinkingXhigh` |
| Modes | `bashMode` |
| Category | Colors |
| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| General | `text`, `accent`, `muted`, `dim` |
| Status | `success`, `error`, `warning` |
| Borders | `border`, `borderAccent`, `borderMuted` |
| Messages | `userMessageText`, `thinkingText`, `customMessageText`, `customMessageLabel` |
| Tools | `toolTitle`, `toolOutput` |
| Diffs | `toolDiffAdded`, `toolDiffRemoved`, `toolDiffContext` |
| Markdown | `mdHeading`, `mdLink`, `mdLinkUrl`, `mdCode`, `mdCodeBlock`, `mdCodeBlockBorder`, `mdQuote`, `mdQuoteBorder`, `mdHr`, `mdListBullet` |
| Syntax | `syntaxComment`, `syntaxKeyword`, `syntaxFunction`, `syntaxVariable`, `syntaxString`, `syntaxNumber`, `syntaxType`, `syntaxOperator`, `syntaxPunctuation` |
| Thinking | `thinkingOff`, `thinkingMinimal`, `thinkingLow`, `thinkingMedium`, `thinkingHigh`, `thinkingXhigh` |
| Modes | `bashMode`, `pythonMode` |
| Status bar | `statusLineSep`, `statusLineModel`, `statusLinePath`, `statusLineGitClean`, `statusLineGitDirty`, `statusLineContext`, `statusLineSpend`, etc. |
**Background colors** (`theme.bg(color, text)`):
`selectedBg`, `userMessageBg`, `customMessageBg`, `toolPendingBg`, `toolSuccessBg`, `toolErrorBg`
`selectedBg`, `userMessageBg`, `customMessageBg`, `toolPendingBg`, `toolSuccessBg`, `toolErrorBg`, `statusLineBg`
**For Markdown**, use `getMarkdownTheme()`:
@@ -293,8 +439,8 @@ import { getMarkdownTheme } from "@oh-my-pi/pi-coding-agent";
import { Markdown } from "@oh-my-pi/pi-tui";
renderResult(result, options, theme) {
const mdTheme = getMarkdownTheme();
return new Markdown(result.details.markdown, 0, 0, mdTheme);
const mdTheme = getMarkdownTheme();
return new Markdown(result.details.markdown, 0, 0, mdTheme);
}
```
@@ -312,7 +458,7 @@ interface MyTheme {
Cache rendered output when possible:
```typescript
class CachedComponent {
class CachedComponent implements Component {
private cachedWidth?: number;
private cachedLines?: string[];
@@ -333,7 +479,7 @@ class CachedComponent {
}
```
Call `invalidate()` when state changes, then `handle.requestRender()` to trigger re-render.
Call `invalidate()` when state changes. The TUI will re-render automatically when keyboard input is received.
## Examples
+1 -7
View File
@@ -35,13 +35,7 @@ import { AgentOutputManager } from "./output-manager";
import { mapWithConcurrencyLimit } from "./parallel";
import { renderCall, renderResult } from "./render";
import { renderTemplate } from "./template";
import {
type AgentProgress,
type SingleResult,
type TaskParams,
type TaskToolDetails,
taskSchema,
} from "./types";
import { type AgentProgress, type SingleResult, type TaskParams, type TaskToolDetails, taskSchema } from "./types";
import {
applyBaseline,
captureBaseline,