An interactive dashboard for every agent CLI session on this machine (pi tracked by default; the set is yours to configure, see Configuration), wherever it actually is: a VS Code integrated terminal or a bare Ghostty tab, with its live state and jump-to-window on Enter.
This is the Go implementation, and the one actively developed going
forward. An earlier Python/Textual prototype lives at ../canopy-python
(kept for reference, no longer installed); this version has the same
behavior, ported to a single static binary: no interpreter, no venv,
instant startup.
canopy's only job is agent sessions; it has no notion of git worktrees at all. If you also use git worktrees, see Ecosystem below for the sibling tools that cover that.
canopy is one of four tools that split "what's running, and where, on this machine" into two independent radars over two independent lifecycle tools, one pair for agent sessions, one pair for git worktrees:
| Tool | Layer | Job |
|---|---|---|
wt (worktrunk) |
engine | creates/removes worktrees, runs lifecycle hooks (post-start, pre-remove, ...), maintains the shared registry |
| coppice | lifecycle CLI | cross-repo new/list/remove/clean worktrees, on top of wt, from anywhere on disk |
| understory | worktree radar | live, read-only dashboard of every worktree in the registry; open-or-focus a VS Code window on Enter |
| canopy (this repo) | agent radar | live, read-only dashboard of every agent CLI session on the machine; jump-to-window on Enter |
flowchart LR
wt["wt (worktrunk)<br/>engine + hooks"]
coppice["coppice<br/>cross-repo worktree CLI"]
registry[("~/.cache/wt/known-repos")]
understory["understory<br/>worktree radar"]
coppice -- new/remove/clean, via --> wt
wt -- post-start hook writes --> registry
coppice -- also writes, on first touch --> registry
registry -- read only --> understory
canopy doesn't appear in that diagram on purpose: it's fully independent
of wt's registry, and of the other three tools. It discovers agent
processes directly via ps/lsof and AppleScript for Ghostty, the same
way understory discovers worktrees, just from a completely different
source. The two dashboards (canopy, understory) are designed to run side by
side, each a tab-free, single-view radar over one kind of thing, rather
than one tool trying to be both. This split happened deliberately: canopy
briefly grew a second "Worktrees" view (agent-to-worktree matching,
jump-to-worktree) before that code was pulled out into understory, so
canopy's scope could stay exactly "agent sessions," nothing else.
canopy — agent sessions on this machine
3 sessions: 1 done · 1 working · 1 idle
State Since Kind Model Surface Location CPU RAM Uptime PID
working 12s pi GPT-6.1 Sol (US) [amazon-bedrock] VS Code ~/projects/personal/canopy 4% 278M 1h 86872
done 3m pi — VS Code ~/worktrees/.../isa-orchestration 0% 140M 2h30m 9514
idle 1h20m pi — Ghostty ~/some/other/project 0% 95M 1d 65834
↑/↓ move · enter jump · c dismiss · x kill · / filter · ? help · q quit
(the currently selected row also gets a full-width grey highlight in the real terminal output, not shown here since it's just a background color)
The footer only lists the few most-used bindings; ? opens the full
keybinding list as an overlay (any key closes it again).
/ filters the rows, the same gesture jira-today's fzf picker uses:
typing narrows the table fuzzily (a subsequence match over the row's
state, surface, location, kind, and pid), enter still jumps while the
input is focused, and esc leaves the input with the filter still
applied. A second esc, now back in normal mode, clears it. While the
input is focused every letter is query text, not a binding, so typing
x filters instead of killing.
The view polls on a short interval, but also refreshes the moment the terminal window regains focus: the typical flow is starting a session in another window and then switching to canopy to check on it, and a session started a moment ago shouldn't be invisible until the next tick.
canopy and understory share one set of keybinding conventions, so muscle
memory transfers between the two dashboards: lowercase keys act on the
selected row or are reversible (x, c, p), uppercase keys are the
bulk or stronger form (X, C, D), every destructive action asks for
confirmation first, and ctrl+c always quits: from the table, from a
confirmation prompt, from the help overlay. The full set of shared
decisions (keybindings, the modal discipline, phrasing, rendering,
testing, releasing) is written down once in
dashkit's CONVENTIONS.md.
Each internal column border can be dragged with the mouse. The two columns beside it trade width, so a drag does not change the table's total width. Canopy shares this behavior with understory through github.com/luiul/dashkit/trellis. Header dividers mark the borders through loam.DrawHeaderBorders.
Drag floors keep usual values readable. Model floors at 27 cells and Location at 20 cells. Mouse widths survive polls, even when they exceed the automatic caps. A terminal resize resets them to the automatic layout.
The selected row has a subtle grey background across the table. State keeps its own color on that row. Canopy shares this rendering with understory through github.com/luiul/dashkit/loam.
Columns follow the scan order: State and Since show what needs attention, then Kind and Model identify the agent. Surface and Location show where it lives. CPU, RAM, Uptime, and PID provide secondary details at the right. CPU and RAM come from ps. Uptime is the process age, not its time in the current state.
Model shows the selected name and provider when the companion extension reports them. Missing reports show —. Its width follows the longest reported label, up to 60 cells. Location takes the remaining space, up to 40 cells, and shortens the home-directory prefix to ~. On tight terminals, Model loses extra space and Location can shrink to eight cells to keep PID visible. Very long model labels and paths can still truncate.
State is color-coded (green (bold) for done, yellow for working, dim
for idle/unknown, cyan for stopped). A row that just went done blinks: a trailing *
plus a reverse-video highlight, toggling on and off a few times right
away, then again every five minutes for as long as it stays
unacknowledged — a repeating nudge rather than a one-shot highlight, since
done is the one state that otherwise only an enter/c press ever
clears (see below). done is also the one state that rings the terminal
bell (ASCII BEL) the moment a row newly transitions into it — the one
signal here that reaches you even if canopy's own pane isn't the one on
screen (a dock bounce, tab badge, or audible beep, depending on your
terminal's own bell setting), unlike the color/blink treatment, which
only helps once you're already looking at it. The bell only fires on the
transition itself, not on every poll a row happens to stay done —
including the first poll right after canopy starts up, if a session is
already sitting done at that point (done's first blink burst treats "just
discovered" the same as "just transitioned", too). Sessions are sorted
most-actionable first: done, then working, then idle, stopped,
and unknown.
Pass --no-color (or set NO_COLOR) to disable the color/blink treatment
and get plain text, and --no-bell to disable just the bell.
A row that's done stays done (still sorted to the top, still
colored, still bell-eligible for its own transition, still blinking every
five minutes) until you actually do something about it: press enter to
jump to it (which also dismisses it right away), or c to dismiss it
in place without jumping at all. To clear a whole screen of done rows at
once, C dismisses every done row in place, no jumping, no per-row
selection. Any of these immediately displays the affected rows
as idle, drops them back down in the sort order, and stops the blinking
— no poll wait required, even mid-burst. It goes back to reading done
— unacknowledged, blinking again from scratch — the next time it actually
earns that state again (a fresh turn ending), not on every subsequent
poll where the underlying session happens to still be sitting done.
canopy decides which processes are agent sessions by matching the executable basename in ps output against a tracked set of kind names. That set comes from $XDG_CONFIG_HOME/canopy/config.toml (usually ~/.config/canopy/config.toml), which holds one key:
# The complete set of tracked agent CLI kinds. Replace semantics: this
# list is everything canopy tracks, there is no merge with the defaults.
agents = ["pi", "pig", "claude"]With no file, canopy tracks the built-in default: pi. The moment the file exists, its agents list is the complete tracked set: to track one more kind, add it to the list, and to stop tracking a kind, remove it. Delete the file to go back to the default.
Matching rules are the same for every kind, default or configured:
- Exact match on the executable basename.
.../bin/pigmatchespig; the npm launchernode /opt/homebrew/bin/pighas basenamenodeand correctly never matches. - A controlling terminal is required.
- The second token must not be a denylisted subcommand (
mcp,serve,--version, ...), soclaude mcpandclaude --versionnever show up as sessions.
Validation is strict, and any problem exits with a clear startup error instead of silently tracking the wrong thing: names must be plausible argv0 basenames (non-empty, no slashes, no whitespace), duplicates are deduplicated, unknown TOML keys are rejected, malformed TOML fails fast, and an existing file with a missing or empty agents list is an error.
To track everything the old built-in list did (21 kinds), plus pig:
agents = ["pi", "pig", "claude", "codex", "gemini", "cursor", "devin", "agy", "cline", "omp", "mastracode", "opencode", "copilot", "kimi", "kiro", "droid", "amp", "grok", "hermes", "kilo", "qodercli", "maki"]TOML rather than JSON because the file is hand-edited and comments matter (the same convention as worktrunk's ~/.config/worktrunk/config.toml).
canopy can also act on a session, not just watch it. These act on the
selected row (or, for D, on every done row at once):
xterminates the selected session gracefully (SIGTERM),Xforces it (SIGKILL). Both ask first: the footer shows the target's kind, pid, and location (plus a warning if the session is mid-turn),yconfirms,n/esc/entercancels, and an unanswered prompt cancels itself after 10 seconds. The prompt is yellow for a terminate, red for a force-kill.Dterminates every session currently readingdone(SIGTERM), with the same confirmation, for cleaning up a screen full of finished sessions at once.ppauses a session (SIGSTOP); pressed again on the same, nowstopped, row, it resumes it (SIGCONT). No confirmation here: pausing is fully reversible.
(? lists these in the app itself, along with every other binding.)
Two safeguards are built in. First, an armed prompt tracks its target
across polls: if the session exits on its own while the prompt is up, the
prompt cancels itself rather than dangling (and a bulk prompt sheds
whichever targets vanished). Second, before any signal is actually sent,
canopy re-verifies the process's identity (same pid, same lifetime within
a small slack, read from a fresh ps snapshot), so a pid the OS recycled
between poll and confirmation is never signaled by mistake. Only the
agent process itself is signaled, never its process group: agents often
share one with their parent shell. Children (MCP servers and the like)
may be left behind, exactly as with a manual kill.
canopy is 100% process discovery, subprocess orchestration, and a polling
TUI, no real computation. That profile made a compiled language a better
fit: no interpreter/venv to install or drift across Python versions,
near-instant startup for a tool you re-launch constantly, and os/exec
maps almost line-for-line onto every subprocess call the original Python
prototype made. Measured against that prototype: ~34x faster startup,
~3.4x less idle RSS, ~23x smaller install footprint (single 3.4 MB binary
vs. an interpreter + venv).
See docs/agent-state-machine.md for the
finite state machine behind a row's state, including the invariant
that a done row only ever leaves done via enter or c.
One Go package per concern:
internal/scan: shells out tops/lsof, parses their output into typed rows, and filters processes against the tracked kind set it is given.internal/config: loads the optional$XDG_CONFIG_HOME/canopy/config.tomlthat sets which agent CLI kinds canopy tracks (see Configuration); no file means the built-in default ofpi.internal/state: CPU%-based idle/working heuristic for processes not running in VS Code or Ghostty.internal/pistatus: reads the small status file the optionalextensions/canopy-status.tscompanion writes for a runningpiprocess, so canopy can use pi's own real working/idle/done instead of the CPU heuristic for that one agent kind (see "Real pi status" below).internal/ancestry: walks a process's parent chain to classify which app (VS Code / Ghostty) is hosting it.internal/jump: maps a row's Surface ontogithub.com/luiul/dashkit/mycelium's shared open-or-focus logic (code --reuse-window/-nfor VS Code, Ghostty AppleScript for a bare tab), switching to an already-open window when one matches the row's working directory, or opening a brand-new one when none does. The window detection and switch-or-create behavior itself lives in mycelium, not here, since understory needs the exact same thing for a worktree row with no agent connection of its own.internal/kill: delivers signals (SIGTERM/SIGKILL/SIGSTOP/SIGCONT) to a row's process for thex/X/p/Dkeybinds, behind a process identity check (pid plus lifetime, from a freshpssnapshot) so a recycled pid is never signaled by mistake.internal/registry: merges a fresh poll against the previous one so a single missedps/poll doesn't flicker a row away.internal/ack: lets multiple concurrently running canopy instances agree on whichdonerows have been acknowledged (enter/c), the one piece of dashboard state that isn't already derivable from a shared, externally observable source the wayStateitself is (see "Multiple instances" below).internal/tui: the Bubble Tea dashboard (table, polling timer, jump-on-Enter, notifications, mouse column resizing viagithub.com/luiul/dashkit/trellis— the same package understory uses for its own table — and the kill confirmation modal behind x/X/D plus the?help overlay, the modal's state machine and the overlay's renderer shared with understory viagithub.com/luiul/dashkit/confirmandgithub.com/luiul/dashkit/loam'sHelpView).cmd/canopy: the CLI entry point (flags, config load, version).
cd canopy
scripts/install.sh # builds, installs to ~/.local/bin, code-signs with a
# stable local identity so the macOS Automation
# permission (needed by mycelium's jump-to on every
# run: System Events for VS Code windows, Ghostty's
# scripting bridge for terminal rows) survives future
# rebuilds instead of resetting every time -- see the
# script's own comment for why and how to set up that
# signing identity onceOr, without the stable signature (fine for a one-off build, but expect to re-grant Automation for Ghostty after every rebuild):
cd canopy
go build -o /tmp/canopy-build ./cmd/canopy
install -m 0755 /tmp/canopy-build ~/.local/bin/canopy # or anywhere on PATHOr, if $(go env GOPATH)/bin (usually ~/go/bin) is on your PATH:
go install ./cmd/canopygo build ./...
go vet ./...
go test -race ./...
bun test extensions/canopy-status.test.ts # optional extension test (needs Bun)
gofmt -l . # should print nothing
golangci-lint run ./...Or, all at once:
make checkCanopy has no pty for a pi process running outside a terminal it owns, so by default it falls
back to the same CPU% heuristic every other agent kind gets. pi is the
one agent kind canopy can ask directly instead of guessing, though:
extensions/canopy-status.ts is a small companion pi extension (see
docs/extensions.md in the pi repo) that hooks pi's own agent-lifecycle
events (before_agent_start, agent_start, tool_execution_start,
agent_settled) and writes a tiny ~/.pi/agent/canopy-status/<pid>.json
file with pi's real state, which internal/pistatus reads straight into
that pid's RegistryEntry, no CPU sampling involved. The extension also
writes a separate <pid>.model.json record with the selected model's
name and provider. Canopy shows it as Name [provider] in the Model
column. The model report has its own heartbeat: it stays available while
pi is idle, without updating the state timestamp or ringing the done
bell. Selecting another model updates it at once. When the record is
missing or stale, Model shows — and state polling still works. Existing
pi sessions must reload their extensions or restart to begin writing the
new record. Only interactive pi sessions publish these records. SDK subagents and print, JSON, or RPC sessions cannot overwrite or delete the interactive session's status files.
Install it by symlinking (or copying) it into pi's global extensions directory:
ln -s "$(pwd)/extensions/canopy-status.ts" ~/.pi/agent/extensions/canopy-status.tsIt reports working while pi is actively running, and done
unconditionally once a turn ends — no frontmost/focus detection at all
(see docs/agent-state-machine.md's "Removed: frontmost/focus detection"):
canopy's dashboard already requires an explicit enter or c on the row
before it displays anything other than done, so guessing whether you
were already looking at that terminal at settle-time couldn't change what
you'd see there either way. One consequence: the bell/blink now fires on
every settled turn, including ones you watched finish directly in the
terminal, not just ones you missed. macOS only; not installing it (or
running on another OS) just leaves canopy on the CPU heuristic, same as
today.
Running canopy in more than one terminal at once (e.g. two Ghostty tabs)
just works: every instance polls the same machine independently, so the
table itself already looks identical everywhere. Acknowledging a done
row (enter/c) syncs too — within one poll interval (2s by default),
not instantly — via a small shared file per row under
~/.pi/agent/canopy-status/acks/; see
docs/agent-state-machine.md
for how. No daemon, no locking: each instance still only ever talks to
the filesystem, the same as everything else canopy reads.
- Same machine, same user only.
- Tracking matches the executable basename exactly (no globbing): an agent
launched through a differently named wrapper does not show up.
pig's own process tree is the example: its npm launcher runs asnodeand is ignored, while the realpigbinary underneath it is the row canopy tracks. - macOS only: canopy checks this at startup and exits with a clear error
on any other OS, rather than silently reporting zero sessions (its
process discovery relies on macOS-specific
ps/lsofoutput and AppleScript). - Idle/working for non-
pisurfaces (andpiitself without the extension above installed) is a CPU% heuristic, not a real status. - If the underlying agent-process scan itself fails to run (as opposed to
running fine and finding zero matches, e.g. a
pshung past its 5s deadline under system load), canopy keeps the last known sessions on screen (a failed scan is no evidence anything exited) and shows a warning banner in the header, instead of silently looking identical to "no sessions." - Ghostty jump-to matches by working directory, not tty/pid; ambiguous if two tabs share a cwd. If no open tab matches anymore (e.g. it was closed), Enter opens a brand-new Ghostty window at that cwd instead, same reuse-or-create behavior as VS Code's.
- VS Code jump-to matches by exact folder path against the window
titles (the dotfiles
window.titlesetting renders each title as the opened folder's full path), so same-named worktrees are no longer indistinguishable. The matched window is raised directly with an AXRaise: the right window comes to front, but not necessarily the specific integrated-terminal tab within it. - Mouse click-to-jump/acknowledge isn't implemented (keyboard only: arrow
keys, Enter, c); Bubble Tea's table widget doesn't ship row-click
handling out of the box the way Textual's
DataTabledoes.