Mission control for your coding agents. See every session, know which one needs you, and jump straight to it. Your sessions stay in your terminal.
36
stars
347
commits
Rust
primary language
Sep 10, 2026
updated
Know which coding agent needs you, without checking all terminal windows.
Picture a normal afternoon. Six agent sessions open: four still thinking, one that finished ten minutes ago and is waiting on your follow-up, one stopped on a decision it won't make without you. You can't tell which is which without tabbing through all six.
captain-miao is a TUI dashboard for every coding agent you have running, Claude Code, Codex and others. Each session gets a row that says what it's doing right now.
https://github.com/user-attachments/assets/e51ffc2f-0d6c-41c1-a825-0de32f2bed3a
npx @hyperlogue/captain-miao
Runs in the terminal you already use: Kitty, Ghostty, iTerm2, zellij or tmux. See Installation for Cargo, Nix and prebuilt binaries.
Unlike herdr or cmux, captain-miao embeds no terminal of its own. It drives the Kitty, Ghostty, iTerm2, zellij or tmux you already run (every session is a native window or pane, controlled through the terminal's own protocol), so it stays one small, focused tool and the rest of your workflow is yours to compose.
.envrc picks up that environment automatically (via direnv exec).caffeinate on macOS, systemd-inhibit on Linux).r3 watch waiting for your review, it flags as Review and surfaces as needing your attention.One supported terminal to drive, and at least one agent CLI on your PATH.
| Terminal | Notes |
|---|---|
| Kitty | Needs remote control enabled (Kitty setup). Most of features in captain-miao are designed around Kitty. |
| Ghostty ≥ 1.3 | macOS only, driven through Ghostty's AppleScript dictionary. Nothing in that API reads a window's screen, so there is no preview. |
| iTerm2 ≥ 3.0 | macOS only, driven through iTerm2's AppleScript dictionary. Previews are plain text — iTerm2 returns no colour. |
| zellij ≥ 0.44 | The stack layout is simulated by full screen floating windows. |
| tmux ≥ 3.2 | One window per session. |
Every one of them runs the whole dashboard; the notes above are the deltas. One cosmetic difference isn't among them: the header's paw is a real image only under Kitty, the single backend that speaks the kitty graphics protocol (and not from inside zellij or tmux, even in a Kitty window). Everywhere else it's a 🐾 glyph and clicking it does nothing.
| Agent | Notes |
|---|---|
| Claude Code | |
| Codex | Uses an owned captain-miao profile in your real CODEX_HOME; --profile / -p is therefore reserved on managed launches. No support for pasting in remote sessions, as Codex reads the clipboard in-process (details). |
| Reasonix | Token/model columns and worktrees don't work (known limits). |
| Kimi Code | Hooks can't be injected per-invocation, so a session runs under a synthetic KIMI_CODE_HOME. No fork and no worktrees (known limits). |
| Grok Build | Hooks via ~/.grok/hooks/captain-miao.json (no-op outside captain-miao). Token and model columns come off signals.json. Worktree name isn't shown on the row (known limits). |
| opencode | Has no hooks at all, so a session runs under a synthetic OPENCODE_CONFIG_DIR carrying a generated plugin. No worktrees (known limits). |
| Pi | Hooked with a generated extension passed as pi -e; nothing of yours is touched. No approval state (Pi has no per-tool gate), no resume-picker entries and no worktrees (known limits). |
| Antigravity | Runs under a synthetic $HOME that symlinks your real one, since agy reads hooks only from ~/.gemini/config/. No approval state, no fork, no worktrees, no token column, and an interrupted turn keeps reading as working (known limits). |
| omp | Hooked with a generated extension passed as omp -e; nothing of yours is touched. No resume-picker entries and no worktrees (known limits). |
[!NOTE] The Kitty/zellij + Claude Code/Codex have the best level of support and features. Other terminals and agents are either in experimental stage or feature incomplete due to the lack of API to customize their behavior.
cargo install --git https://github.com/hyperlogue/captain-miao
This installs the miao command (the project is captain-miao; the binary is short because you'll type it a lot).
Building needs a Rust toolchain and a C compiler (for the statically-bundled SQLite that reads Codex session titles).
No Rust toolchain, no build:
npx @hyperlogue/captain-miao # run it once
npm install -g @hyperlogue/captain-miao # or install the `miao` command
bunx @hyperlogue/captain-miao # same, with bun (no Node needed)
bun add -g @hyperlogue/captain-miao
The npm package is a small launcher that execs a prebuilt native binary shipped
as a per-platform optional dependency, so your package manager downloads only
the one binary matching your machine; nothing is fetched at runtime. Prebuilt
binaries cover macOS (Apple silicon + Intel) and Linux (x86-64 + arm64),
and are also attached to every
GitHub Release as
miao-v<version>-<target>.tar.gz if you'd rather download one directly.
Every prebuilt binary carries an x86-64 glibc miao-server, so it can set up a
remote host that has nothing installed on it — see Running sessions on remote
servers. A host on another architecture, or
one with no glibc, gets its server downloaded at deploy time instead. If you'd
rather not depend on that, each release also has a
miao-bundled-all-server-v<version>-<target>.tar.gz carrying every server
captain-miao publishes — larger, and never needs the network.
A flake is provided; run it straight from GitHub:
nix run github:hyperlogue/captain-miao
captain-miao drives Kitty over its remote-control protocol, so your kitty.conf must allow it. Remote control is a real privilege (a program that has it can read your terminal and run commands), so the tightest setup kitty offers pairs a password with an authorization script:
allow_remote_control password
remote_control_password "i-am-the-captain-miao" captain_miao_rc.py
listen_on unix:/tmp/mykitty
Kitty resolves that filename against your config directory, so put the script at ~/.config/kitty/captain_miao_rc.py:
# The only remote-control commands captain-miao issues.
ALLOWED_COMMANDS = frozenset({
"ls", "get-text", "launch", "focus-window",
"focus-tab", "close-window", "detach-window", "goto-layout",
})
def is_cmd_allowed(pcmd, window, from_socket, extra_data):
# Reject the in-terminal escape-code channel; only the listen_on socket gets in.
return from_socket and pcmd["cmd"] in ALLOWED_COMMANDS
Every request must now clear three checks: arrive over the socket (not the escape-code channel that a shell, even one across ssh, could otherwise use), carry the password, and name one of the commands above. i-am-the-captain-miao is captain-miao's built-in default, so this works as written; to use your own secret instead, set remote_control_password (above) and [kitty] rc_password in captain-miao's config to match. Keep the script the last item after the password; command names listed alongside it are allowed without ever calling your function.
Looser alternatives: allow_remote_control socket-only (off the escape-code channel, but no password and no allowlist) or allow_remote_control yes (no checks at all; avoid it). captain-miao verifies remote control at startup and exits with a diagnostic if it can't connect.
Keep the stack layout enabled. captain-miao's default Stacked session layout puts every session in one kitty tab and shows one at a time via kitty's stack layout. The default enabled_layouts * already includes it; if you've narrowed that list, add stack or sessions tile instead of stacking. (The alternate Per-tab layout, toggled with Space l, needs no particular layout.)
Run the dashboard inside a supported terminal (Kitty, Ghostty, iTerm2, zellij or tmux):
miao
From the dashboard, o / O start new sessions and r resumes existing ones. You can also drive captain-miao from the shell:
| Command | What it does |
|---|---|
miao | Run the TUI dashboard (the default). |
miao launch <agent> [dir] [args…] | Launch <agent> in dir (default .) with tracking hooks. Args starting with - (e.g. --resume) are forwarded straight to the agent. <agent> is one of claude, codex, reasonix, kimi, grok, opencode, pi, antigravity, omp — see per-agent limits. |
miao focus [--window-id <id>] | Focus the running dashboard window; with --window-id, also ring the session running in that Kitty window. |
miao hook <event> | Internal: forwards an agent hook event to the launcher. You won't run this yourself; it's wired up automatically. |
Sessions launched via miao launch <agent> are wrapped by a launcher process that injects the tracking hooks, so they show up in the dashboard automatically. Nothing is written to your global ~/.claude/settings.json or ~/.codex/hooks.json. For Codex, captain-miao writes one owner-only integration file: ~/.codex/captain-miao.config.toml (or the same file under $CODEX_HOME), loaded only for managed sessions through --profile captain-miao. Because Codex selects only one named profile, forwarding your own --profile / -p is unsupported; move settings needed in managed sessions into the base config.toml. Codex writes its own answers into whichever profile it was launched with, so a directory you trust inside a managed session is trusted for managed sessions, not for a bare codex run. The one place a launch is refused is a bare Ghostty window — see Ghostty setup.
Claude Code and Codex are the proven backends. The seven below all ship and track status; Antigravity and omp are the only two of them probed against a released binary, and the other five have not been run against one — report anything that looks wrong. A row stuck at Starting usually means the agent rejected our hook config.
Status, launch, resume and fork work.
reasonix setup works inside a session — its .env and config.toml are written into the synthetic home and moved back to your real ~/.reasonix on the next launch.Status, launch, resume and the title, token and model columns work. Esc mid-turn settles the row immediately, because Kimi reports an interrupt as a real hook. Written from Kimi's docs alone, so the least verified here.
f hides itself) — Kimi documents no flag to branch a resume, so the key would silently resume in place.Status, launch, resume, fork, worktrees, and the title, token and model columns all work. An interrupt (Esc / Ctrl+C) settles the row — Grok 1.0.4 fires StopCancelled for one.
Stop with live background work (backgroundTasks / /loop crons) does land on Task / Server / Review — an r3 watch is Review.~/.grok/hooks/captain-miao.json is always loaded, including in grok sessions you start yourself. miao hook exits immediately when it has no launcher socket, so those events are discarded.Ctrl+V pastes a screenshot on a Linux host offered the clipboard. Grok 1.0.5 otherwise never shells out to wl-paste in a displayless session, so a pooled launch sets the documented kill switch; a macOS host still needs clipboard-paste.Status, launch, resume, fork and the title, tool, token and model columns all work. opencode has no shell-command hooks, so captain-miao generates a JavaScript plugin for it.
Idle whatever else is still running.plugins/ is rebuilt to symlink each of them beside our generated captain-miao.js.permission.ask, so nothing it does can delay a decision you're being asked to make.Status, launch, resume, fork and the token, model and title columns all work.
pi -r opens Pi's own picker meanwhile.Starting is usually Pi's project-trust prompt — answer it in the session window.Status, launch, resume and the model column work, verified against agy
1.1.11. Antigravity's whole hook vocabulary is five events and none of them
fires at startup, on compaction, or while it waits on you, so most of what's
missing below is missing from the agent rather than from captain-miao.
Starting until your first prompt. Nothing fires when agy
starts, so that is the first moment a session can be identified.f hides itself) — conversations fork from inside Antigravity's
own TUI, and no flag does it at launch.$HOME that symlinks your real one, and
that applies to the commands the agent runs too. Existing paths resolve
through the links as usual; a brand-new top-level dotfile written inside a
session lands in the synthetic home and is set aside as .shadow-… on the
next launch rather than deleted. Your own ~/.gemini/config/hooks.json is
merged, not replaced.Status, launch, resume, fork and the approval, title, token and model
columns all work. omp is Oh My Pi, a
heavily-evolved fork of pi, and is hooked with a generated extension passed as
omp -e; nothing of yours is touched. Verified against omp v17.3.7.
Waiting for approval state works — omp has a per-tool approval gate
(tool_approval_requested / tool_approval_resolved), the one capability
pi lacks. s jumps to a session blocked on one.agent_end fires on an
aborted run too, so the case that costs Antigravity a stranded Active row
cannot arise.parentId shape pi's docs describe). omp -r opens omp's own
picker meanwhile.task spawns), but nothing enumerates them on any payload
we receive, so a settled turn reads as Idle whatever else is still running.Press ? in the dashboard for the complete list. The six you'll reach for most:
| Key | Action |
|---|---|
j/k, ↑/↓, Ctrl-n/p | Navigate sessions |
Enter | Focus the selected session's window, or attach one to a detached session (asking first if another client holds it) |
o / O | New session (same cwd / prompt for cwd) |
r / f | Resume picker (one host; Ctrl-h switches) / fork the selected session |
x / D | Kill the selected session / detach from it, leaving it running |
s | Jump to the next session needing attention |
| Key | Action |
|---|---|
gg / G | Jump to top / bottom |
1..9 / Ctrl-1..9 | Select Nth session / select and focus its window |
p / i | Pin / toggle needs-input on the selected session |
y | Copy the selected session id to the clipboard |
t / w | Move window to tab (Kitty and tmux) / switch to or open the cwd's work tab |
h/l, ←/→ | Scroll the preview horizontally |
Ctrl-u / Ctrl-d | Scroll the preview up / down |
R | Refresh the preview now |
Space v / Space d | Toggle the preview / detail panel |
Space i | Edit the selected directory's icon + color |
Space e / Space E | Restart the selected / all idle sessions |
Space z | Toggle keep-awake (inhibit OS sleep while sessions work) |
Space a / Space H | Set the default backend / default host for new sessions |
Space l | Switch session layout (stacked in one tab / one tab per session; not offered on tmux, Ghostty or iTerm2, which have only the one) |
Space h / Space s | Hosts panel (add, edit, port forwards, suspend with c, upgrade the host's server with u, connection log with l) / attach to a session, kicking the client holding it |
Space A | Attach a window to every detached session that's free to take (rows another client holds are skipped, not stolen) |
Space m | Message log — the footer's status messages, newest last (j/k, g/G to scroll; in memory only, last 200) |
? | Show the full key list (help overlay) |
/ | Search |
q / Ctrl-c | Quit |
Pressing Space (the leader) shows a which-key strip of the available follow-up keys in the footer.
In the cwd picker, Ctrl-t switches the backend for that one launch, Ctrl-h the host, and Ctrl-d drops the highlighted recent directory.
It opens pointed at the focused session's own workdir when that host's recent list holds it, so a single Enter starts a second session alongside the one you were looking at. That choice then follows Ctrl-h from host to host: a host that doesn't have the directory falls back to the top of its own list without losing it, so cycling on to one that does lands back on it. Moving the cursor picks a new one.
Custom keybindings. Every Normal-mode command above is remappable via a [keybinds] table in ~/.config/captain-miao/config.toml. Map a command id to a key (or list of keys); an empty list unbinds it:
[keybinds]
kill = "X" # move kill from x to X
jump_attention = ["s", "n"] # bind two keys to one command
restart = "space r" # remap a leader sequence
toggle_detail = [] # unbind a command
Keys parse forms like "ctrl+u", "O" (= "shift+o"), "space e", "enter", "f5", and arrow names. Ctrl-c, g g, and the 1..9 / Ctrl-1..9 selectors are fixed.
Command ids are the string in each Command::id(); the authoritative list lives in the DEFAULTS table in src/app/keymap.rs, and they match the actions in the key-bindings table above.
captain-miao reads an optional TOML file at ~/.config/captain-miao/config.toml (or $XDG_CONFIG_HOME/captain-miao/config.toml). Every key is optional and falls back to the default shown below; an unparseable file falls back to defaults rather than crashing. The complete set of options:
[terminal]
backend = "kitty" # "kitty" | "ghostty" | "iterm" | "zellij" | "tmux"; unset
# auto-detects (zellij, then tmux, then iTerm2/Ghostty,
# else Kitty)
sessions_layout = "stacked" # "stacked" | "per-tab" (the runtime Space l toggle overrides this;
# tmux, Ghostty and iTerm2 are always per-tab)
[kitty]
rc_password = "i-am-the-captain-miao" # the built-in default, and a published constant; set your own (see Kitty setup)
[launcher]
default_agent = "claude" # backend for new sessions: "claude" | "codex" | "reasonix" |
# "kimi" | "grok" | "opencode" | "pi" | "antigravity" | "omp";
# a name this build can't drive falls back to Claude
# (Space a overrides)
approval_grace_secs = 2 # grace window after a permission dialog before a transcript change reads as "dismissed"
max_recent_cwds = 50 # entries kept in the workdir picker's recent list
resume_list_limit = 50 # max sessions listed in the resume picker (most recent first)
new_tab_title = "{agent}: {basename}" # new-session tab title; placeholders: {agent} {basename} {cwd}
resume_tab_title = "{agent}: {basename}" # resumed-session tab title
pooled = false # run this machine's sessions in a local pty pool, so they
# survive closing the window; needs miao-server on PATH
[remote]
on_window_close = "close" # "close" | "detach": what closing a pooled session's window
# does to the session. Only a window *you* close counts — an
# attach that ends because its link died (a laptop resuming to
# a dropped ssh) always detaches, and the session keeps running.
[thresholds]
context_warning_tokens = 175000 # context usage turns to the warning color here
context_critical_tokens = 400000 # …and to the critical color here
preview_stale_secs = 20 # show "updated Ns ago" once the preview is older than this (0 = always)
[polling]
fs_reload_debounce_ms = 100 # debounce for filesystem-watch reloads
preview_debounce_ms = 200 # debounce before re-fetching the preview
event_poll_ms = 100 # input poll interval (floored at 10)
preview_auto_refresh_secs = 10 # auto-refresh the preview while focused + busy + unscrolled (0 disables)
[ui.panels]
preview_auto_min_height = 16 # min body height before the preview auto-shows
detail_auto_min_width = 70 # min body width before the detail panel auto-shows
detail_default_width = 36 # detail panel column width
narrow_max_width = 90 # at/below this body width the layout stacks vertically
[ui.table]
name_truncate = 35 # max characters of a session name before truncation
[colors.ui]
title_fg = "cyan"
header_fg = "cyan"
attention_fg = "yellow"
error_fg = "red"
highlight_bg = "dark_gray"
selection_fg = "blue"
selection_symbol = "❯ " # display width = cursor gutter width; keep the
# trailing cell unless the glyph paints within one
[colors.picker]
highlight_bg = "dark_gray"
chevron_fg = "blue"
[debug]
enabled = false # verbose logging; also enabled by CAPTAIN_MIAO_DEBUG=1
log_file = "debug.log"
keybind_log_file = "keybinds.log"
[keybinds]
# Remap any Normal-mode command: command-id = "key" or ["key", "alt"]; [] unbinds.
# command-ids are the Command::id() strings in src/app/keymap.rs (DEFAULTS table).
# e.g. kill = "X" / jump_attention = ["s", "n"] / restart = "space r"
Colors accept named values (cyan, dark_gray, …) or #rrggbb hex. The command ids for [keybinds] are the ones in the key-bindings table above (kill, jump_attention, restart, toggle_preview, …).
Add hosts with Space h. Each runs a miao-server daemon holding its sessions
in a pty pool, and the dashboard attaches local windows to them over ssh — so a
dropped connection or a slept laptop detaches windows without touching the
sessions, and reconnecting brings them back. Full design notes:
docs/remote-sessions.md.
The panel is where each host reports in: connection state and the reason when it
failed, session counts, daemon version, latency, and CPU + memory. l opens its
full connection log, c suspends it, u upgrades its server.
Enter attaches, Space A
attaches every free one, Space s steals a held one.x; set on_window_close = "detach" under [remote] for the opposite. A window lost to a dropped link
detaches instead, so a flaky network never costs you a session.Options takes verbatim ssh arguments, mainly port forwards
(-L 8080:localhost:3000), which come up and go away with the connection.
Everything else belongs in ~/.ssh/config.TERM is offered yours, so
sessions there stop falling back to xterm-256color. It asks first.PATH or one the dashboard deploys.
cargo xtask dist bundles servers into the binary (--list shows the
variants); carrying none for a host, it offers to download the published one.
miao --version reports what a binary carries.loginctl enable-linger "$USER" on any Linux host running the daemon,
or systemd-logind takes its sockets away at your last logout.A host's Clipboard field (Space h, e, then Space on it) offers that host
this machine's clipboard, so Ctrl+V in an agent running there attaches a
screenshot you just took here. It works by shadowing xclip/wl-paste on the
agent's PATH with a shim that asks back over an owner-only unix socket,
ssh-forwarded while the host is connected. A row that has it on shows 📋.
Only images are ever served. Text is not filtered out — it is never requested, so a remote can't read your password manager through this. It is off by default and per-host, because while a host is connected anything running as you there (including the agent, which runs arbitrary code by design) can read your clipboard when it holds an image.
Sharp edges worth knowing:
Ctrl+V here: it reads the clipboard in-process, so no shim
can serve it. Run clipboard-paste in the session instead — it writes the image
beside the agent and prints the path to hand it.wl-paste when WAYLAND_DISPLAY
is set, so a pooled launch without a display sets Grok's documented kill
switch and a dummy value rather than waiting on arboard. Reasonix, Kimi Code,
opencode and Pi are shimmed identically but untested: each works if it shells
out to xclip/wl-paste and silently does nothing if it reads the clipboard
in-process the way Codex does. clipboard-paste works on all of them
regardless, so treat it as the reliable route until one is confirmed.osascript,
which never reaches a shim. clipboard-paste is the whole story on such a host.captain-miao is built around a strict unidirectional data flow:
notify (FSEvents on macOS, inotify on Linux) and re-reads files when they change. It performs no IPC of its own.State lives under ~/.local/state/captain-miao/ and runtime sockets under $XDG_RUNTIME_DIR/captain-miao/, both owner-only: session state files record your prompt text, so they are written 0600 under a 0700 directory. For a deeper tour of the architecture, module layout, hook wiring, and data files, see AGENTS.md.
MIT. See LICENSE.
Rust
98.7%
Mission control for your coding agents. See every session, know which one needs you, and jump straight to it. Your sessions stay in your terminal.
36
stars
347
commits
Rust
primary language
Sep 10, 2026
updated
Know which coding agent needs you, without checking all terminal windows.
Picture a normal afternoon. Six agent sessions open: four still thinking, one that finished ten minutes ago and is waiting on your follow-up, one stopped on a decision it won't make without you. You can't tell which is which without tabbing through all six.
captain-miao is a TUI dashboard for every coding agent you have running, Claude Code, Codex and others. Each session gets a row that says what it's doing right now.
https://github.com/user-attachments/assets/e51ffc2f-0d6c-41c1-a825-0de32f2bed3a
npx @hyperlogue/captain-miao
Runs in the terminal you already use: Kitty, Ghostty, iTerm2, zellij or tmux. See Installation for Cargo, Nix and prebuilt binaries.
Unlike herdr or cmux, captain-miao embeds no terminal of its own. It drives the Kitty, Ghostty, iTerm2, zellij or tmux you already run (every session is a native window or pane, controlled through the terminal's own protocol), so it stays one small, focused tool and the rest of your workflow is yours to compose.
.envrc picks up that environment automatically (via direnv exec).caffeinate on macOS, systemd-inhibit on Linux).r3 watch waiting for your review, it flags as Review and surfaces as needing your attention.One supported terminal to drive, and at least one agent CLI on your PATH.
| Terminal | Notes |
|---|---|
| Kitty | Needs remote control enabled (Kitty setup). Most of features in captain-miao are designed around Kitty. |
| Ghostty ≥ 1.3 | macOS only, driven through Ghostty's AppleScript dictionary. Nothing in that API reads a window's screen, so there is no preview. |
| iTerm2 ≥ 3.0 | macOS only, driven through iTerm2's AppleScript dictionary. Previews are plain text — iTerm2 returns no colour. |
| zellij ≥ 0.44 | The stack layout is simulated by full screen floating windows. |
| tmux ≥ 3.2 | One window per session. |
Every one of them runs the whole dashboard; the notes above are the deltas. One cosmetic difference isn't among them: the header's paw is a real image only under Kitty, the single backend that speaks the kitty graphics protocol (and not from inside zellij or tmux, even in a Kitty window). Everywhere else it's a 🐾 glyph and clicking it does nothing.
| Agent | Notes |
|---|---|
| Claude Code | |
| Codex | Uses an owned captain-miao profile in your real CODEX_HOME; --profile / -p is therefore reserved on managed launches. No support for pasting in remote sessions, as Codex reads the clipboard in-process (details). |
| Reasonix | Token/model columns and worktrees don't work (known limits). |
| Kimi Code | Hooks can't be injected per-invocation, so a session runs under a synthetic KIMI_CODE_HOME. No fork and no worktrees (known limits). |
| Grok Build | Hooks via ~/.grok/hooks/captain-miao.json (no-op outside captain-miao). Token and model columns come off signals.json. Worktree name isn't shown on the row (known limits). |
| opencode | Has no hooks at all, so a session runs under a synthetic OPENCODE_CONFIG_DIR carrying a generated plugin. No worktrees (known limits). |
| Pi | Hooked with a generated extension passed as pi -e; nothing of yours is touched. No approval state (Pi has no per-tool gate), no resume-picker entries and no worktrees (known limits). |
| Antigravity | Runs under a synthetic $HOME that symlinks your real one, since agy reads hooks only from ~/.gemini/config/. No approval state, no fork, no worktrees, no token column, and an interrupted turn keeps reading as working (known limits). |
| omp | Hooked with a generated extension passed as omp -e; nothing of yours is touched. No resume-picker entries and no worktrees (known limits). |
[!NOTE] The Kitty/zellij + Claude Code/Codex have the best level of support and features. Other terminals and agents are either in experimental stage or feature incomplete due to the lack of API to customize their behavior.
cargo install --git https://github.com/hyperlogue/captain-miao
This installs the miao command (the project is captain-miao; the binary is short because you'll type it a lot).
Building needs a Rust toolchain and a C compiler (for the statically-bundled SQLite that reads Codex session titles).
No Rust toolchain, no build:
npx @hyperlogue/captain-miao # run it once
npm install -g @hyperlogue/captain-miao # or install the `miao` command
bunx @hyperlogue/captain-miao # same, with bun (no Node needed)
bun add -g @hyperlogue/captain-miao
The npm package is a small launcher that execs a prebuilt native binary shipped
as a per-platform optional dependency, so your package manager downloads only
the one binary matching your machine; nothing is fetched at runtime. Prebuilt
binaries cover macOS (Apple silicon + Intel) and Linux (x86-64 + arm64),
and are also attached to every
GitHub Release as
miao-v<version>-<target>.tar.gz if you'd rather download one directly.
Every prebuilt binary carries an x86-64 glibc miao-server, so it can set up a
remote host that has nothing installed on it — see Running sessions on remote
servers. A host on another architecture, or
one with no glibc, gets its server downloaded at deploy time instead. If you'd
rather not depend on that, each release also has a
miao-bundled-all-server-v<version>-<target>.tar.gz carrying every server
captain-miao publishes — larger, and never needs the network.
A flake is provided; run it straight from GitHub:
nix run github:hyperlogue/captain-miao
captain-miao drives Kitty over its remote-control protocol, so your kitty.conf must allow it. Remote control is a real privilege (a program that has it can read your terminal and run commands), so the tightest setup kitty offers pairs a password with an authorization script:
allow_remote_control password
remote_control_password "i-am-the-captain-miao" captain_miao_rc.py
listen_on unix:/tmp/mykitty
Kitty resolves that filename against your config directory, so put the script at ~/.config/kitty/captain_miao_rc.py:
# The only remote-control commands captain-miao issues.
ALLOWED_COMMANDS = frozenset({
"ls", "get-text", "launch", "focus-window",
"focus-tab", "close-window", "detach-window", "goto-layout",
})
def is_cmd_allowed(pcmd, window, from_socket, extra_data):
# Reject the in-terminal escape-code channel; only the listen_on socket gets in.
return from_socket and pcmd["cmd"] in ALLOWED_COMMANDS
Every request must now clear three checks: arrive over the socket (not the escape-code channel that a shell, even one across ssh, could otherwise use), carry the password, and name one of the commands above. i-am-the-captain-miao is captain-miao's built-in default, so this works as written; to use your own secret instead, set remote_control_password (above) and [kitty] rc_password in captain-miao's config to match. Keep the script the last item after the password; command names listed alongside it are allowed without ever calling your function.
Looser alternatives: allow_remote_control socket-only (off the escape-code channel, but no password and no allowlist) or allow_remote_control yes (no checks at all; avoid it). captain-miao verifies remote control at startup and exits with a diagnostic if it can't connect.
Keep the stack layout enabled. captain-miao's default Stacked session layout puts every session in one kitty tab and shows one at a time via kitty's stack layout. The default enabled_layouts * already includes it; if you've narrowed that list, add stack or sessions tile instead of stacking. (The alternate Per-tab layout, toggled with Space l, needs no particular layout.)
Run the dashboard inside a supported terminal (Kitty, Ghostty, iTerm2, zellij or tmux):
miao
From the dashboard, o / O start new sessions and r resumes existing ones. You can also drive captain-miao from the shell:
| Command | What it does |
|---|---|
miao | Run the TUI dashboard (the default). |
miao launch <agent> [dir] [args…] | Launch <agent> in dir (default .) with tracking hooks. Args starting with - (e.g. --resume) are forwarded straight to the agent. <agent> is one of claude, codex, reasonix, kimi, grok, opencode, pi, antigravity, omp — see per-agent limits. |
miao focus [--window-id <id>] | Focus the running dashboard window; with --window-id, also ring the session running in that Kitty window. |
miao hook <event> | Internal: forwards an agent hook event to the launcher. You won't run this yourself; it's wired up automatically. |
Sessions launched via miao launch <agent> are wrapped by a launcher process that injects the tracking hooks, so they show up in the dashboard automatically. Nothing is written to your global ~/.claude/settings.json or ~/.codex/hooks.json. For Codex, captain-miao writes one owner-only integration file: ~/.codex/captain-miao.config.toml (or the same file under $CODEX_HOME), loaded only for managed sessions through --profile captain-miao. Because Codex selects only one named profile, forwarding your own --profile / -p is unsupported; move settings needed in managed sessions into the base config.toml. Codex writes its own answers into whichever profile it was launched with, so a directory you trust inside a managed session is trusted for managed sessions, not for a bare codex run. The one place a launch is refused is a bare Ghostty window — see Ghostty setup.
Claude Code and Codex are the proven backends. The seven below all ship and track status; Antigravity and omp are the only two of them probed against a released binary, and the other five have not been run against one — report anything that looks wrong. A row stuck at Starting usually means the agent rejected our hook config.
Status, launch, resume and fork work.
reasonix setup works inside a session — its .env and config.toml are written into the synthetic home and moved back to your real ~/.reasonix on the next launch.Status, launch, resume and the title, token and model columns work. Esc mid-turn settles the row immediately, because Kimi reports an interrupt as a real hook. Written from Kimi's docs alone, so the least verified here.
f hides itself) — Kimi documents no flag to branch a resume, so the key would silently resume in place.Status, launch, resume, fork, worktrees, and the title, token and model columns all work. An interrupt (Esc / Ctrl+C) settles the row — Grok 1.0.4 fires StopCancelled for one.
Stop with live background work (backgroundTasks / /loop crons) does land on Task / Server / Review — an r3 watch is Review.~/.grok/hooks/captain-miao.json is always loaded, including in grok sessions you start yourself. miao hook exits immediately when it has no launcher socket, so those events are discarded.Ctrl+V pastes a screenshot on a Linux host offered the clipboard. Grok 1.0.5 otherwise never shells out to wl-paste in a displayless session, so a pooled launch sets the documented kill switch; a macOS host still needs clipboard-paste.Status, launch, resume, fork and the title, tool, token and model columns all work. opencode has no shell-command hooks, so captain-miao generates a JavaScript plugin for it.
Idle whatever else is still running.plugins/ is rebuilt to symlink each of them beside our generated captain-miao.js.permission.ask, so nothing it does can delay a decision you're being asked to make.Status, launch, resume, fork and the token, model and title columns all work.
pi -r opens Pi's own picker meanwhile.Starting is usually Pi's project-trust prompt — answer it in the session window.Status, launch, resume and the model column work, verified against agy
1.1.11. Antigravity's whole hook vocabulary is five events and none of them
fires at startup, on compaction, or while it waits on you, so most of what's
missing below is missing from the agent rather than from captain-miao.
Starting until your first prompt. Nothing fires when agy
starts, so that is the first moment a session can be identified.f hides itself) — conversations fork from inside Antigravity's
own TUI, and no flag does it at launch.$HOME that symlinks your real one, and
that applies to the commands the agent runs too. Existing paths resolve
through the links as usual; a brand-new top-level dotfile written inside a
session lands in the synthetic home and is set aside as .shadow-… on the
next launch rather than deleted. Your own ~/.gemini/config/hooks.json is
merged, not replaced.Status, launch, resume, fork and the approval, title, token and model
columns all work. omp is Oh My Pi, a
heavily-evolved fork of pi, and is hooked with a generated extension passed as
omp -e; nothing of yours is touched. Verified against omp v17.3.7.
Waiting for approval state works — omp has a per-tool approval gate
(tool_approval_requested / tool_approval_resolved), the one capability
pi lacks. s jumps to a session blocked on one.agent_end fires on an
aborted run too, so the case that costs Antigravity a stranded Active row
cannot arise.parentId shape pi's docs describe). omp -r opens omp's own
picker meanwhile.task spawns), but nothing enumerates them on any payload
we receive, so a settled turn reads as Idle whatever else is still running.Press ? in the dashboard for the complete list. The six you'll reach for most:
| Key | Action |
|---|---|
j/k, ↑/↓, Ctrl-n/p | Navigate sessions |
Enter | Focus the selected session's window, or attach one to a detached session (asking first if another client holds it) |
o / O | New session (same cwd / prompt for cwd) |
r / f | Resume picker (one host; Ctrl-h switches) / fork the selected session |
x / D | Kill the selected session / detach from it, leaving it running |
s | Jump to the next session needing attention |
| Key | Action |
|---|---|
gg / G | Jump to top / bottom |
1..9 / Ctrl-1..9 | Select Nth session / select and focus its window |
p / i | Pin / toggle needs-input on the selected session |
y | Copy the selected session id to the clipboard |
t / w | Move window to tab (Kitty and tmux) / switch to or open the cwd's work tab |
h/l, ←/→ | Scroll the preview horizontally |
Ctrl-u / Ctrl-d | Scroll the preview up / down |
R | Refresh the preview now |
Space v / Space d | Toggle the preview / detail panel |
Space i | Edit the selected directory's icon + color |
Space e / Space E | Restart the selected / all idle sessions |
Space z | Toggle keep-awake (inhibit OS sleep while sessions work) |
Space a / Space H | Set the default backend / default host for new sessions |
Space l | Switch session layout (stacked in one tab / one tab per session; not offered on tmux, Ghostty or iTerm2, which have only the one) |
Space h / Space s | Hosts panel (add, edit, port forwards, suspend with c, upgrade the host's server with u, connection log with l) / attach to a session, kicking the client holding it |
Space A | Attach a window to every detached session that's free to take (rows another client holds are skipped, not stolen) |
Space m | Message log — the footer's status messages, newest last (j/k, g/G to scroll; in memory only, last 200) |
? | Show the full key list (help overlay) |
/ | Search |
q / Ctrl-c | Quit |
Pressing Space (the leader) shows a which-key strip of the available follow-up keys in the footer.
In the cwd picker, Ctrl-t switches the backend for that one launch, Ctrl-h the host, and Ctrl-d drops the highlighted recent directory.
It opens pointed at the focused session's own workdir when that host's recent list holds it, so a single Enter starts a second session alongside the one you were looking at. That choice then follows Ctrl-h from host to host: a host that doesn't have the directory falls back to the top of its own list without losing it, so cycling on to one that does lands back on it. Moving the cursor picks a new one.
Custom keybindings. Every Normal-mode command above is remappable via a [keybinds] table in ~/.config/captain-miao/config.toml. Map a command id to a key (or list of keys); an empty list unbinds it:
[keybinds]
kill = "X" # move kill from x to X
jump_attention = ["s", "n"] # bind two keys to one command
restart = "space r" # remap a leader sequence
toggle_detail = [] # unbind a command
Keys parse forms like "ctrl+u", "O" (= "shift+o"), "space e", "enter", "f5", and arrow names. Ctrl-c, g g, and the 1..9 / Ctrl-1..9 selectors are fixed.
Command ids are the string in each Command::id(); the authoritative list lives in the DEFAULTS table in src/app/keymap.rs, and they match the actions in the key-bindings table above.
captain-miao reads an optional TOML file at ~/.config/captain-miao/config.toml (or $XDG_CONFIG_HOME/captain-miao/config.toml). Every key is optional and falls back to the default shown below; an unparseable file falls back to defaults rather than crashing. The complete set of options:
[terminal]
backend = "kitty" # "kitty" | "ghostty" | "iterm" | "zellij" | "tmux"; unset
# auto-detects (zellij, then tmux, then iTerm2/Ghostty,
# else Kitty)
sessions_layout = "stacked" # "stacked" | "per-tab" (the runtime Space l toggle overrides this;
# tmux, Ghostty and iTerm2 are always per-tab)
[kitty]
rc_password = "i-am-the-captain-miao" # the built-in default, and a published constant; set your own (see Kitty setup)
[launcher]
default_agent = "claude" # backend for new sessions: "claude" | "codex" | "reasonix" |
# "kimi" | "grok" | "opencode" | "pi" | "antigravity" | "omp";
# a name this build can't drive falls back to Claude
# (Space a overrides)
approval_grace_secs = 2 # grace window after a permission dialog before a transcript change reads as "dismissed"
max_recent_cwds = 50 # entries kept in the workdir picker's recent list
resume_list_limit = 50 # max sessions listed in the resume picker (most recent first)
new_tab_title = "{agent}: {basename}" # new-session tab title; placeholders: {agent} {basename} {cwd}
resume_tab_title = "{agent}: {basename}" # resumed-session tab title
pooled = false # run this machine's sessions in a local pty pool, so they
# survive closing the window; needs miao-server on PATH
[remote]
on_window_close = "close" # "close" | "detach": what closing a pooled session's window
# does to the session. Only a window *you* close counts — an
# attach that ends because its link died (a laptop resuming to
# a dropped ssh) always detaches, and the session keeps running.
[thresholds]
context_warning_tokens = 175000 # context usage turns to the warning color here
context_critical_tokens = 400000 # …and to the critical color here
preview_stale_secs = 20 # show "updated Ns ago" once the preview is older than this (0 = always)
[polling]
fs_reload_debounce_ms = 100 # debounce for filesystem-watch reloads
preview_debounce_ms = 200 # debounce before re-fetching the preview
event_poll_ms = 100 # input poll interval (floored at 10)
preview_auto_refresh_secs = 10 # auto-refresh the preview while focused + busy + unscrolled (0 disables)
[ui.panels]
preview_auto_min_height = 16 # min body height before the preview auto-shows
detail_auto_min_width = 70 # min body width before the detail panel auto-shows
detail_default_width = 36 # detail panel column width
narrow_max_width = 90 # at/below this body width the layout stacks vertically
[ui.table]
name_truncate = 35 # max characters of a session name before truncation
[colors.ui]
title_fg = "cyan"
header_fg = "cyan"
attention_fg = "yellow"
error_fg = "red"
highlight_bg = "dark_gray"
selection_fg = "blue"
selection_symbol = "❯ " # display width = cursor gutter width; keep the
# trailing cell unless the glyph paints within one
[colors.picker]
highlight_bg = "dark_gray"
chevron_fg = "blue"
[debug]
enabled = false # verbose logging; also enabled by CAPTAIN_MIAO_DEBUG=1
log_file = "debug.log"
keybind_log_file = "keybinds.log"
[keybinds]
# Remap any Normal-mode command: command-id = "key" or ["key", "alt"]; [] unbinds.
# command-ids are the Command::id() strings in src/app/keymap.rs (DEFAULTS table).
# e.g. kill = "X" / jump_attention = ["s", "n"] / restart = "space r"
Colors accept named values (cyan, dark_gray, …) or #rrggbb hex. The command ids for [keybinds] are the ones in the key-bindings table above (kill, jump_attention, restart, toggle_preview, …).
Add hosts with Space h. Each runs a miao-server daemon holding its sessions
in a pty pool, and the dashboard attaches local windows to them over ssh — so a
dropped connection or a slept laptop detaches windows without touching the
sessions, and reconnecting brings them back. Full design notes:
docs/remote-sessions.md.
The panel is where each host reports in: connection state and the reason when it
failed, session counts, daemon version, latency, and CPU + memory. l opens its
full connection log, c suspends it, u upgrades its server.
Enter attaches, Space A
attaches every free one, Space s steals a held one.x; set on_window_close = "detach" under [remote] for the opposite. A window lost to a dropped link
detaches instead, so a flaky network never costs you a session.Options takes verbatim ssh arguments, mainly port forwards
(-L 8080:localhost:3000), which come up and go away with the connection.
Everything else belongs in ~/.ssh/config.TERM is offered yours, so
sessions there stop falling back to xterm-256color. It asks first.PATH or one the dashboard deploys.
cargo xtask dist bundles servers into the binary (--list shows the
variants); carrying none for a host, it offers to download the published one.
miao --version reports what a binary carries.loginctl enable-linger "$USER" on any Linux host running the daemon,
or systemd-logind takes its sockets away at your last logout.A host's Clipboard field (Space h, e, then Space on it) offers that host
this machine's clipboard, so Ctrl+V in an agent running there attaches a
screenshot you just took here. It works by shadowing xclip/wl-paste on the
agent's PATH with a shim that asks back over an owner-only unix socket,
ssh-forwarded while the host is connected. A row that has it on shows 📋.
Only images are ever served. Text is not filtered out — it is never requested, so a remote can't read your password manager through this. It is off by default and per-host, because while a host is connected anything running as you there (including the agent, which runs arbitrary code by design) can read your clipboard when it holds an image.
Sharp edges worth knowing:
Ctrl+V here: it reads the clipboard in-process, so no shim
can serve it. Run clipboard-paste in the session instead — it writes the image
beside the agent and prints the path to hand it.wl-paste when WAYLAND_DISPLAY
is set, so a pooled launch without a display sets Grok's documented kill
switch and a dummy value rather than waiting on arboard. Reasonix, Kimi Code,
opencode and Pi are shimmed identically but untested: each works if it shells
out to xclip/wl-paste and silently does nothing if it reads the clipboard
in-process the way Codex does. clipboard-paste works on all of them
regardless, so treat it as the reliable route until one is confirmed.osascript,
which never reaches a shim. clipboard-paste is the whole story on such a host.captain-miao is built around a strict unidirectional data flow:
notify (FSEvents on macOS, inotify on Linux) and re-reads files when they change. It performs no IPC of its own.State lives under ~/.local/state/captain-miao/ and runtime sockets under $XDG_RUNTIME_DIR/captain-miao/, both owner-only: session state files record your prompt text, so they are written 0600 under a 0700 directory. For a deeper tour of the architecture, module layout, hook wiring, and data files, see AGENTS.md.
MIT. See LICENSE.
Rust
98.7%