A model-agnostic, CLI-based AI coding agent built on the OpenAI Responses API. Interacts with you one-shot or in a REPL, runs local tools (files, shell, git), streams output, and integrates MCP servers — all behind a declarative permission system.
Status: Phases 0–5 and the Phase 7
todotool are done. Todaypicodeholds one-shot and REPL conversations, streams output, and runs a full local tool suite — file read/write/edit/list/stat, arun_commandshell, read-only git, web search/fetch, atodochecklist tool, and a headlessrun_agentsub-agent tool — behind a declarativeallow/ask/denypermission engine with interactive prompts andinteractive/auto/planmodes. MCP integration lands in Phase 6.
- Node.js 26+ (runs TypeScript directly via type stripping — no build step)
- pnpm (never
npmoryarn) - An OpenAI API key (default env var:
OPENAI_API_KEY)
pnpm install
pnpm start # run the CLI from this repo
pnpm link # optional: exposes the global `picode` commandpicode "explain this repo" # one-shot
picode # interactive REPLThe model is selected with --model or the config file.
The agent can call local tools, all confined to the workspace (config.root
plus config.additionalDirs); path traversal outside is blocked.
run_command— runs a shell command via/bin/shin the workspace root, with a timeout (config.toolTimeout, default 30s) and capped output. Returns a blob starting withexit <code>followed by truncated stdout/stderr; a timeout reportsexit 124.read_file/write_file/list_dir/stat— file operations.write_filecreates parent directories and overwrites existing files.edit_file— surgical patch: replacesold_stringwithnew_stringinside a file without rewriting the rest of it.old_stringmust match exactly once (whitespace/indentation included) unlessreplace_allis set; an ambiguous or missing match reports back instead of guessing.git_status/git_diff/git_log/git_show— read-only git, run in the workspace root.web_search— searches the web via the Brave Search API and returns ranked title/URL/description results. Needs an API key (env var named bybraveSearchApiKeyEnv, defaultBRAVE_SEARCH_API_KEY); without one, the tool reports that plainly instead of failing to register.web_fetch— fetches a URL and returns its readable text (HTML is stripped to plain text; other content types pass through as-is). Every request is checked against a hard, non-configurable guard that rejects loopback, private, and link-local targets (including the cloud metadata address169.254.169.254) before it's made — this runs regardless of permission rules, the same way file tools are unconditionally confined to the workspace.run_agent— delegates a well-scoped, self-contained subtask ({ description, prompt }) to a fresh sub-agent that runs its ownrunTurnloop with the same provider and toolset, then reports back its final text. It's isolated (no access to the parent conversation) and headless: it can't pop an interactive approval prompt, so anything not already allowed by policy is silently refused instead of asking — only what an allow rule, the read-only shell default, orauto/planmode already covers goes through. One level of nesting only — it can't callrun_agentitself. Iftodois available, the sub-agent gets its own isolated checklist instead of sharing (or being able to see) the parent conversation's — every other tool (filesystem, shell, git, web) is shared, since those operate on the real workspace rather than session state.todo— tracks a multi-step task as a checklist ({ action, content?, id?, status? }withadd/update/complete/delete/list). Every call returns the full checklist (todo: done/totalplus one line per item). It only mutates session state, never the workspace, so it's allowed in every mode without prompting; atodopermission rule can still deny it. The checklist is cleared by/reset; arun_agentsub-agent gets its own separate checklist rather than sharing this one.
Every tool call is gated by the permission engine. Denied
tools are hidden from the model's toolset; calls that are denied only for
specific arguments are hard-blocked at run time (the denial status line names
the blocking rule or plan mode).
The default instructions (src/agent/systemPrompt.ts) tells the model how
to use this toolset correctly — notably to prefer edit_file over
write_file for changes to an existing file, since a full rewrite discards
anything the edit didn't intend to touch, and when to delegate to
run_agent. Setting instructions in a config file replaces this default
wholesale rather than appending to it.
Before a tool call runs, the agent loop evaluates it through the permission engine:
allow(by rule, read-only default, orautomode): runs without askingask: prompts at an interactive terminal —yruns once,ndenies,aalways allows (records the exact pattern, session-only for now)deny(by rule orplanmode): blocked; status line shows the reason- non-interactive (one-shot prompt, piped stdin): an unresolved
askresolves to a denial — no prompt
| Flag | Description |
|---|---|
--model <id> |
Model to use (overrides config) |
--mode <mode> |
Permission mode: interactive | auto | plan (default: interactive) |
--auto |
Alias for --mode auto |
--plan |
Alias for --mode plan |
--config <path> |
Config file to load |
--no-stream |
Buffer the full response instead of streaming |
--verbose |
Show full tool input/output |
--no-color |
Disable ANSI colors (NO_COLOR is also honored) |
--version |
Print version |
--help |
Show help |
/help /model /mode /clear /reset /tools /version /exit
/mode— show the current mode, or switch:/mode auto//mode plan//mode interactive/tools— list every tool with its effective permission (allow/ask/deny) in the current mode/version— show the runningpicodeversion/clearclears the terminal;/resetwipes the current conversation
Config is merged in this order (later wins):
- Built-in defaults
~/.config/picode/config.json(user-level)./picode.json(project-level, checked in to share with your team)- CLI flags
- Environment:
OPENAI_BASE_URLoverridesbaseURL;OPENAI_DEFAULT_MODELoverridesmodel(CLI flags still win); the API key is read fromapiKeyEnv(defaultOPENAI_API_KEY)
The pnpm start / pnpm dev scripts load .env from the project root via
Node's --env-file flag, so you can keep credentials out of your shell (see
.env.example):
OPENAI_API_KEY=sk-...
# OPENAI_DEFAULT_MODEL=gemma4:cloud # optional: model used when --model is not given
# OPENAI_BASE_URL=http://localhost:11434/v1 # optional: override the endpoint
# BRAVE_SEARCH_API_KEY=... # optional: enables the web_search tool
Variables already exported in your shell take precedence over .env, and an
explicit --model flag beats OPENAI_DEFAULT_MODEL.
picode gates every tool call through a rule engine with allow / ask /
deny buckets, evaluated deny > ask > allow. Rules use Tool(pattern)
syntax (Bash(pnpm test *), Edit(src/**), Read(.env),
WebSearch(*), WebFetch(https://internal.corp/**), Agent(fix the tests),
Todo(add)) — web_search and web_fetch are separate webSearch/webFetch
categories, not one shared web bucket, so a rule can target one without
also matching the other; run_agent gets its own agent category and todo
its own todo category for the same reason. read, webSearch,
webFetch, and todo default to allow (non-mutating — todo only
touches session state), so a rule is needed only to deny them. When a call
needs approval you answer:
y— allow oncen— denya— always allow (records the exact pattern, session-only for now)
Phase 3 — live. The rule engine replaces the mode-only prompt hook; the
loop's authorize() is composed from evaluateCall + promptForDecision,
so tools stay untouched. Rules use Claude-Code-style Tool(pattern) syntax
(Bash(pnpm test *), Edit(src/**), Read(.env), mcp.<server>.<tool>),
resolved deny > ask > allow.
- interactive (default): prompts for anything not allowed by a rule
- auto (
--auto): auto-approves anything not explicitly denied - plan: read-only — file writes, shell commands, and
run_agentare all denied (read-only shell, file reads,web_search/web_fetch, andtodoare allowed, since they're non-mutating —run_agentisn't, since a sub-agent could write files or run shell commands through its own tool calls)
Built-in read-only shell commands (ls, cat, grep, git status, …) run
without prompting. Destructive/escaping commands still prompt even in auto.
| Code | Meaning |
|---|---|
0 |
success |
1 |
runtime/uncaught error |
2 |
a tool call was denied by policy |
3 |
tool-call limit reached (MAX_TOOL_ROUNDS) |
See PLAN.md — the single source of truth for planning. Status:
| Phase | Scope | Status |
|---|---|---|
| 0 | Scaffold + docs | 🟢 done |
| 1 | CLI parsing, config, plain chat | 🟢 done |
| 2 | Streaming + tools + function-calling loop | 🟢 done |
| 3 | Built-in tools + permission engine | 🟢 done |
| 4 | Web tools (web_search, web_fetch) |
🟢 done |
| 5 | Sub-agent delegation tool (run_agent) |
🟢 done |
| 6 | MCP integration | ⚪ not started |
| 7 | Todo tracking tool | 🟢 done (core; session tagging/persistence deferred to Phase 8) |
| 8 | Session persistence, context trimming, parallelism | ⚪ not started |
| 9 | Polish, automation, and advanced features | ⚪ future |
pnpm dev # run with --watch
pnpm test # run tests once
pnpm test:watch # run tests in watch mode
pnpm test:live # end-to-end live test against the configured model
pnpm typecheck # tsc --noEmit
pnpm format # prettier --write
pnpm format:check # prettier --check (gates CI)pnpm test:live (scripts/live-e2e.py) drives the real CLI against the
configured model (.env key + picode.json model/base URL) and asserts the
CLI output — tool status lines, streamed text, and permission prompts — stays
clean (no clobbering) and that exit codes track policy. Coverage spans every
built-in tool (read_file/write_file/edit_file/stat, run_command,
git_status, web_search/web_fetch, run_agent, todo), the permission
engine (allow/ask/deny, interactive/auto/plan, the a
always-allow answer, a custom --config), and every REPL slash command —
including a regression test that a run_agent sub-agent gets its own
isolated todo checklist rather than sharing the parent's. It runs
model-dependent REPL flows under a pseudo-terminal via script; use
--quick to skip them and --keep to retain the captured transcripts in
.live/.
Developer guide: AGENTS.md
{ "model": "gpt-5.6", "baseURL": "https://api.openai.com/v1", "apiKeyEnv": "OPENAI_API_KEY", "braveSearchApiKeyEnv": "BRAVE_SEARCH_API_KEY", "instructions": "You are picode, a coding agent.", // overrides the built-in default wholesale "root": "/path/to/workspace", // default: current directory "additionalDirs": ["../sibling"], "mode": "interactive", // interactive | auto | plan "maxTokens": 8192, // response output token cap "maxRetries": 3, // OpenAI SDK request retries "toolTimeout": 30000, // default shell tool timeout (ms) "permission": { "shell": { "allow": ["Bash(pnpm test *)"], "ask": [], "deny": ["Bash(rm -rf *)"] }, "edit": { "allow": ["Edit(src/**)"], "ask": [], "deny": [] }, "read": { "allow": [], "ask": [], "deny": ["Read(.env)"] }, "webSearch": { "allow": ["WebSearch(*)"], "ask": [], "deny": [] }, "webFetch": { "allow": [], "ask": [], "deny": ["WebFetch(https://internal.corp/**)"] }, "agent": { "allow": [], "ask": [], "deny": [] }, "todo": { "allow": [], "ask": [], "deny": [] } } }