A terminal-native AI coding agent built with Bun and pnpm.
labunbun -p "fix the failing tests" # headless: one prompt, print result
labunbun # interactive REPL
- Multi-provider LLM support — Anthropic, OpenAI, Google and any OpenAI-compatible API (DeepSeek, Kimi, GLM, OpenRouter, custom endpoints) via settings; the startup probe asks each provider holding a key what it serves and corrects the catalog from the answer.
- Core coding tools — Bash (incl.
run_in_backgroundwith BashOutput / KillBash), Read, Write, Edit (exact string replace with diff preview), Grep, Glob, LS, WebFetch, WebSearch; parallel execution of safe tools, started mid-stream as soon as their arguments finish arriving. - Task list — TaskCreate/TaskList/TaskGet/TaskUpdate let the agent plan and track multi-step work; progress renders in the REPL.
- Interactive dialogs — permission approvals plus structured AskUserQuestion multiple-choice prompts.
- Permission system — rule engine (
Bash(git *),Edit(src/**),mcp__server__*), five permission modes, interactive approval dialog, "don't ask again" session rules. - Sessions — append-only JSONL tree per project (
~/.labunbun/projects/), crash-safe resume with--resume, prompt history with ↑ recall. - Context management — automatic compaction at the context-window
threshold (structured summary + re-injected recent files),
/trimto replace old tool results with previews before paying for a summary (trimOldToolResultsdoes the same ahead of the threshold),/contextfor what the window is made of, and a live indicator measured against the compaction point — system prompt and tool schemas included, so it reads as full when the session is. - Hooks — user-configurable
PreToolUse/PostToolUse/Stop/SessionStart… command hooks with a JSON stdin/stdout contract. - MCP client — stdio + StreamableHTTP servers from
.mcp.json; tools merge into the registry asmcp__server__tool. - Subagents — the Task tool runs nested agent sessions (sidechain
transcripts persisted); custom agents via frontmatter
.mdfiles. - Skills —
SKILL.mdfolders become prompt-expanding slash commands. - Plan mode — read-only research then plan approval before mutations.
- Model fallback chain —
fallbackModelsin settings are tried in order when the primary model fails before streaming any content. - Cost — the built-in catalog carries each model's list price, and
pricingin settings overrides it (per"provider/model", for any model, built-in or not)./costreports the conversation you are in and the project it lives in as two separate totals, and names any model whose tokens it could not price rather than counting them as free.-p --output-format jsonreports the same arithmetic ascost_usd. - Terminal UX — virtualized transcript (sealed history + live tail),
ctrl+O full-transcript browser, vim modal editing (
vimMode: true), eight token-based themes withautobackground detection and third-party theme files. - DualShock 4 — drive the whole REPL from a controller: navigate lists and dialogs, confirm and cancel, approve or deny a permission, interrupt a turn, open the command wheel, scroll the transcript, tap and swipe the touchpad, and type with an on-screen keyboard without touching the keyboard. The lightbar follows the theme and what the app is doing, a small vocabulary of buzzes says what changed without looking, and the battery sits in the status line. Bindings are yours to change, and the motors and the light can each be switched off — see Gamepad.
- Headless output —
--output-format text|json|stream-json. - Config import —
labunbun migratemaps an existing agent-tool setup (Claude Code, Codex, ZCode,~/.agents) onto labunbun's own config: settings, skills, rules, slash commands, past conversations and the prompts ↑ recalls. Dry run by default, and a/migratewizard that asks what to take — or takes everything after one question.
pnpm install
export ANTHROPIC_API_KEY=sk-... # or DEEPSEEK_API_KEY etc.
bun run dev # interactive REPL
bun run dev -p "list files here" # headless~/.labunbun/settings.json:
{
"model": "myprovider/my-model",
"providers": {
"openaiCompatible": [
{
"id": "myprovider",
"baseUrl": "https://api.example.com/v1",
"apiKeyEnv": "MYPROVIDER_API_KEY",
"models": [
{
"id": "my-model",
"contextWindow": 128000,
"maxOutputTokens": 8192,
"pricing": { "input": 0.6, "output": 2.2, "cacheRead": 0.11 }
}
]
}
]
}
}pricing is USD per million tokens (cacheRead/cacheWrite default to 0,
which is what an API that does not bill cached tokens separately means). A
top-level pricing map — { "anthropic/claude-sonnet-5": { "input": 1.5, "output": 7.5 } } — overrides the catalog's own list prices, which is how a
gateway or a negotiated rate gets costed correctly. Without a price, tokens are
counted and /cost says they could not be costed; it does not report them as
free.
Already configured another agent tool? Copy over what has an equivalent:
bun run dev migrate # dry run: report only, writes nothing
bun run dev migrate --from codex # one source (claude-code | codex | zcode | agents | all)
bun run dev migrate --only settings # categories: settings | assets | history | all
bun run dev migrate --apply # write it
bun run dev migrate --apply --force # also overwrite values that existSources are only read, never modified. Model names, env, MCP servers,
permission rules, skills, agents (~/.labunbun/agents/) and rules carry over,
and a skill directory travels whole — a body pointing at references/x.md finds
it on the other side, and the supporting files that could not come (binary, too
large) are counted in the report. Slash commands (~/.claude/commands/**)
arrive as skills with $ARGUMENTS expanded; a frontmatter key with no
equivalent here (allowed-tools, model, argument-hint) is named in the
report rather than written as if it worked. Codex's ~/.codex/rules/*.rules
become permission rules — Codex matches a parsed argv prefix where labunbun
matches the whole command line, so a chained git commit && … matches here too.
Anything without an equivalent is reported as skipped with a reason rather than
dropped silently, keys the importer does not know included: they are listed by
name, never by value. Existing values are kept unless --force says otherwise.
The report names every written file that ends up holding a credential.
Past conversations import as sessions under ~/.labunbun/projects/<cwd>/, so
--continue finds them, and the prompts you typed import into
~/.labunbun/history.jsonl, so ↑ recalls them in the directory each was typed
in. Both answer to the same scope: only the current project is taken by default
— --history-scope all goes across projects, none skips history entirely —
and --history-limit <n> (default 20) caps the sessions from each source, with
a separate cap for prompts. A tool call whose other half is missing is dropped
on the way in: a transcript the messages API would reject is worse than a
shorter one.
/migrate in the REPL asks rather than assumes. The first question offers
Import everything — every source found, every category — or Choose… for the
step-by-step questions; either way it asks once about history, prints the same
dry-run report, and writes only after you confirm. --migrate is the same
command as the subcommand, for when the flag is easier to type than the word.
/theme # list every theme, marking the active one
/theme high-contrast-dark # switch immediately and remember the choice
/theme auto # match the terminal background| Name | For |
|---|---|
dark |
default; follows the terminal's own palette |
light |
light backgrounds, where terminal-default colors wash out |
high-contrast-dark |
maximum contrast on dark, every state bold |
high-contrast-light |
maximum contrast on light, every state bold |
deuteranopia-dark |
red/green color blindness — success is blue, not green |
tritanopia-dark |
blue/yellow color blindness — avoids the blue/green pair |
spiderman |
red and blue |
splatoon |
green and magenta |
theme in settings.json selects one; "auto" asks the terminal for its
background color (OSC 11, then COLORFGBG) and picks the theme that states that
appearance — one of yours if any of them does, the matching built-in otherwise.
Detection never blocks startup: a terminal that does not answer gets dark.
State is never carried by color alone. Success, warning, error, pending and the selected row each render a symbol as well, so the transcript stays readable to a colorblind reader and through anything that strips ANSI.
Drop a JSON file in ~/.labunbun/themes/ (or .labunbun/themes/ for one
project, which wins on a name collision):
{
"name": "midnight",
"appearance": "dark",
"extends": "dark",
"tokens": {
"accent": "#7aa2f7",
"error": "#f7768e",
"codeText": "#9aa5ce",
"marks": { "error": "×" }
}
}extends names a built-in that supplies every token the file leaves out, so a
theme that changes a handful of colors does not have to restate the two dozen it
is happy with. Values are anything Ink accepts: "red", "#d55e00",
"rgb(215,95,0)".
The full token list is the Theme interface in
packages/tui/src/themes/tokens.ts, where each token documents what it colors.
A broken theme file never stops the REPL from starting; run /doctor to see
which file failed and why — including misspelled token names, which otherwise
just do nothing.
A DualShock 4 (USB or Bluetooth) can drive the REPL on its own — no keyboard needed to answer a permission, pick a model, or type a prompt.
/gamepad on # read a controller now, and remember it
/gamepad status # transport, battery, reports, last write, which bindings you changed
/gamepad watch # every change as a line: press a button, read its name
/gamepad list # every controller interface the OS reports
/gamepad reset # let go of it and look again, now — the rescue for a stuck pad
/gamepad approve # may ✕ answer a permission dialog? (on|off, no argument toggles)
/gamepad rumble # buzz once — the quickest check that writing works
/gamepad # the current button and gesture → action table--gamepad turns it on for one run without writing anything; --no-gamepad
turns it off the same way.
| Button | Action |
|---|---|
| D-pad, left stick | Move: lists, dialogs, the command wheel, the transcript |
| Right stick (vertical) | Scroll the transcript, speed with the offset |
| L2 / R2 | Modifiers, not rebindable: fine (160 ms) and fast (30 ms) repeats |
| ✕ | Confirm — send, allow, choose. Held 600 ms in a permission dialog: always allow |
| ○ | Cancel — close, deny, and interrupt a running turn |
| □ | Clear the screen; backspace in the on-screen keyboard |
| △ | Command wheel; shift in the on-screen keyboard |
| L1 / R1 | Page back / forward in lists, the transcript, and the keyboard |
| Options | Transcript browser |
| Share | On-screen keyboard |
| Touchpad press | /status |
| L3 / R3 | Open /model / pick the permission mode |
| PS | Nothing on purpose — the OS and Steam claim it |
Movement repeats: 400 ms before the first repeat, then every 80 ms, or at the L2/R2 rates above. Battery appears in the status line while a pad is connected, and the lightbar takes the theme's accent — brighter while a turn is running, flashing in the permission colour while a dialog is waiting, red when the battery is low.
A waiting dialog also blinks: half a second on, half a second off, in the pad's own hardware. The software pulse only dims the bar, and a bar that dims is one you can miss from the sofa — which is the one thing a question must not be. It is the only state that asks for a blink: the battery warning already reaches black on its own, and two rhythms over one light is a flicker.
The motors have a small vocabulary, and it is a sentence each rather than a volume:
| Felt | Meaning |
|---|---|
| One short tap | A controller was found |
| A firmer tap | Work started |
| A long, deep note | The turn finished |
| Two quick taps | You stopped it yourself |
| The faintest tap | A no from the pad: ○ on a dialog, or ✕ where it may not approve |
| A hard kick, both motors | Something wants a decision |
| Both motors, gently | The battery just crossed into the low band |
Two of those are felt even if something else buzzed a moment earlier — a question, and the battery crossing — because each happens once, and the one that is dropped is the one that mattered. Everything else waits its turn: two buzzes a moment apart read as one stutter, not as two pieces of news.
The touchpad is a second input and not a mouse: a finger on it is a position and a gesture, never a cursor.
| Gesture | Action |
|---|---|
| Tap | Confirm, exactly like ✕ |
| Drag | Move — one step per eighth of the surface, in the direction the finger goes |
| Two-finger slide | Page back / forward in lists and the transcript |
They are bindings like any other, under the names /gamepad prints:
touch-tap, touch-up, touch-down, touch-left, touch-right,
touch-two-left, touch-two-right.
A tap confirms but never holds. The ✕-hold is a real thing — a permission
dialog reads 600 ms of ✕ as "always allow" — and a gesture lasts exactly one
report, so the only way one could ever reach that threshold is the reports
stopping: a pad that went to sleep with a finger resting on it. A controller in
a bag must not be able to answer a dialog on its own, so gestures are edges by
construction and buttons keep the hold. allowApprove governs the tap in a
permission dialog exactly as it governs ✕.
Buttons keep the hold on the same principle, measured the same way: 600 ms of
the pad reporting ✕ down, not 600 ms of wall clock. A controller that idles
out mid-press and comes back with the button still under a thumb spent that
time saying nothing, so the time is not credited — the press keeps its true age,
which is what a dialog reads to ask whether it was aimed at it. Nothing is
invented on the way back either: the button is neither pressed again nor
released, the hold simply starts over. A silence here means a quarter of a
second with nothing from the pad (REPORT_GAP_MS in
packages/gamepad/src/service.ts) — dozens of reports at the rate a live DS4
sends them, and an eighth of the two seconds after which the pad is declared
gone and everything is forgotten.
The thresholds — 250 ms and a few surface units for a tap, an eighth of the
height for a step — are constants in packages/gamepad/src/touch.ts, named so
that tuning the feel is editing one number.
A controller that is plugged in and switched on is attached twice: the OS
lists two collections of one pad, and nothing on either says they belong
together. Both are opened and both are written to in their own shape — 32 bytes
over the wire, 78 with a CRC over the radio — so the bar and the motors work
whichever link the pad is obeying. /gamepad status says so:
transport: usb
links: usb + bluetooth (reading usb)
Buttons are read from one link at a time, the wire first, and the reading moves to the other link when that one goes quiet. That is what makes the cable a non-event in both directions: pull it and the radio takes over mid-press without inventing a release; plug it in mid-session and the new link is picked up within a second and written to from the next frame.
Two controllers switched on at once look exactly like this, because nothing on either collection says which device it belongs to: two links, one of them read and both of them written to. A ✕ on the pad in the bag then moves the session the moment the one in your hands stops reporting, and the bag's bar follows the screen. There is no identity to sort them by, so nothing is sorted — but a whole second of the two links reporting different buttons (one pad's links are a few milliseconds apart at an edge, never more) is worth saying out loud:
both links report — these may be two controllers (pin one with the device filter)
The filter is the way out: "device": "wireless", or a path from /gamepad list, leaves the other collection alone.
Bindings, the device filter and the deadzone live in settings.json:
{
"gamepad": {
"enabled": true,
"deadzone": 0.25,
"device": "wireless",
"rumble": true,
"lightbar": true,
"bindings": { "cross": "confirm", "r2": "command:/status", "square": "none" },
"phrases": ["explain what you just did", "run the tests"]
}
}rumble and lightbar default to on, and either can be turned off for someone
who does not want a controller that moves or glows on its own. Off is silence
and darkness rather than less of them: a packet describes the whole pad, so a
field left out is a field written as zero. The motors stay still, the bar goes
dark, and /gamepad status says which of the two is off — as does
/gamepad rumble, which is the one check that needs no screen and would
otherwise buzz into the void.
/gamepad with no argument prints the button ids and the surface's gestures,
which are the names this file writes. A binding that names a button, a gesture,
an action or a command that does not exist costs that one binding and is
reported at startup and in /doctor — the rest of the file is unaffected.
Approving from the pad is off unless you say otherwise. allowApprove is a
user-tier setting: a project's .labunbun/settings.json cannot set gamepad at
all, because a button held down in a pocket is not a person deciding, and a
cloned repository must not be able to hand its own tool calls to a controller
lying in your lap. With it off, ✕ does nothing in a permission dialog and the
keyboard answers; the pad still navigates.
node-hid is an optional dependency. Without it the rest of labunbun is
exactly as it was; /gamepad on reports which command installs it. When it is
installed, pnpm bin:build carries it along: bun build --compile embeds the
native prebuild, and the resulting executable reads a controller with no
node_modules next to it. (Verified against node-hid 3.4.0 on Windows; nothing
needs --external.)
| Package | Purpose |
|---|---|
@labunbun/ai |
Provider-neutral message model, streaming protocol, Anthropic/OpenAI-compat adapters, retry, faux test provider |
@labunbun/agent |
Agent loop, Tool interface, execution pipeline, permission engine, JSONL session tree, compaction |
@labunbun/tools |
Built-in coding tools behind an FS/exec operations abstraction |
@labunbun/mcp |
MCP client (stdio/HTTP), tool adaptation |
@labunbun/tui |
React Ink REPL: store, message views, editor, dialogs, themes |
@labunbun/gamepad |
DualShock 4: report parsing, button mapping, lightbar and rumble rules, the device source (node-hid, optional) |
@labunbun/coding-agent |
CLI entry, settings hierarchy, commands, memory, hooks, subagents, skills |
Dependency direction is strictly layered: ai ← agent ← tools/mcp/tui ← coding-agent.
gamepad is a leaf with no runtime dependency at all — tui takes its types and
pure functions, and coding-agent owns the single place node-hid is imported.
The loop never imports provider adapters directly — they arrive via injected
StreamFn, which is what makes the zero-network faux-provider test strategy work.
pnpm typecheck # tsc over all packages (source-mapped, no build step)
pnpm test # bun test — 2000+ tests, no network needed
pnpm lint # biome check
bun run scripts/smoke.ts anthropic/claude-sonnet-5 # live smoke test
bun run scripts/gamepad-probe.ts # a controller, without the app in the way
bun run scripts/gamepad-probe.ts --touch # measure the touchpad: decoded points + raw bytes
pnpm bin:build # standalone executable via bun build --compileTypeScript runs in erasable-syntax-only mode and packages export their src/
directly — Bun executes TS natively, so there is no build step in the dev loop.
- User:
~/.labunbun/—settings.json,.mcp.json,MEMORY.md,rules/*.md,agents/,skills/,themes/, plusprojects/<cwd>/for that project's sessions and MCP approvals - Project:
.labunbun/—settings.json,settings.local.json,rules/*.md,agents/,skills/,themes/ - Project and local settings are read as repo-controlled: they may not set
model,fallbackModels,permissionMode,env,providers,hooks,mcpServers,pricing,trimOldToolResults,gamepad,permissions.allow, orpermissions.additionalDirectories. Those are honored from the user, policy (managed-settings.json), and--settingstiers only; anything dropped is listed at startup.permissions.denyis still honored from every tier — tightening is always allowed. Whethersettings.local.jsonis committed is up to you; labunbun writes no ignore rule for it. - Memory files:
LABUNBUN.mdorAGENTS.mdper directory, walked cwd → root - Base URLs are overridable per provider via
<PROVIDER>_BASE_URL, e.g.ANTHROPIC_BASE_URLfor a gateway or proxy - Inside the workspace,
.git/is not writable by the agent — in any mode, and including a nested repository's own.gitor a symlink that resolves into one. Rewriting history is not an edit the user can undo, so it is not something a permission can grant. Reading git metadata is unaffected (gitruns as usual).
If LaBunbun Code saves you time, consider supporting development:
| Network | Address |
|---|---|
| BTC | bc1qv9zhpzzdddyakzsetgwr4tkznl4ycsuxn7d00g |
| ETH | 0x8dFB632F494C694a1a0Ff4CC2566617230530020 |
| SOL | AdryGzPCKyH5PPzEmZ9ZxW77A5kCbBuapmrqeYFGcPna |
MIT © 2026 zayoka — see LICENSE.