A TypeScript coding-agent CLI on top of the pi-mono runtime
(@earendil-works/pi-agent-core + @earendil-works/pi-ai). We layer the
TUI, tools, slash commands, permissions, OAuth, sessions, and the headless
/ JSON-RPC entry points; pi-mono owns the agent loop and provider
protocols.
Goals: simple architecture, any LLM provider, single npm i -g install,
designed to fit cleanly in a terminal session.
For deep context, read .settings/ first — tenets.md,
architecture.md, extending.md, testing.md. Those are the canonical
docs; this file is the quick reference.
Language: TypeScript (ESM), Node ≥ 20
TUI: ink (React for terminals)
Schema: typebox (runtime validation of tool args)
Tests: vitest 2.x
Lint/format: biome 2.x
Build: tsc → dist/, no bundler
Binary name: codebase
Config dir: ~/.codebase/
Sessions: ~/.codebase/sessions/<cwd-hash>/
npm run build # tsc + chmod
npm run check # typecheck + lint + tests (the pre-publish gate)
node dist/cli.js # interactive TUI in current dir
node dist/cli.js --new # skip auto-resume of prior session
node dist/cli.js run "<prompt>" # one-shot headless
node dist/cli.js run --output json "<prompt>"
node dist/cli.js auth login # OAuth via codebase.foundation
node dist/cli.js app-server # JSON-RPC over stdio (for IDE extensions)npm test # all unit tests (576+)
npm test -- -t "name fragment" # filter
npm run test:watch
npm run bench:micro # vitest microbenchmarks (*.bench.ts)
npm run bench # end-to-end LLM bench (needs API key)User input → ChatApp (src/ui/App.tsx)
→ /cmd → CommandRegistry (src/commands/)
→ !cmd → runShellEscape (src/ui/shell-escape.ts)
→ @path → collectAttachments → augmented prompt
→ router (src/agent/router.ts) → chat | plan | agent
→ bundle.agent.prompt(text) # pi-agent-core owns the loop
→ AgentEvents stream over bundle.subscribe(...)
→ useCoalescedAgentEvents flushes at 16ms (60fps cap)
→ reducer (src/agent/events.ts) → ChatState → React render
Full src layout, data flow, and component responsibilities live in
.settings/architecture.md.
Provided per-provider via pi-ai's resolver. The first-run wizard detects what's set and offers it. Common ones:
OPENAI_API_KEY OpenAI (also catches any OAI-compatible endpoint)
OPENAI_BASE_URL override endpoint for OAI-compatible providers
ANTHROPIC_API_KEY Anthropic
GROQ_API_KEY Groq
OPENROUTER_API_KEY OpenRouter
OAuth users sign in via codebase auth login; tokens land in
~/.codebase/credentials.json and process.env is not required.
TAVILY_API_KEY Tavily (recommended)
BRAVE_API_KEY Brave Search
SEARXNG_URL self-hosted SearXNG instance
CODEBASE_FRESH=1 skip auto-resume of prior session (same as --new)
CODEBASE_REACT_TUI=1 legacy ink renderer (pi-tui is the default)
CODEBASE_NO_SUGGESTIONS=1 disable ghost-text prompt suggestions
CODEBASE_NO_NOTIFY=1 silence the turn-completion bell + OS notification
CODEBASE_NO_AUTO_MEMORY=1 disable background memory extraction
CODEBASE_DEBUG=1 verbose stderr logging
CODEBASE_DEBUG_INPUT=1 log every keystroke to ~/.codebase/logs/input.log
NO_HYPERLINK=1 disable OSC 8 clickable file paths
The default UI is pi-tui (differential renderer): Ctrl-O copy-mode
(clean OSC-52 copy of code/present_copy boxes), Ctrl-V image paste,
Ctrl-R history search, ghost suggestions, dynamic terminal title. New
slash commands: /effort <off…xhigh> (reasoning level), /permissions
(view/edit allow-deny rules), /resume (multi-session picker),
/export, /doctor, /skills, /agents.
Input prefixes: /cmd slash command, !cmd shell escape, @path file
attach, #note quick-add memory (#feedback: … to pick the bucket).
MCP servers contribute tools, resources (read_mcp_resource), and
prompts (/mcp__<server>__<name> commands).
By default the agent has three soft guards. Each one has an opt-out
env var, and --unrestricted (alias --yolo) sets all three:
CODEBASE_NO_PROJECT_ROOT=1 file/shell tools can read/write/cd anywhere
the running user can. Default: clamped to cwd.
CODEBASE_NO_VALIDATOR=1 shell tool skips the rm -rf / dd / fork-bomb
hard blocks. Default: those patterns refuse.
CODEBASE_NO_READ_BEFORE_WRITE=1 write_file / edit_file proceed even when the
model never read the file in this turn.
Default: refused with FileNotReadFirstError.
When ANY of these are set, the CLI prints a yellow banner at session start enumerating which restrictions are off — so you don't run unrestricted by accident. Philosophy: defaults are conservative so new users can't accidentally trash their machine; opt-outs let power users tell us "I trust this agent on this box, get out of the way."
CODEBASE_CLIENT_ID override OAuth client id
CODEBASE_SCOPES override requested scopes
.env and .env.local in the cwd are auto-loaded at startup
(src/dotenv/loader.ts).
- ESM imports with
.jsextensions (TS NodeNext convention). import type { ... }for type-only — biome enforces this.- Tabs for indentation (biome config). Single quotes off (double quotes).
- Errors thrown as
Errorsubclasses with descriptive names (ConfigError,PermissionDeniedError,UserQueryCancelled, etc.). - Default to no comments. Add one only when why is non-obvious.
- No multi-paragraph JSDoc blocks. One short line max.
- One responsibility per file. If a file passes ~400 lines, look for
a seam — see how
Message.tsxandApp.tsxwere split. - Tests colocated as
*.test.ts/*.test.tsxnext to source. - Benchmarks colocated as
*.bench.tsnext to source. - Component files use PascalCase (
Message.tsx); modules use kebab-case (tool-call-line.tsx,diff-summary.tsx).
- Don't
as unknown asto smuggle a field through an interface. Fix the interface. - Don't add a feature flag for backwards-compat of code you're rewriting in the same PR. Just change it.
- Don't add a runtime dependency for something stdlib + the existing deps can do.
- Don't reach into pi-agent-core / pi-ai internals. Public API only; if it's not enough, fix it upstream.
- Don't commit aspirational documentation. The code is the spec; docs follow the code.
A few load-bearing decisions you should know going in:
- One
Toolinterface for everything (~45 implementations insrc/tools/). Adding a tool is mechanical: declare effects, validate args with TypeBox, export. No per-tool snowflakes. - Effect-based permissions (
reads_fs,writes_fs,runs_shell,network). Policies match on effects, not tool names. ~600 LOC total. - Single immutable
ChatStatedriven by a typed reducer insrc/agent/events.ts. Every UI state transition flows through it; the reducer is tested exhaustively inevents.test.ts. - One proactive compaction strategy with a calibrated token budget.
See
src/compaction/. - Plain
npm i -gdistribution.tsconly, no bundler.
Instruction files layer (lowest → highest specificity, all included):
- User — first of
~/.codebase/AGENTS.md,~/.codebase/CLAUDE.md - Project — first of
AGENTS.md/CLAUDE.md/CODEX.md/.cursorrulesat the cwd root - Rules — every
.mdin<cwd>/.codebase/rules/, sorted by name - Local — first of
AGENTS.local.md/CLAUDE.local.md(personal, gitignored overrides)
Markdown layers support @path imports — @./docs/api.md,
@~/notes/style.md, @/abs/path.md tokens inline the referenced file
(recursive, cycle-safe, ignored inside fenced code blocks and when the
path doesn't resolve to a readable text file).
Markdown files in ~/.codebase/skills/ (user) or
<cwd>/.codebase/skills/ (project, wins on id clash) register as
/<id> commands:
---
description: Refactor the named file for performance.
---
Profile and optimize $ARGUMENTS. Measure before and after.$ARGUMENTS is replaced with whatever follows the command; without the
placeholder, args are appended. Built-in commands always win on name
collisions. /skills lists what's loaded.
Every write_file / edit_file / multi_edit / notebook_edit
snapshots the target's exact prior bytes before mutating (subagent edits
included). Two ways to roll back:
/rewind(no args, pi-tui) opens an interactive picker of prior user prompts; choosing one rewinds the conversation to before that prompt AND restores every file edited since to match — the turns un-happen. On UIs without the picker it falls back to the file list./rewind <n>restores every file touched at-or-after checkpoint #n (overwrites get their old content back, created files are deleted) and leaves the conversation untouched — precision file-only undo.
Pre-image blobs live under ~/.codebase/checkpoints/<run>/, removed on
exit.
dispatch_agent takes agent_type:
- explore (default) — read-only investigator
- general — can edit files, run shell, commit
- custom — markdown in
~/.codebase/agents/*.mdor<cwd>/.codebase/agents/*.md(project wins; builtins not overridable):
---
description: Reviews changed code for security issues.
tools: read_file, grep, glob, git_diff
model: claude-haiku-4-5-20251001 # optional, same provider/proxy
effort: high # optional reasoning level
max_turns: 40 # optional default cap (per-call wins)
---
You are a security reviewer. Cite file:line for every finding.model / effort / max_turns are optional per-agent overrides.
model swaps the id while reusing the parent's provider, proxy, and
key (cross-provider is out of scope — it would need its own credential).
Subagent tool calls go through the same plan-mode gate, permission
prompts, and hooks as the main loop. isolation: "worktree" gives a
subagent its own git worktree (auto-removed if left clean, kept and
reported otherwise) so parallel writers can't collide. /agents lists
the available types.
/tournament [n] <task> (default n=3, max 5) races N agents on the same
build task and lets you merge the winner. --models opus,sonnet,haiku <task> instead runs one contestant per model id (same provider/proxy as
the parent, via per-agent model id-cloning) — race models head-to-head on
your actual codebase. Called mid-build: it snapshots
the working tree — tracked + untracked, via a scratch index so your
real index is untouched (src/agent/wip-snapshot.ts) — and branches
every contestant from that, so in-progress work is preserved. Each
contestant is a general agent in its own worktree, runs to completion +
verifies, and its diff is scored by a cheap-model judge
(src/agent/tournament.ts). A results overlay shows the ranking with the
judge's pick preselected; you choose (overriding the judge if you
want). The winner's diff is applied to your tree (git apply), the
losers' worktrees are discarded, and a hidden note tells the agent what
landed. pi-tui only; the core is provider-agnostic and unit-tested with
injected runner + judge.
Reshape how the agent writes its answers (terse / explanatory /
report-mode / …) without touching the base prompt. Markdown files in
~/.codebase/output-styles/<name>.md (user) or
<cwd>/.codebase/output-styles/<name>.md (project, wins on id clash):
---
name: Terse
description: One-liners, no preamble.
---
Answer in as few words as possible. Skip restating the question./output-style lists them, /output-style <id> activates one (the
body is appended to the system prompt and the agent rebuilds in place),
/output-style off clears it. The choice persists in
~/.codebase/config.json. Same frontmatter parser as skills
(src/config/frontmatter.ts).
Runtime:
@earendil-works/pi-agent-core,@earendil-works/pi-ai— agent loop + protocol adaptersink+react— TUItypebox— runtime schema validation for tool argsdiff— LCS-paired diffs for theedit_file/multi_editsummaryglob,ignore— file matching for tools
Dev:
vitest,@biomejs/biome,typescript,tsx,shx,@types/*
Don't add a dep without a real second use case in mind. The stdlib + the deps above can do almost everything.
User-configurable shell commands that fire on agent lifecycle events.
Loaded from ~/.codebase/hooks.json (user) and ./.codebase/hooks.json
(project, merged after user). Each hook gets the event context as JSON
on stdin so shell hooks can jq whatever fields they care about.
{
"hooks": [
{
"event": "PreToolUse",
"matcher": "edit_file|write_file:src/**",
"command": "scripts/lint-staged.sh",
"timeout": 15000,
"async": false
}
]
}PreToolUse before any tool runs — exit 2 to block the call
PostToolUse after any tool returns — non-blocking observer
PostToolUseFailure after a tool returns an error — payload includes
.toolError; fires in addition to PostToolUse
PostEdit after a write_file / edit_file / multi_edit /
notebook_edit succeeds — formatter / linter / commit
hooks live here (skipped when the write failed)
UserPromptSubmit before a user-initiated prompt reaches the agent —
exit 2 to refuse the submit (e.g. block secrets)
SessionStart once per agent boot
SessionEnd once as the CLI shuts down — payload includes
.endReason; awaited so blocking cleanup hooks run
Stop after the agent settles a turn — payload includes
the final assistant text in .finalMessage
PreCompact before the compaction engine runs
PostCompact after compaction — payload includes
.collapsedMessageCount and .truncatedTokens
SubagentStart before dispatch_agent spawns a subagent
SubagentStop after the subagent run completes
undefinedor empty — match every event of that type"tool"— exact tool name"toolA|toolB"— either tool"tool:pathGlob"— tool name AND file path matches the glob"*:pathGlob"— any tool whose file path matches
Globs use * (no separator) and ** (with separators), gitignore-style.
- Default (
async: false): the agent waits for the hook to exit before continuing. Exit code 2 blocks the action and the hook's stderr is surfaced to the model so it can self-correct. async: true: fire-and-forget. The agent doesn't wait, and a non-zero exit is invisible unlessCODEBASE_DEBUG=1is set.
timeout is milliseconds; default 30000. After the timeout we send
SIGTERM and treat the hook as failed (exit code 1, "hook timed out"
in stderr). Blocking hooks that time out do NOT block the action by
default — only an actual exit-2 blocks.
{
event: HookEvent,
workingDir: string, // cwd the agent is running in
toolName?: string, // tool events
toolArgs?: unknown, // tool events
filePath?: string, // tool events that operate on a file
toolError?: string, // PostToolUseFailure
userPrompt?: string, // UserPromptSubmit
finalMessage?: string, // Stop
endReason?: string, // SessionEnd
messageCount?: number, // Pre/PostCompact
collapsedMessageCount?: number,// PostCompact
truncatedTokens?: number, // PostCompact
subagentType?: string, // Subagent events
subagentPrompt?: string, // SubagentStart
subagentSuccess?: boolean // SubagentStop
}The agent can run shell commands on remote machines via the ssh_exec
tool. The host list is an allowlist managed by the user — the agent
picks a name from the enrolled set, not arbitrary user@host strings.
# Generate a key (default Ed25519, --rsa for RSA-4096 if your
# compliance / legacy infra requires it). Passphrase-less by default
# because the agent runs non-interactively.
codebase ssh keygen staging
# Print the pubkey + a one-liner to install it on the remote.
# Register the host:
codebase ssh add staging staging.example.com --user deploy --key ~/.codebase/ssh/staging
# Verify connectivity:
codebase ssh test staging
# Inspect / remove:
codebase ssh list
codebase ssh rm stagingWhen asked to "deploy the build to staging", the model issues:
ssh_exec({ host: "staging", command: "cd /app && systemctl restart codebase" })The host argument is the registered NAME, not a hostname. The tool
resolves it against ~/.codebase/ssh.json and (optionally) project
overrides at <cwd>/.codebase/ssh.json. Project entries override
user entries with the same name.
- Allowlist by name, not free-form. The agent can target
stagingonly if the user enrolledstaging. Even with prompt injection, the model can't pick a destination the user didn't pre-approve. - Same shell-validator as the local
shelltool.rm -rf /, fork bombs, raw writes to/dev/sdaetc. are blocked before the ssh spawn, regardless of which host they target. - BatchMode=yes. Never prompts for a password. If the key isn't accepted, the call fails fast instead of stalling.
- StrictHostKeyChecking=accept-new. First connection pins the host key (TOFU); a later host-key mismatch refuses to connect.
- ConnectTimeout=10s + ServerAliveInterval=30s. Unreachable hosts fail in seconds, dead network paths are detected during long-running commands.
- IdentitiesOnly=yes when --key given. Predictable auth path — ssh doesn't fall back to other keys in the agent.
The validator is advisory, not a security boundary. Real isolation for hostile workloads still belongs in container / sandbox boundaries on the remote.
{
"hosts": [
{
"name": "staging",
"host": "staging.example.com",
"user": "deploy",
"port": 22,
"identityFile": "~/.codebase/ssh/staging",
"description": "staging app server (us-east-1)"
}
]
}name must match [a-z0-9][a-z0-9_-]*. host rejects anything
that looks like user@host:port syntax — use separate fields.
Long-running commands (dev servers, log tails, build watchers) go in the background so the agent doesn't block on them. Two flavors:
shell({ background: true, command: "npm run dev" })
// → returns task_id "bg-3" immediately
shell_output({ task_id: "bg-3" }) // poll buffered output
shell_kill({ task_id: "bg-3" }) // terminateThe agent gets notified automatically when a background shell exits — no need to poll for completion.
The agent can ATTACH a monitor to a running background shell to be notified as matching lines arrive, instead of polling. Use this for "watch the log for ERROR" or "tell me the first time the server prints 'Listening on'."
shell({ background: true, command: "tail -f logs/app.log" })
// → "bg-1"
monitor({
task_id: "bg-1",
match: "ERROR|FATAL", // regex; default = match every line
flags: "i", // default; "" for case-sensitive
max_matches: 5, // auto-stop after N (optional)
note: "watching app errors" // free-form hint
})
// → "mon-1". Now any matching line steers a system-reminder
// into the agent mid-conversation.
monitor_stop({ monitor_id: "mon-1" }) // unregister earlyMonitors auto-clean when the watched shell exits or when max_matches
is reached. The background shell itself keeps running until
shell_kill — monitor_stop only unsubscribes from notifications.
Connect external tool servers (filesystem, Postgres, git, fetch, …)
without us writing each integration. Two transports: stdio
(spawned subprocess, newline-delimited JSON-RPC 2.0) and remote
Streamable HTTP (JSON-RPC over POST with SSE-or-JSON responses,
session-id continuity, OAuth 2.1 with dynamic client registration and
silent refresh — per-server tokens persist 0600 at
~/.codebase/mcp-credentials.json). No SDK dependency — both clients
are hand-rolled (src/mcp/), consistent with our minimal-deps tenet.
Configure in ~/.codebase/mcp.json (user) or <cwd>/.codebase/mcp.json
(project, wins on name clash) — the de-facto mcpServers schema so
existing Claude Desktop / Cursor configs port over:
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path"]
},
"postgres": {
"command": "uvx",
"args": ["mcp-server-postgres"],
"env": { "DATABASE_URL": "postgres://…" }
},
"remote": {
"url": "https://mcp.example.com/mcp",
"headers": { "Authorization": "Bearer <token>" }
}
}
}Remote servers with static auth use headers; servers that gate on
OAuth self-authorize on first connect (browser flow; the URL is printed
for headless sessions) and re-auth automatically on a 401.
Servers connect after launch (async subprocess spawn + handshake);
their tools splice into the live agent namespaced as
mcp__<server>__<tool> so they can't collide with built-ins. A broken
server is recorded and skipped — never blocks the others. /mcp shows
status + tools. Remote MCP tools route through the same effect-based
permission prompts. Lifecycle: connected on mount, re-connected on a
/model swap, torn down on exit.
- Platform skills: OAuth-fetched skills from codebase.foundation — the loader exists; wiring is gated on a stable endpoint contract.