Skip to content
luiulPublic

About

Read-only visibility into agent CLI sessions

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

69 Commits

Folders and files

Repository files navigation

canopy

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.

Ecosystem

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
Loading

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.

What it looks like

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.

Conventions

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.

Configuration

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/pig matches pig; the npm launcher node /opt/homebrew/bin/pig has basename node and correctly never matches.
  • A controlling terminal is required.
  • The second token must not be a denylisted subcommand (mcp, serve, --version, ...), so claude mcp and claude --version never 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).

Process control

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):

  • x terminates the selected session gracefully (SIGTERM), X forces it (SIGKILL). Both ask first: the footer shows the target's kind, pid, and location (plus a warning if the session is mid-turn), y confirms, n/esc/enter cancels, and an unanswered prompt cancels itself after 10 seconds. The prompt is yellow for a terminate, red for a force-kill.
  • D terminates every session currently reading done (SIGTERM), with the same confirmation, for cleaning up a screen full of finished sessions at once.
  • p pauses a session (SIGSTOP); pressed again on the same, now stopped, 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.

Why Go, not Python

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).

Architecture

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 to ps/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.toml that sets which agent CLI kinds canopy tracks (see Configuration); no file means the built-in default of pi.
  • internal/state: CPU%-based idle/working heuristic for processes not running in VS Code or Ghostty.
  • internal/pistatus: reads the small status file the optional extensions/canopy-status.ts companion writes for a running pi process, 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 onto github.com/luiul/dashkit/mycelium's shared open-or-focus logic (code --reuse-window/-n for 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 the x/X/p/D keybinds, behind a process identity check (pid plus lifetime, from a fresh ps snapshot) so a recycled pid is never signaled by mistake.
  • internal/registry: merges a fresh poll against the previous one so a single missed ps/poll doesn't flicker a row away.
  • internal/ack: lets multiple concurrently running canopy instances agree on which done rows have been acknowledged (enter/c), the one piece of dashboard state that isn't already derivable from a shared, externally observable source the way State itself is (see "Multiple instances" below).
  • internal/tui: the Bubble Tea dashboard (table, polling timer, jump-on-Enter, notifications, mouse column resizing via github.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 via github.com/luiul/dashkit/confirm and github.com/luiul/dashkit/loam's HelpView).
  • cmd/canopy: the CLI entry point (flags, config load, version).

Install

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 once

Or, 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 PATH

Or, if $(go env GOPATH)/bin (usually ~/go/bin) is on your PATH:

go install ./cmd/canopy

Development

go 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 check

Real pi status (optional)

Canopy 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.ts

It 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.

Multiple instances

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.

Limitations

  • 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 as node and is ignored, while the real pig binary 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/lsof output and AppleScript).
  • Idle/working for non-pi surfaces (and pi itself 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 ps hung 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.title setting 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 DataTable does.

About

Read-only visibility into agent CLI sessions

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages