pigagent / pig
pi, ported to PHP: agent core, unified LLM API, terminal UI, coding agent CLI
Requires
- php: >=8.3
- composer-runtime-api: ^2.0
- ext-json: *
- ext-mbstring: *
- ext-openssl: *
- ext-pcntl: *
- ext-pcre: *
Requires (Dev)
- phpunit/phpunit: ^12
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
English · 简体中文
A PHP port of pi, the agent harness whose coding agent runs on a famously small core. Same architecture, same file layout, written for PHP 8.3 with no runtime dependencies — no Guzzle, no ReactPHP, no amphp, no ncurses. Just the standard library.
Status: it runs.
bin/pigis a coding agent you can talk to in a terminal: streaming answers, tools with live output, edits shown as diffs, Escape to interrupt, typing while it works, and!cmdto run a shell command the model can then see (!!cmdkeeps it out of the conversation). Sessions are saved as they happen —--continuepicks up the last one,/resumepicks from a list. When the context fills it summarises itself and carries on, which/compactalso does on demand, and/modelswitches models mid-session. Skills are picked up from~/.pig/skillsand from Claude's and Codex's folders too, and images a tool returns are drawn in the terminal. Hooks are PHP files that can block a tool, edit what the model is shown, or add commands of their own, and a folder with anindex.phpin it is a tool the model can call.-pprints one answer and exits, and--mode jsonor--mode rpcdrops the terminal for JSON lines, for an editor.
Packages
| Package | Namespace | State |
|---|---|---|
pig/async |
Pig\Async\ |
Event loop, futures, coroutines — done |
pig/ai |
Pig\Ai\ |
Unified LLM API — Anthropic, OpenAI chat-completions, OpenAI Responses and Gemini all stream end to end |
pig/agent-core |
Pig\Agent\ |
Agent loop with tool calling, JSON Schema validation of tool arguments, and state — done |
pig/tui |
Pig\Tui\ |
Terminal UI with differential rendering — done |
pig/coding-agent |
Pig\CodingAgent\ |
Coding agent — tools, prompt, interactive CLI, saved sessions, compaction, model switching, skills, hooks, custom tools and all three modes (terminal, print, RPC) done |
pig/async has no counterpart upstream: JavaScript ships an event loop and PHP does not. It
exists so one stream_select() can wait on the model's socket and on the keyboard at the same
time, which is what makes interrupting a running turn — and typing while the model streams —
possible at all.
Behind a proxy, which some networks require to reach a provider at all:
bin/pig --proxy socks5://127.0.0.1:7891 # or http://127.0.0.1:7890 https_proxy=http://127.0.0.1:7890 bin/pig # or all_proxy, or proxy.url in the settings
HTTP CONNECT and SOCKS5, with a username and password if the proxy wants one. TLS starts after
the tunnel is open and verifies the provider's certificate, so the proxy carries the bytes and can
read none of them; a hostname is handed to the proxy to resolve, because a local resolver is often
the other thing that does not work. Loopback always goes direct, no_proxy and proxy.bypass add
to that, and --no-proxy turns the lot off.
CodingAgent::session() is the whole of startup as a call: it resolves the model and the thinking
level, loads the skills, context files, hooks and custom tools, opens or creates the session file,
and hands back a session plus a list of warnings. bin/pig is a caller of it rather than the place
it lives, so the resolution order can be tested instead of run and looked at.
A different thing with the same name: Agent\StreamProxy sends the conversation to a gateway
server that holds the provider keys and makes the call, speaking upstream's /api/stream wire
format so a gateway written for pi works unchanged. Nothing turns it on — it is a streamFn you
pass in — because it hands the conversation to whoever runs that server, which is the point for a
team that wants no keys on laptops and a reason to avoid it otherwise.
A tool call is checked against the tool's own JSON Schema before it runs — Ai\Utils\JsonSchema
is a draft-07 subset with AJV's wording, because the reader of a validation failure is the model
that has to correct itself from it. An unknown keyword is ignored rather than failed, so adding a
keyword to a tool's schema can never break the tool.
Try it
curl -fsSL https://raw.githubusercontent.com/owner888/pig/main/install.sh | sh
ANTHROPIC_API_KEY=sk-ant-... pig
The installer checks PHP and its extensions before touching anything, installs the package with
Composer, and offers to put Composer's global bin directory on your PATH — which is the step that is
otherwise easy to miss, because Composer says nothing about it and pig is then command not found.
Composer directly works too, with that last step left to you:
composer global require pigagent/pig echo "export PATH=\"$(composer global config bin-dir --absolute):\$PATH\"" >> ~/.zshrc exec $SHELL
To work on pig itself, clone it instead — everything below is the same either way, with bin/pig in
place of pig:
git clone https://github.com/owner888/pig && cd pig && composer install ANTHROPIC_API_KEY=sk-ant-... bin/pig
--read-only takes away edit, write and bash; --theme light for a light terminal;
--api-key <key> uses a key for this run only, without saving it;
-c/--continue to pick up where you left off, or -r/--resume on its own to choose from
a list — type in that list to search it, and the search matches anything said in the
conversation, not just the line it opened with. Escape there starts a new conversation instead;
ctrl+c leaves without starting one. /resume inside a session shows the same list, search and
all. -h for the flags, -v for the version.
/help inside lists every key, the prompt's own editing keys included, and every command.
Shift+ctrl+d writes a debug log — the frame, how wide each line came out, and the conversation —
which is the thing to attach to a bug report.
A start asks Packagist once, in the background, whether there is a newer pig, and says so under
the conversation if there is — with the command to run, because pig does not update itself.
Nothing is said when the answer is no, when the network is not there, or when anything else goes
wrong. --no-update-check skips the asking for one run, and "update": {"check": false} in
~/.pig/settings.json turns it off for good. /changelog shows what changed, and the entries
newer than the version you last saw are shown once, by themselves, after an upgrade.
With a Claude Pro or Max subscription there is no key to set: /login gives you a URL to open
and takes the code that comes back. A GitHub Copilot subscription works the same way — it shows
a code to type at github.com and waits, and escape stops the waiting. Gemini CLI (Google Cloud
Code Assist) opens a browser and catches the redirect on localhost:8085, so port 8085 has to be
free; it needs Google's own client id and secret, which pig does not ship — set
GEMINI_CLI_CLIENT_ID and GEMINI_CLI_CLIENT_SECRET, or geminiCli.clientId and
geminiCli.clientSecret in ~/.pig/settings.json. Antigravity is the fourth and works the same
way on port 51121, with a client id and secret of its own (ANTIGRAVITY_CLIENT_ID and
ANTIGRAVITY_CLIENT_SECRET, or antigravity.clientId and antigravity.clientSecret) — it is
what gets you Gemini 3, Claude and GPT-OSS through a Google subscription. Whichever you use, the
token is kept in ~/.pi/agent/auth.json — pi's own file, when pi has one, so signing in once is
signing in once. /logout forgets it.
There is also bin/pig-ai login — the same four sign-ins from a plain command line, for a
machine you are setting up over ssh. bin/pig-ai list names them.
If that directory still has an old pi's oauth.json in it,
pig moves it across on the first run and says which providers it moved — the old file is renamed,
not deleted.
Anything that is not an option is a message, and @some/file is read in front of it:
bin/pig "what does bin/pig do?" # ask, then carry on in the terminal bin/pig @src/Thing.php "why is this slow?" # the file first, then the question bin/pig @screenshot.png "what is wrong here?"
An image goes in as an image, so a screenshot reaches the model without a tool call. -- ends
the options, for a message that starts with a dash or an @.
Ctrl+G opens whatever is in the prompt in $VISUAL or $EDITOR and puts the result back —
for the message that turned out to be three paragraphs. It works while the model is still
answering: the editor gets the terminal, and the answer keeps arriving behind it.
The model gets four tools by default — read, bash, edit, write — which is upstream's set;
--tools read,grep,find,ls names the set outright, and --read-only swaps in the read-only four.
Fewer tools is deliberate: a model with seven spends part of every turn choosing between them, and
searching through bash with rg is what the prompt already asks for.
--model takes a part of a name rather than a whole id — --model sonnet, --model 'opus 4.1' —
and --model sonnet:high sets the thinking level at the same time. --list-models lists the ones
you have a key for, with their context and output limits; --list-models gem pro narrows that,
fuzzily, over the provider and the id together. /model offers the same list, and switching to
a model with no key is refused by name rather than failing on the next turn. Ctrl+L opens that
list; Ctrl+P steps to the next model on it without opening it, Shift+Ctrl+P back to the previous
one.
--models narrows the session to a few of them, comma-separated, and then Ctrl+P walks only
those: --models sonnet,haiku opens on sonnet and cycles between the two, and
--models 'anthropic/*:high' takes every Anthropic model with thinking set high for each. A
pattern may be a glob or a part of a name, and :level on the end sets the level for that entry.
/model offers the scope too, so the list and the keystroke agree about what is on offer.
171 models are known: Anthropic's, OpenAI's own on the Responses API, Gemini, and Groq,
Cerebras, xAI, Zai and Mistral, which all speak OpenAI chat-completions. Set the matching
*_API_KEY and --model reaches them.
GitHub Copilot's nineteen are there too, and Google Cloud Code Assist's five — the models those
subscriptions serve, under the same ids their own providers use. So gpt-5 and gemini-2.5-pro
name two models each: a bare name means the direct provider, and a subscription's is
--model github-copilot/gpt-5 or --model google-gemini-cli/gemini-2.5-pro.
/label before the refactor names where you are, and /tree shows the name beside what was
said — pi's own label entries, so a name set in either tool shows up in the other.
/tree goes back to an earlier point in the conversation and carries on from there. It draws the
whole conversation as a tree, forks and all, so the road not taken is a row you can move the
cursor onto rather than something the file merely still contains:
• user: port the tree selector
• assistant: Here is what it does…
├─ user: actually, do the proxy first
│ └─ assistant: Right — starting with CONNECT…
└─ • user: no, keep going with the tree
• assistant: Carrying on…
• marks the path you are on. Ctrl+O cycles five filters (everything, no tool results, only what
you said, only what you named, absolutely everything), typing searches — what you have typed is on
the Search: line above the rows — and l names the row under the cursor. Going back offers to summarise the branch you are leaving, so an hour of exploring
arrives on the branch you are joining instead of being left behind. Going back to something you
said takes it out of the conversation and puts it back in the prompt, to be asked differently.
Sessions are written in pi's own format, in pi's own directory layout — so a conversation
started in one opens in the other, and --resume lists what is in ~/.pi/agent/sessions/
beside pig's own. Carrying on from a pi session appends to that file, in that format.
Resuming brings back the model and the thinking level that conversation was being had with, not
whatever a new one would open with. --model still wins if you name one.
/changelog shows what changed release by release, and new entries are shown once after an
upgrade (not when you resume a conversation).
/export writes the conversation out as one self-contained HTML file — markdown rendered,
code highlighted, no JavaScript in it at all. pig --export <session.jsonl> [out.html] does the
same to a session on disk without opening it.
A turn that fails because the provider is busy — a 429, a 503, a socket that died — is
waited out and sent again, doubling from two seconds, up to three times, with what the
provider said and a countdown on screen and escape to stop. When the provider names the moment
its quota comes back, that is the wait instead of the doubling — coming back in two seconds to a
quota that resets in forty is three more refusals. Past a minute it is not waited out at all: the
turn ends with what the provider said, because a retry that resumes after lunch is not a retry. A turn that fails because the
conversation outgrew the model's window is a different thing and is treated as one: it is
summarised first, then sent again, because the same request would be exactly as long in four
seconds. Escape stops that summarising as well, and stops it whether it was /compact that asked
or a full window. retry.enabled: false in the settings turns the first off.
A provider pig has never heard of goes in ~/.pig/models.json — your own box, a proxy, a
local server — and its models then work everywhere a built-in one does, including --model,
/model and --list-models:
{ "providers": { "my-box": {
"baseUrl": "http://192.168.1.9:8080/v1", "apiKey": "MY_BOX_KEY",
"api": "openai-completions",
"models": [{ "id": "qwen3-coder", "name": "Qwen3 Coder", "reasoning": false,
"input": ["text"], "contextWindow": 262144, "maxTokens": 32768 }] } } }
apiKey is the name of an environment variable if one answers to it, so the key itself
need not be in the file — and an all-capitals name with no variable set is treated as no key at
all, said at startup by name, rather than being sent to the endpoint as though it were the key. A model may carry a
"cost": { "input": …, "output": …, "cacheRead": …, "cacheWrite": … } block, in dollars per
million tokens — so three dollars per million is 3.0, not 0.000003 — and leaving it out means
free, which is what a local model is. A price that is not a number is named and the model skipped,
rather than quietly costing nothing in /session and the footer. api is one of openai-completions, openai-responses,
anthropic-messages or google-generative-ai, and can be set on the provider or per model;
authHeader: true sends the key as Authorization: Bearer … for a proxy that wants it there.
Anything wrong with the file is printed and skipped — the rest of it, and every built-in
model, still work. It is pi's format, and pi's own ~/.pi/agent/models.json is read when pig
has none.
Settings live in ~/.pig/settings.json, and a project can override them in
.pig/settings.json. The theme, model and thinking level you pick are remembered.
/settings shows what can be changed from inside a session — theme, thinking, whether
reasoning is drawn, whether pictures are drawn, whether messages you type mid-run go over one at
a time or together, auto-compact, auto-retry — with what each one is set to now. Enter changes
the row you are on and the list stays open; escape closes it.
A markdown file in ~/.pig/commands/ or .pig/commands/ becomes a slash command: review.md
is /review, its body is the prompt, and $1 and $@ are filled from what follows. Those and
the ones a hook registers work outside the terminal too — pig -p "/review src/Foo.php" sends the
prompt, and pig -p "/deploy staging" runs the hook's command.
A PHP file in ~/.pig/hooks/ or .pig/hooks/ that returns a callable is a hook. It gets
sixteen events — every tool call and result, every turn, the context on its way to the model,
compaction, /tree, startup and shutdown — and can block a tool, rewrite what the model is
shown, or add a slash command of its own:
<?php // ~/.pig/hooks/no-force-push.php use Pig\CodingAgent\Hooks\HookApi; use Pig\CodingAgent\Hooks\Results\ToolCallEventResult; return function (HookApi $pi): void { $pi->on('tool_call', function ($event) { if ($event->toolName === 'bash' && str_contains($event->input['command'] ?? '', '--force')) { return new ToolCallEventResult(block: true, reason: 'No force pushes from here.'); } return null; }); };
A hook can also say something — $pi->sendMessage('build', 'the tests are failing') puts
it in the conversation where the model reads it, with display: false if it is for the model
and not for you, and triggerTurn: true to have the agent answer it there and then. A hook can
draw its own messages with registerMessageRenderer(). And $pi->appendEntry('permissions', $data) writes something into the session file that the model never sees — hook state that is
still there after a restart, costing no context.
A hook runs inside pig, so it can hand back an object and reach pig's own classes — and a
hook that loops or calls exit() takes the session with it. Broken ones are named on the
shell at startup rather than crashing; /hooks lists what loaded and --no-hooks skips them.
A hook can also ask, mid-turn, and wait for the answer:
$pi->on('tool_call', fn ($event, $ctx) => $ctx->ui->confirm('Let bash run?', $event->input['command'] ?? '') ? null : new ToolCallEventResult(block: true, reason: 'You said no.'));
The tool call parks, a picker appears, and the keystroke resumes it — the handler gets a
plain bool back. select, input, a multi-line editor, notify, a keyed footer line and
custom (draw your own component) are there too. Escape answers no, so walking away from the
question does not wave the tool through.
A folder with an index.php in ~/.pig/tools/ or .pig/tools/ is a tool the model can
call — a folder, because that directory also holds the fd and rg binaries pig may have
downloaded, and a file there is one of those. Same loader as hooks, same trade-off; /tools
lists everything the model has and where each one came from, and --no-tools skips them:
<?php // ~/.pig/tools/wc/index.php use Pig\Agent\AgentToolResult; use Pig\Ai\TextContent; use Pig\CodingAgent\CustomTools\CustomTool; use Pig\CodingAgent\CustomTools\CustomToolApi; return fn (CustomToolApi $pi) => new CustomTool( name: 'wc', label: 'Count lines', description: 'Count the lines in a file.', parameters: [ 'type' => 'object', 'properties' => ['path' => ['type' => 'string', 'description' => 'the file']], 'required' => ['path'], ], execute: fn ($id, $params, $onUpdate, $ctx) => new AgentToolResult( [new TextContent(trim($pi->exec(['wc', '-l', $params['path']])->stdout))], ), );
$ctx is the session: the conversation so far, which model is answering, whether the agent
is busy, a way to stop it, and the same ui a hook gets. A tool can also be told when the
session starts, switches, jumps or ends, which is how one that keeps state rebuilds or lets
go of it. And it can draw its own call and its own result in the transcript,
so a tool whose answer is a table is not squeezed through formatting meant for files.
Skills are folders with a SKILL.md in them. pig reads ~/.pig/skills and .pig/skills, and
also ~/.claude/skills, .claude/skills, ~/.codex/skills, ~/.pi/agent/skills and .pi/skills,
so a skill written for another agent — or for pi, before the move — works here unchanged. Two folders
holding the same name is an override rather than an error: pig > pi > claude > codex, a project
folder beats the home one, and --skills-dir beats all of them. /skills lists what was found and
where each one came from, and the startup says which file an override took the name from;
--no-skills loads none of them, as --no-hooks and --no-tools do for theirs. The five folders
another tool owns can also be turned off one at a time, under upstream's own settings keys —
skills.enableCodexUser, enableClaudeUser, enableClaudeProject, enablePiUser,
enablePiProject — which is what to reach for when a large ~/.claude/skills does not belong in
front of the model. pig's own two folders have no such key: skills.enabled and --no-skills are
the switch for those, and skills.ignoredSkills takes glob patterns for anything narrower.
Rpc\RpcClient is the other end of --mode rpc: it starts the agent, sends the twenty-two commands
and hands back events, with every call suspending its own fiber rather than returning a promise — so
a host reads like a program that blocks, and nothing blocks.
There are three ways in, and the terminal is only the default. -p says it, prints the
answer and exits — for a shell script, or a pipe:
bin/pig -p "one sentence on what this repo does" | pbcopy bin/pig -p @error.log "what went wrong?"
--mode json is the same run with every event on standard output instead: the streaming a
terminal would draw, for something that wants to parse it. Warnings stay on standard error, so
the lines are all JSON.
bin/pig --mode json -p "..." | jq -r 'select(.type=="message_update") | .delta.delta // empty'
--mode rpc reads commands as JSON lines and never stops: for an editor, or anything else
driving pig from code.
echo '{"id":"1","type":"prompt","message":"what does bin/pig do?"}' | bin/pig --mode rpc
Twenty-three commands — prompt, steer, abort, switch models, compact, run a shell command,
walk the conversation tree, export — and the answer arrives as the same streaming events the terminal
draws. A hook can still ask: the question goes out as a hook_ui_request line and the tool
call parks until the host answers it, which is the terminal trick with a different transport.
examples/ask.php is the same stack with no UI at all:
ANTHROPIC_API_KEY=sk-ant-... php examples/ask.php "why is the sky blue?"
Everything under that line is pig's own: one non-blocking TLS socket, HTTP/1.1 written by hand,
SSE parsed as it arrives, coroutines on Fiber. Ctrl-C mid-answer exercises the same abort path
the TUI uses.
Requirements
PHP >= 8.3 with ext-json, ext-mbstring, ext-openssl, and ext-pcntl for the terminal UI.
stty is the one external binary that is required. fd and rg are needed by the find and
grep tools, and pig downloads them into ~/.pig/tools/ on first use if they are not installed
(PIG_OFFLINE=1 turns that off).
Fiber arrived in PHP 8.1 and everything rests on it, so 8.1 is the absolute floor. 8.3 is the
floor actually declared: 8.1 is end-of-life and 8.2 loses security support at the end of 2026.
No Composer dependencies at runtime, which includes the provider protocols: the HTTP client,
the event-stream parser and all five providers are written here. Upstream does none of that — the
Anthropic, OpenAI and Google SDKs do it for pi. That is not a preference: PHP has no official SDK
for the OpenAI or Gemini APIs at all, and the official Anthropic one streams by iterating a PSR-18
response body synchronously, which would block the single stream_select() that watches the model's
socket and the keyboard together — and with it Esc-to-interrupt and typing while the model streams.
CLAUDE.md has the full arithmetic, and it is a dated judgement rather than a principle.
Development
composer install php test/lint.php # php -l over every file vendor/bin/phpunit php test/live.php # the providers against the real endpoints — costs money, needs keys php scripts/generate-models.php # bring the model registry up to date from models.dev
The model registry's rows are generated, as upstream's are: scripts/generate-models.php reads
models.dev, keeps the models whose protocol is ported, and rewrites the tables in Ai\Models in
place — --dry-run prints them instead, and every run reports what was added, removed and changed
so a regeneration can be read rather than trusted. Everything around the rows — the base URLs, the
two subscription catalogues, the collision rules — is hand-written and untouched.
test/live.php is not part of the suite and never runs by itself: twelve scenarios per provider
against the real API, which is the only way to answer whether a provider accepts what pig sends —
a replayed thinking signature, a tool result invented for an interrupted call, a conversation carried
over from another provider, a prompt past the window. It takes its keys where pig takes its own, so a
machine that has signed in needs nothing, and php test/live.php anthropic google names which to run
— or php test/live.php google/<model-id> to try one particular model. The id has to be one the
registry carries, which is pinned at the upstream anchor commit; a model released since then is
declared in ~/.pig/models.json and is then reachable both here and from bin/pig. Each call is
capped at a few hundred tokens.
Verify against the floor, not just your PHP: 8.3 rejects 8.4-only syntax at parse time, and it is easy to reach for a feature the declared floor does not have.
PIG_TIMING=1 bin/pig prints what each part of starting up cost, on standard error, before the
terminal takes over.
See CLAUDE.md for the porting rules, the decisions on record, and the traps found so far.
Upstream
Ported against pi at commit d0a4c37 (2026-01-02) — the snapshot where agent-loop.ts was 418
lines, before the harness grew. Class, file and method names track upstream so a diff against a
newer upstream commit stays mechanical.
License
MIT