orbit
A dashboard for the coding-agent sessions running on your machine — Claude Code,
Codex and GitHub Copilot CLI, across every project. Meant to live in its own
terminal tab.
It answers the question you can't answer by staring at a wall of tabs: which
session is blocked on me right now? Sessions run inside tmux, so closing a tab
never kills work.
██████╗ ██████╗ ██████╗ ██╗████████╗
██╔═══██╗██╔══██╗██╔══██╗██║╚══██╔══╝
██║ ██║██████╔╝██████╔╝██║ ██║
██║ ██║██╔══██╗██╔══██╗██║ ██║ ▲ needs attention 1 · ● working 2
╚██████╔╝██║ ██║██████╔╝██║ ██║ 18 of 77 sessions · claude · codex · copilot
╚═════╝ ╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝
╭─ SESSIONS · RECENT ────────────────────────────╮ ╭─ LIVE · WORK/API-GATEWAY ──────────────────╮
│ ▌ ▲ cl work/api-gateway 2m │ │ Refactor batch runner │
│ ▌ Refactor batch runner needs attention │ │ ~/work/api-gateway │
│ ⠸ cx src/widgets 40m │ │ claude · main · 46 msgs · 2m ago ▲ attention│
│ Evaluate the docs of this repo working │ │ │
│ ◆ cl services/billing 1h │ │ ▸ last prompt │
│ Add retry to the webhook queue finished │ │ now run the tests │
│ · cp work/docs-site 3d │ │ │
│ Integrate Copilot in Actions │ │ ▸ live output │
│ │ │ ● Bash(go test ./...) │
│ │ │ Do you want to proceed? │
╰────────────────────────────────────────────────╯ ╰────────────────────────────────────────────╯
⏎ attach tab drive n new d dispatch / filter ? shortcuts
Why
Agent CLIs each ship a --resume picker, but every one of them only sees the
directory you're standing in. If you run several agents across several repos,
nothing tells you where they all are, which are still alive, or which one has
been sitting on a permission prompt for ten minutes. orbit is that missing view.
Install
macOS, via Homebrew — a prebuilt binary, nothing to compile:
brew install sadrig91/tap/orbit
Linux, or without Homebrew:
go install github.com/sadrig91/orbit/cmd/orbit@latest
Or download a binary for your platform from
Releases.
tmux 3.4 or newer is required either way — that's what Ubuntu 24.04 LTS ships,
and orbit checks on start rather than misbehaving later.
Ghostty (1.3+ for the best experience) or iTerm2 is
optional but gets you tab spawning and desktop notifications.
Staying current
orbit checks GitHub for a newer release when it starts — at most once a day,
cached in ~/.cache/orbit/update.json — and installs it the same way this
copy was installed: brew upgrade for a Homebrew install, go install for a
go-installed one, and a checksum-verified binary swap otherwise. The status
line says what it's doing, and orbit restarts itself once the new version is
in place and has confirmed its own version.
Turn it off in ~/.config/orbit/config.toml:
[update]
auto = false
or per run with --no-update / ORBIT_NO_UPDATE=1.
The Go tests cover the pieces; the last step — orbit exec'ing the new build
over itself — needs a real terminal, so it has a script instead:
./scripts/check-self-update.sh
It builds a deliberately stale orbit in a scratch directory with its own
HOME, drives it under a pty, and checks that it announces the update,
replaces its own binary, and survives the restart with the same pid. Your
install, config and update cache are not touched, and it never runs brew.
Releasing
Pushing a v* tag is the whole process: CI cross-compiles for macOS and Linux
on both architectures, publishes the archives and checksums with generated
notes, and updates the Homebrew cask in
SadriG91/homebrew-tap. It needs a
HOMEBREW_TAP_TOKEN secret, because the release runs in this repo and
GITHUB_TOKEN cannot write to a different one.
Keys
| key |
action |
⏎ |
attach — resumes the session first if it isn't running |
tab |
switch focus between sessions and the selected live pane |
ctrl+f |
toggle the focused live pane full screen |
ctrl+e |
toggle a wider live pane while keeping sessions visible |
i |
attach in this terminal; returns to orbit when you detach |
t |
attach in a new Ghostty tab |
w |
attach in a new Ghostty window |
s |
summarise this session with a cheap model, then cache it |
S |
queue every visible session that has no summary yet |
f |
full-text search inside transcripts |
o |
cycle sort: age / tokens / project / agent |
p |
cycle grouping: off / project folder / agent |
n |
new session, same agent and dir, opened inside orbit |
d |
dispatch a task to an agent headlessly, in that dir |
x |
kill the tmux session (the transcript is untouched) |
/ |
filter by title, path, branch or agent |
a |
show everything (default hides untitled + older than 30d) |
r |
refresh now |
? |
open the keyboard shortcut helper |
q |
quit the dashboard — running sessions carry on |
⏎ picks the best available: a Ghostty or iTerm2 tab on macOS, otherwise
in-place. If the session is already showing in a tab, ⏎, t and w switch
to that tab instead of opening a second one; if its tab can't be found any
more, a new one opens as before.
n starts a normal interactive agent in tmux and keeps orbit on screen. Its
live pane gets focus immediately. tab switches focus between the session list
and that pane without replacing the terminal with a text preview. The terminal
stays muted while its row remains selected. Moving through the list replaces it
with the newly selected session's preview; pressing tab focuses that session.
When the selected transcript is dormant, tab first resumes it in tmux and
focuses it as soon as it is ready. The row and detail pane show one preparation
state while Orbit
waits for the agent—not just the shell or tmux pane—to be ready. The resume
command and shell startup stay hidden; the row and terminal become live
together. Once the pane is focused, normal keys—including Shift+Tab—go to
the agent. ctrl+f toggles
full screen, ctrl+e gives the pane more width without hiding the session list,
the mouse wheel scrolls the agent when it is over the live pane, and ctrl+g
is a second way back to sessions. Orbit keeps wheel reporting enabled so it
cannot turn into accidental arrow-key navigation after returning to the session
list, but only routes wheel events while the live pane has focus. The tmux
session keeps running in the background, and ⏎ still
attaches it to a full terminal whenever that is more comfortable.
/ filters titles and paths as you type; f searches message bodies and runs
on Enter. esc clears a search. Press ? for the full shortcut list without
leaving the dashboard; ? or esc closes it. p groups the Sessions pane
into contiguous project-folder or agent sections, with a count in each heading.
Grouping keeps the current o sort: the first session surfaced by that sort
positions its group, and the same ordering is preserved inside the section.
Search and summaries
Titles are terse — "Check branch status against main" doesn't say which branch —
so f searches the transcript bodies themselves and shows the matching text in
the detail pane. Nothing is held in memory; the files are scanned on demand,
which across ~80 sessions takes under 200ms.
s summarises a session in two or three sentences and caches the result.
A summary records how much of the conversation it covers. When you continue a
session it goes stale rather than invalid: the detail pane says how many
messages it hasn't seen, and the next regeneration sends the existing summary
plus only the new messages. Input stays roughly constant no matter how long a
conversation runs, instead of re-reading the whole transcript every turn — which
on an active session would bill a full summarisation per prompt.
Rolling summaries compound their own omissions, so after five incremental
updates the next one rebuilds from the transcript. An update is also skipped in
favour of a rebuild when the unseen part is most of the conversation, where
building on the old text buys nothing.
Automatic regeneration (auto = true) is the only thing that spends money
without being asked, so it is guarded: it waits until a session is
auto_min_new_messages behind (default 8) and never fires while a turn is in
flight, since the transcript is still being written.
Summaries run through each provider's own CLI in non-interactive mode
(claude -p, codex exec, copilot -p), so the work is billed to that agent's
existing subscription and orbit needs no API keys. The command is configurable
per provider — point it at the cheapest model each one offers, since this is a
summarising job rather than a reasoning one:
orbit owns these commands and updates them with each release, so they are not
written into your config. They depend on things that move underneath us: which
flag stops a CLI recording a session of its own, which models a provider will
accept, which subcommand the installed version has. A copy frozen into your
file on install day goes wrong quietly — a default that turned out to be
rejected by every ChatGPT-account login stayed that way, unfixable, because
orbit couldn't tell it from a value you'd chosen.
Write one yourself to take it over, and orbit leaves it alone for good:
[summary.codex]
command = ["codex", "exec", "--ephemeral", "--model", "something-cheaper"]
Keep the flag that stops the CLI recording a session if you do —
--no-session-persistence for claude, --ephemeral for codex — or every
summary leaves a conversation of orbit's own on disk, resumable and counted in
the token sort. Copilot has no equivalent, so orbit runs all three in
~/.cache/orbit/scratch and hides sessions found there.
A command orbit itself wrote into an older config is retired on the next start,
with a note saying so. One you wrote is never touched.
Generation takes around ten seconds and runs in the background. The bar in the
header is global and measures coverage — how many of the sessions on screen
have a summary — so it advances only when one finishes, never on time elapsed.
S queues everything still missing one, two at a time, since each job is a
whole agent process and running more in parallel just makes each slower.
Ghostty also gets a native progress indicator on the tab while work is in hand.
Because summarising deliberately uses cheap models, and cheap models have the
smallest context windows, only a thin slice of the transcript is sent: the
opening of the conversation plus its tail, skipping the bulky tool output in
the middle. max_input_chars caps it (12000 ≈ 3k tokens), overridable per
provider if you point one at a bigger model.
Set auto = true under [summary] to summarise whatever you're sitting on
without pressing s. It's off by default because it spends tokens as you browse.
State
| icon |
meaning |
▲ |
needs attention — a permission prompt, or a dispatch that stopped |
◆ |
finished — the agent completed its last response |
● |
working |
○ |
tmux session alive, but the agent has exited (just a shell) |
· |
not running — ⏎ resumes it |
Each row also carries the session's token usage, read from whatever the agent
recorded — Claude's per-message usage, Codex's total_token_usage, Copilot's
assistant_usage_events table. o sorts by it.
State comes from the agent itself where possible. Sessions orbit starts carry
a hook — injected per invocation, nothing written into your config — that
reports each change as it happens, so a permission prompt shows as ▲ the
second it is drawn rather than after a heuristic delay. Claude sessions have
this today; codex and copilot are on the older path.
Dispatched sessions (d) don't infer anything at all: orbit started the
process and is reading the CLI's own event stream, so "working" means an event
said so.
Everything else falls back to joining the transcript with live tmux facts: an
unanswered tool call that stops advancing reads as a permission prompt. That
guess is right for prompts and wrong for slow tools, which is exactly why the
hooks exist. Copilot keeps a database rather than an event stream, so its
fallback states are coarser still — though only in the dashboard's usual mode:
its non-interactive CLI has the richest event stream of the three, which is
what dispatch uses.
Dispatch
d opens a dispatch composer in the dashboard's right pane, keeping the
Sessions list visible. The selected session supplies the initial agent and
directory, but neither is binding: edit the directory with Tab, use Up/Down
to choose from known project directories (or type any path), and begin the task
with @claude, @codex, or @copilot to choose the agent explicitly. For example:
@codex can you check feature X?
With no mention, orbit uses the default shown in the composer. Relative
directories are resolved from the directory where orbit was started, and ~
is supported. The run appears in the list like any other session — working,
then waiting for you.
A dispatch is not a separate kind of session. All three CLIs write to their
ordinary stores in non-interactive mode, so ⏎ on a finished dispatch resumes
it in the interactive TUI with the whole conversation in front of you, and
carries on appending to the same transcript. Dispatch starts work; taking it
over is the same key as everything else.
The runner is orbit itself, in a tmux session on orbit's private server, which
is why it behaves like the rest of the dashboard: it survives quitting orbit,
x kills it, and sitting on the row shows the live output. When the run ends
the pane goes with it, leaving a session to resume. The narration is also kept
in ~/.cache/orbit/dispatch/<id>.log.
Approvals stop the run rather than guessing. Claude is the only one of the
three that can ask for permission non-interactively, and it asks orbit rather
than you. Orbit always answers the same way: it interrupts the turn and marks
the session ▲ needs attention, with the exact command it wanted to run in the
detail pane. ⏎ then resumes it where it stopped. Nothing is approved on your
behalf, and nothing is silently refused — which is what the alternative would
have been, since a dispatched claude that cannot ask simply gets its tool denied
and talks its way around it.
Codex and copilot have no approval channel in this mode at all, so a dispatch
to either runs to the end under whatever their own settings permit. Copilot
goes further: its CLI requires --allow-all-tools to run non-interactively, so
orbit will not dispatch one until you have said so in config.
[dispatch]
timeout = "30m" # how long a run may take before orbit stops it
claude_permission_mode = "" # empty: obey the same settings your claude does
copilot_allow_all_tools = false # required before a copilot dispatch will start
Set claude_permission_mode = "manual" if you would rather be asked about
everything an unattended run does. There is no cost to being stopped often —
taking a session over resumes it exactly where it left off.
How it works
Sessions are read from each agent's own store, in parallel, cached on mtime
so a refresh is normally just stat() calls:
| agent |
store |
resume |
| Claude Code |
~/.claude/projects/*/*.jsonl |
claude --resume <id> |
| Codex |
~/.codex/sessions/**/rollout-*.jsonl |
codex resume <id> |
| Copilot CLI |
~/.copilot/session-store.db (sqlite3) |
copilot --resume=<id> |
Claude's per-project directory name is lossy — / and . both become - — so
the working directory is read out of the records rather than decoded from the
path. Older Codex rollouts key the session as id rather than session_id, and
fall back to the uuid in the filename.
Dispatch (d) drives each CLI's non-interactive mode and reads its JSONL
event stream. The flags were arrived at by running them, not from the docs:
| agent |
how orbit runs it |
approval |
| Claude Code |
-p --input-format stream-json --output-format stream-json --permission-prompt-tool stdio --session-id <uuid> |
control_request{can_use_tool}, answered with deny + interrupt |
| Codex |
exec --json --skip-git-repo-check -C <dir> |
none exists in exec mode |
| Copilot CLI |
-p --output-format json --session-id <uuid> --allow-all-tools |
none exists; the flag is mandatory |
--permission-prompt-tool stdio is what makes the approval visible at all —
without it the CLI auto-denies the tool and the model works around the refusal.
Claude and copilot accept a session id, so orbit picks one before the process
starts and the run is joined to a session from its first tick; codex only
reveals its thread_id on the first event, and orbit records it then. No
sandbox or approval policy is imposed on codex, and no permission mode on
claude by default: your own config decides, so a handoff means "your agent
would have prompted here".
tmux runs on a private server (tmux -L orbit) with its own config at
~/.config/orbit/tmux.conf, installed from a copy embedded in the binary. It
never touches a normal tmux. The status bar is off and the prefix is moved to
C-o so it can't steal C-b from an agent's input line. Agents are launched by
typing into an interactive login shell rather than via new-session <cmd>,
because agents are often shell functions or version-manager shims that don't
exist in a bare sh -c.
Session state is read back with one list-sessions -F, and tmux is fussier
about that than it looks. Older versions (3.4, which Debian and Ubuntu 24.04
ship) escape control characters in command output, so the \x1f field
separator arrives as the literal \037 — the parser takes either. Every
invocation also passes -u, without which tmux replaces multi-byte characters
with _ for any client whose locale it doesn't like, mangling the · in tab
titles and leaving orbit re-titling every session on every tick because the
title it wrote never matches the one it reads.
Tab titles are pushed as <short pwd> · <session title> through tmux's
set-titles, configured to ignore pane_title so agents can't overwrite them.
New tabs are opened through Ghostty's AppleScript dictionary (Ghostty
1.3+), which hands back a stable tab id. orbit stores that id on the tmux
session (@orbit_tab), so opening a session that's already on screen switches
to its tab instead of stacking up another — matching id and current title,
since Ghostty reuses ids after a close and titles can collide. The same works
in iTerm2 via its own dictionary. On older Ghostty, orbit falls back to
sending cmd+T via System Events and typing the attach command, which needs
Accessibility permission and can only find tabs again by title. Without any
of that, use i.
Notifications use OSC 9 when a session needs attention or finishes a turn.
Layout
cmd/orbit/ entry point: flags, wiring, --list/--json output
internal/
config/ config.toml and its defaults
format/ shared string and time helpers
session/ the session model: agents, parsers, index, sorting
tmux/ the private tmux server; knows nothing about agents
term/ Ghostty and iTerm2: opening tabs, focusing them back
search/ full-text search over transcript bodies
hooks/ agent hook events -> per-session state; `orbit hook`
summary/ cached, incrementally-updated summaries
update/ the once-a-day release check and self-update
scripts/ checks that need a real terminal, not a test runner
ui/ Bubble Tea model, rendering, logos, notifications
test/ integration tests that touch the real system
Dependencies run one way: format and config sit at the bottom and import
nothing local, tmux is deliberately ignorant of agents (it deals in names and
strings), session is the domain model, and ui composes the lot. Anything
needing both an agent and tmux — resuming a session, starting a new one — lives
in ui, which is the only layer entitled to know about both.
Unit tests sit beside the code they cover, as Go expects, so they can reach
unexported internals. test/ is for integration tests that spawn a real tmux
server or read the actual session stores.
Caveats
- tmux owns the scrollback, so your terminal's find-in-page only searches the
visible pane inside a session. Use tmux copy-mode (
C-o [).
- Closing a session tab detaches; it does not kill. Sessions accumulate until
you
x them.
- Reading only. orbit will not answer prompts on your behalf.
Config
~/.config/orbit/config.toml is written with annotated defaults on first run:
icons, attach behaviour, notifications, recent_days, sort order, grouping
and its group_by mode, spawn delays, dispatch, updates and the per-provider
summary commands. Environment variables (ORBIT_ICONS, ORBIT_SPAWN_DELAY,
ORBIT_TAB_DELAY) still win over the file, so a single run can be changed
without editing it.
Settings orbit gains later are appended to the file on the next start, with
the comments that explain them, and orbit says which ones it added. It only
ever adds: a key already in the file is left alone even when the shipped
default has since changed, because at that point the value is a decision and
nothing can tell a deliberate one from a leftover. So a new setting shows up
where you'd look for it, and nothing you've edited is rewritten, reordered or
reformatted.
Agent logos
Sessions are tagged with the actual Claude, OpenAI and Copilot marks, drawn as
images via the Kitty graphics protocol — supported by Ghostty and Kitty. Any
other terminal gets the cl / cx / cp text tags instead: the default is
icons = "auto", which detects the capability rather than assuming it. Set
icons = "text" (or ORBIT_ICONS=text) to pin it to the tags. Check what
yours does:
orbit --probe-logos
The marks are transmitted once at startup and placed with Unicode placeholders,
so they flow with the text rather than being pinned to screen coordinates, and
occupy exactly the two columns the text tag did. tmux doesn't pass placeholders
through, so logos switch themselves off if orbit is running inside one.
Artwork is from Simple Icons (CC0), recoloured for a
dark terminal. Trademarks belong to their respective owners; the marks identify
which agent owns a session and imply no affiliation or endorsement.
Flags: --inline, --window, --no-notify, --list, --json, --probe-logos, --version.
License
MIT