@nothumanwork/agi
v0.1.0
Published
Bun-only SDK for driving interactive AI coding CLIs (Claude Code, Codex) inside tmux, observed through their native transcripts
Maintainers
Readme
agi
Requires Bun. This package uses Bun.spawn and Bun.file; Node.js is not
supported.
A small SDK for driving interactive AI coding agents (Claude Code, Codex, …)
from scripts. Harness-agnostic by design: one Agent interface, pluggable
adapters per CLI.
Two ideas make it work:
- Interactive, not headless. Agents run as their real TUIs inside tmux
sessions, so they keep their full capabilities, survive your process, and a
human can drop into any of them at any time with
tmux attach. - Transcripts, not screen-scraping. Agent state (working / idle) and replies are read from each harness's own session transcript on disk — structured JSONL, no TUI noise. The pane is only used to type into.
Install
bun add @nothumanwork/agiAlternatively, install straight from GitHub (the repo root manifest exposes this directory as the same package):
bun add github:thehumanworks/harnessBun runs the TypeScript sources directly, so there is no build step. For a
zero-setup single-file script that installs the SDK from GitHub on first run,
see examples/standalone-fleet.ts.
import { spawn, attach } from "@nothumanwork/agi";
const agent = await spawn("claude", { cwd: "/path/to/repo", model: "opus" });
// fire-and-forget + poll
await agent.send("Fix the failing test in src/parser.ts");
console.log(agent.attachCommand); // tmux attach -t agi-1a2b3c4d — watch it live
while ((await agent.status()).state === "working") { /* do other work */ }
// or ask-and-wait
const reply = await agent.ask("Summarise what you changed.");
// reattach later, from a different process, by id
const same = await attach(agent.id);
console.log(await same.messages()); // normalized conversation so far
await same.kill(); // stop the CLI; session stays resumable
await same.resume(); // relaunch from harness-native session state
await same.forget(); // stop + delete the persisted recordHow it works
| | Claude Code | Codex |
|---|---|---|
| Launch | claude --session-id <uuid> in tmux | codex in tmux |
| Transcript | ~/.claude/projects/<encoded-cwd>/<uuid>.jsonl (path known up front) | ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl (discovered by cwd after first prompt) |
| Turn done | last assistant record has stop_reason: "end_turn" | event_msg of type task_complete (carries last_agent_message) |
| Resume | claude --resume <uuid> | codex resume <id> |
Prompts are delivered with tmux send-keys (bracketed paste for multi-line),
with a short settle delay before Enter. Boot dialogs (folder trust) are
answered automatically.
Sessions are persisted as one JSON file each under ~/.agi/agents/ (override
root with AGI_HOME), so any later process can attach(id), list(), or
resume() them.
API
spawn(adapter, { cwd, model, extraArgs, readyTimeoutMs })→Agentattach(id)→Agent(from the persisted record)list()→ persistedSessionRecord[]agent.send(prompt)— type + submit; returns once delivered. Sending while the agent is working is fine (harnesses queue the prompt); sending while it iswaitingon a human dialog throws instead of typing into the dialog.agent.wait({ timeoutMs, pollMs })— until the turn completes; returns reply text. ThrowsTurnTimeoutError(with the current screen — usually a pending permission prompt) on timeout, orWaitingForInputErrorif the agent asked a question only a human can answer (AskUserQuestion/ExitPlanMode). The latest send cursor is persisted, so a later process canattach(id)and wait for a turn started by an earlier process.agent.ask(prompt, opts)—send+waitas one serialized turn; concurrentask()calls on the same agent run back to back.agent.status()—{ state: "working" | "waiting" | "idle" | "exited", lastReply?, waitingOn? }agent.messages()— normalizedTranscriptEvent[](text / tool_use / tool_result)agent.watch({ until: "idle", from: "now" | "start" })— async iterator of transcript events, from the live edge by defaultagent.alive()/agent.resume()/agent.kill()/agent.forget()agent.attachCommand— thetmux attach -t …string for humans
Fleet
runFleet(tasks, opts?)→FleetReport— run many agents with bounded concurrency and collect one structured, JSON-serializable report. Each task is{ adapter, prompt, ...SpawnOptions }(socwd/worktree/model/extraArgsall apply per task).optsis{ concurrency? (default 8), taskTimeoutMs? (default 20 min), cleanup? }, wherecleanupis"forget"|"keep"|"keep-failed"(default). A single task failing (spawn error,TurnTimeoutError,WaitingForInputError) never rejects the run or kills siblings — its error lands in that task's report entry;runFleetitself rejects only on programmer error (empty tasks, bad options).concurrencybounds how many tasks are being actively worked (spawn → reply) at once — it does not cap live agents. Agents kept by the cleanup policy stay alive beyond their task and accumulate, soconcurrency: 1still leaves many tmux CLIs running if the policy keeps them.The
FleetReportis plain data: aggregatefulfilled/failedcounts andstartedAt/endedAt, plus a per-task entry withstate("fulfilled"|"failed"),reply?/error?,agentId,transcript,attachCommand, timestamps, aworktree{ path, branch, base, status? }summary for worktree-backed tasks (statusis""for a clean worktree and absent when it could not be read), and thecleanupoutcome ("forgotten"|"kept"|"cleanup-failed"|"none"). With the default"keep-failed", fulfilled agents are forgotten and failed ones are kept so their session and worktree survive as debugging artifacts (attachCommandandtranscriptpoint a human at them). Seeexamples/fleet.ts.Fleet.start(tasks, opts?)→Fleet— launch a durable background fleet, deliver prompts with non-blockingagent.send(), persist the fleet record under~/.agi/fleets/, and return without waiting for replies. UseFleet.attach(id)from any later process,fleet.status()to inspect per-task progress,fleet.report()to drive remaining pending tasks and collect aFleetReport, andfleet.forget()to forget all spawned agents and delete the fleet record. Persistent fleets do not auto-clean up completed agents; cleanup belongs to the handle.concurrencycontrols how many tasks are launched at a time. If the launcher exits, tasks that were not launched remainpendinguntil a laterreport()ordrive()call resumes them. Seeexamples/hackernews.tsandexamples/hackernews-collect.ts.
Adding a harness
Implement HarnessAdapter (see src/types.ts): how to launch and resume the
CLI, where its transcript lives, how to parse its records, and how to read
turn state out of them. Then registerAdapter(myAdapter). The two built-ins
(src/adapters/claude.ts, src/adapters/codex.ts) are ~150 lines each and
are the reference.
Examples
Runnable scripts under examples/:
pair-review.ts— Claude implements, Codex reviewsfleet.ts— synchronous parallel Claude agents withrunFleetstandalone-fleet.ts— self-contained single-file fleet script; installs the SDK from GitHub on first run, no checkout or package.json neededhackernews.ts/hackernews-collect.ts— launch a durable background fleet, then collect it laterisolated-worktrees.ts— parallel agents in isolated git worktreesshared-session.ts— two client processes share one agent session, concurrent ask() calls serialized (durable turn lock)parallel-codex.ts— two Codex agents in the same directory at once
Doctor
Check that Bun, tmux, harness CLIs, transcript dirs, and AGI_HOME are ready:
bunx @nothumanwork/agi doctor
# or from a checkout:
bun src/cli.ts doctorSample output:
✓ bun: Bun 1.4.0
✓ tmux: tmux 3.6b (/opt/homebrew/bin/tmux)
✓ claude: /Users/you/.local/bin/claude
✓ codex: /Users/you/.bun/bin/codex
✓ agent-clis: Found: claude, codex
✓ claude-projects: /Users/you/.claude/projects
✓ codex-sessions: /Users/you/.codex/sessions
✓ AGI_HOME: /Users/you/.agi/agents is writabledoctor verifies runtime prerequisites before you spawn sessions; use --json for machine-readable output.
Testing
bun test # unit tests (transcript parsing, registry)
AGI_E2E=1 bun test test/e2e.test.ts # live: spawns real claude + codex sessionsKnown limits
- A pending permission prompt looks like
workingin the transcript; it surfaces viawait()timeout, whose error includes the current screen. Spawn with the harness's permission flags viaextraArgsif you want unattended runs. - Codex transcript discovery matches by cwd, spawn time, and first prompt,
excluding rollouts already claimed by other agi sessions; if multiple
unclaimed rollouts still match (e.g. identical simultaneous first prompts),
agi throws
AmbiguousTranscriptErrorrather than guessing. resume()restores harness-native session state; an in-flight turn that was killed mid-tool-call resumes as the harness decides, not seamlessly.
