Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 21 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@

<img src="docs/demo.gif" alt="agentpane watching a fan-out" width="100%">

Another terminal UI harness. Written in Go. With a tree in it. I know - how
original.
Another terminal UI harness. Written in Go. With a tree in it. Yes, I know.

Claude Code fans out to eight subagents and the transcript turns into a wall of
scrolling text. Worse: one of them hits a permission prompt and then just waits,
scrolling text. Worse, one of them hits a permission prompt and then just waits,
silently, while you are looking at a different window.

agentpane draws the tree live in a second pane. One row per agent, what it is
Expand All @@ -23,14 +22,16 @@ agentpane install

A binary into `~/.local/bin`, then the hooks into `~/.claude/settings.json`. The
installer shows you the diff and asks first. Add `--autopane` and the pane opens
itself with every session. Undo it all with `agentpane install --uninstall`.
itself with every session, in iTerm2, tmux, WezTerm or kitty. Undo it all with
`agentpane install --uninstall`.

Then run `agentpane` in a second pane next to your session.

* Just want a look? `agentpane --demo` needs no install and no Claude.
* Something wrong? `agentpane doctor` says what.
* Prefer Go? `go install github.com/simoncoombes/agentpane/cmd/agentpane@latest`
* Using Codex? `agentpane --source codex`
* `agentpane --demo` replays a fan-out. No install, no Claude.
* `agentpane doctor` says what is wrong when nothing shows up.
* `go install github.com/simoncoombes/agentpane/cmd/agentpane@latest` if you
would rather build it.
* `agentpane --source codex` watches an OpenAI Codex run instead.

## The marks

Expand All @@ -40,18 +41,23 @@ nothing · `▪▪▫·` how far it got · the number on the right is tokens
`⏎` inspects a row, `⇥` jumps to the session pane to answer whoever is blocked,
`a` shows the agents that finished, `?` lists the rest.

The tree draws at whatever width the pane is, up to 100 columns, and redraws
when you drag the divider. `w` fixes it at 64 or 44 instead.

Nothing on a row is a guess. The two marks that are inferred say so, and an
agent that reports nothing gets a dim row rather than an invention.

## Docs

* [READING-THE-PANE.md](docs/READING-THE-PANE.md) — every glyph, every
animation, and what the pane won't tell you
* [CLI.md](docs/CLI.md) — every flag, key and config setting
* [INSTALL.md](docs/INSTALL.md) — the installer, the hook snippet, auto-open
* [ITERM2.md](docs/ITERM2.md) — iTerm2 profile and split
* [CODEX.md](docs/CODEX.md) — watching a Codex run
* [DATA-SOURCES.md](docs/DATA-SOURCES.md) — what it reads, and how fast
* [READING-THE-PANE.md](docs/READING-THE-PANE.md). Every glyph, every
animation, and what the pane won't tell you.
* [CLI.md](docs/CLI.md). Every flag, key and config setting.
* [INSTALL.md](docs/INSTALL.md). The installer, the hook snippet, auto-open.
* [TERMINALS.md](docs/TERMINALS.md). Which terminals can open the pane for
you, and what to do in the ones that cannot.
* [ITERM2.md](docs/ITERM2.md). iTerm2 profiles and the split.
* [CODEX.md](docs/CODEX.md). Watching a Codex run.
* [DATA-SOURCES.md](docs/DATA-SOURCES.md). What it reads, and how fast.
* [CONTRIBUTING.md](CONTRIBUTING.md) · [SECURITY.md](SECURITY.md)

MIT licensed. See [LICENSE](LICENSE).
26 changes: 25 additions & 1 deletion cmd/agentpane/adapters.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,10 +178,34 @@ func installedHookEvents() []string {
// uiPrefs persists the w and t toggles under the state dir — never in the
// user's config.toml, which agentpane treats as read-only.
type uiPrefs struct {
Schema int `json:"schema"`
Width string `json:"width"`
RightCol string `json:"right_col"`
}

// uiPrefsSchema is the current shape. Anything older goes through
// migratePrefs on the way in.
const uiPrefsSchema = 1

// migratePrefs upgrades a prefs file written before fill existed.
//
// `w` used to be a two-way toggle between wide and narrow, and wide was the
// default, so a stored "wide" is ambiguous: it almost always means "I toggled
// back to normal", not "hold this pane at 64 columns however wide it gets".
// Honouring it would leave everyone who had ever pressed `w` twice with a pane
// that still refuses the room, and nothing on screen to explain why. It is
// dropped once, and a deliberate "wide" chosen after the upgrade is written
// with the current schema and kept from then on.
//
// A stored "narrow" says something the new default does not, so it survives.
func migratePrefs(p uiPrefs) uiPrefs {
if p.Schema < uiPrefsSchema && p.Width == "wide" {
p.Width = ""
}
p.Schema = uiPrefsSchema
return p
}

func prefsPath() string {
dir, err := statefile.DefaultDir()
if err != nil {
Expand All @@ -197,7 +221,7 @@ func loadPrefs() uiPrefs {
return p
}
json.Unmarshal(data, &p) //nolint:errcheck // malformed prefs = defaults (C9)
return p
return migratePrefs(p)
}

func savePrefs(p uiPrefs) {
Expand Down
100 changes: 44 additions & 56 deletions cmd/agentpane/autopane.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,12 @@ import (
)

const (
// autopaneOsascriptTimeout bounds the osascript call. On expiry the
// helper is ABANDONED, never killed — no code path may signal a process
// (same pattern as editor.go's runQuick post-fix); whatever survives is
// reaped by the harness's hook-tree cleanup, which is fine because the
// pane iTerm2 already created belongs to iTerm2.
autopaneOsascriptTimeout = 3 * time.Second
// autopaneSplitTimeout bounds the command that does the splitting. On
// expiry the helper is ABANDONED, never killed — no code path may signal a
// process — and whatever survives is reaped by the harness's hook-tree
// cleanup, which is fine because the pane the terminal already created
// belongs to the terminal, not to this hook's process tree.
autopaneSplitTimeout = 3 * time.Second

// autopaneLockStale is the age past which a leftover lockfile is
// reclaimed (a crash between lock and split, or a very old session id
Expand Down Expand Up @@ -244,23 +244,15 @@ func runAutopaneMode(stdin io.Reader, extraArgs []string, ppid int, explain io.W
}
autopanePass(explain, "(b) kill switch: not set")

// (c) the session must live in an iTerm2 pane we can target.
// ITERM_SESSION_ID carries "wNtNpN:<uuid>"; the AppleScript matches the
// uuid against `id of session`, so strip through the last ":" — the same
// technique as it2-pane-hook.sh's `${ITERM_SESSION_ID##*:}`.
if os.Getenv("TERM_PROGRAM") != "iTerm.app" {
autopaneSkip(explain, "(c) TERM_PROGRAM=%q — auto-open needs iTerm.app (the split is an iTerm2 AppleScript)", os.Getenv("TERM_PROGRAM"))
// (c) the session must live in a terminal agentpane knows how to split.
// pane.go decides which, and why — a multiplexer beats the terminal
// hosting it, and AGENTPANE_TERMINAL overrides both.
backend := detectBackend(os.Getenv)
if backend == backendNone {
autopaneSkip(explain, "(c) no terminal to split: %s", backendReason(os.Getenv))
return
}
paneUUID := os.Getenv("ITERM_SESSION_ID")
if i := strings.LastIndex(paneUUID, ":"); i >= 0 {
paneUUID = paneUUID[i+1:]
}
if paneUUID == "" {
autopaneSkip(explain, "(c) ITERM_SESSION_ID empty — no pane to target")
return
}
autopanePass(explain, "(c) iTerm2 pane: %s", paneUUID)
autopanePass(explain, "(c) terminal: %s", backend)

// (d) nested Claude session: an interactive session spawned from inside
// another Claude session inherits CLAUDE_CODE_CHILD_SESSION
Expand Down Expand Up @@ -336,28 +328,47 @@ func runAutopaneMode(stdin io.Reader, extraArgs []string, ppid int, explain io.W
return
}
cmdline := paneCommand(exe, p.SessionID, extraArgs)
script := autopaneScript(paneUUID, autopaneProfile(), cmdline)
osascript := os.Getenv("AGENTPANE_OSASCRIPT")
if osascript == "" {
osascript = "osascript"
chain := splitCommands(backend, os.Getenv, cmdline, autopaneColumns())
if len(chain) == 0 {
autopaneSkip(explain, "act: the %s backend has no pane to target from here", backend)
if !dry {
os.Remove(lock) //nolint:errcheck // best-effort release, stay silent
}
return
}
if dry {
fmt.Fprintf(explain, "\nWOULD OPEN a pane: profile %q running %s\n", autopaneProfile(), cmdline)
fmt.Fprintf(explain, "via %s -e <AppleScript targeting session %s>\n", osascript, paneUUID)
if _, err := exec.LookPath(osascript); err != nil {
fmt.Fprintf(explain, "SKIP act: %s not found on PATH: %v\n", osascript, err)
fmt.Fprintf(explain, "\nWOULD OPEN a %s pane %d columns wide, running %s\n",
backend, autopaneColumns(), cmdline)
for _, c := range chain {
fmt.Fprintf(explain, "via %s\n", summariseArgv(c.argv))
if _, err := exec.LookPath(c.argv[0]); err != nil {
fmt.Fprintf(explain, "SKIP act: %s not found on PATH: %v\n", c.argv[0], err)
}
}
return
}
autopanePass(explain, "act: running %s to split pane %s (profile %q)", osascript, paneUUID, autopaneProfile())
if !runAbandoning(osascript, autopaneOsascriptTimeout, "-e", script) {
autopaneSkip(explain, "act: %s failed to start — lock released", osascript)
autopanePass(explain, "act: splitting via %s (%s)", backend, summariseArgv(chain[0].argv))
if !runPaneChain(chain, autopaneSplitTimeout) {
autopaneSkip(explain, "act: every %s split command failed — lock released", backend)
os.Remove(lock) //nolint:errcheck // best-effort release, stay silent
} else {
autopanePass(explain, "act: osascript started — pane should appear")
autopanePass(explain, "act: split issued — pane should appear")
}
}

// summariseArgv renders a command for the --explain trace without pasting a
// forty-line AppleScript into it.
func summariseArgv(argv []string) string {
out := make([]string, 0, len(argv))
for _, a := range argv {
if len(a) > 60 || strings.Contains(a, "\n") {
a = "<" + strconv.Itoa(len(a)) + " bytes of script>"
}
out = append(out, a)
}
return strings.Join(out, " ")
}

// socketState is the guard-(f) probe result for the per-session TUI socket.
type socketState int

Expand Down Expand Up @@ -553,29 +564,6 @@ func autopaneLockReclaim(path string, now time.Time) bool {
return autopaneLockCreate(path)
}

// runAbandoning starts name and waits at most timeout, reporting whether the
// process STARTED (false only when exec itself failed — binary missing, not
// executable). On expiry the process is abandoned WITHOUT being killed — no
// code path anywhere may signal a process (§5.3, PART 10) — and the Wait
// goroutine reaps it whenever it eventually finishes. stdout/stderr stay nil
// (silent on every path).
func runAbandoning(name string, timeout time.Duration, args ...string) bool {
cmd := exec.Command(name, args...)
if err := cmd.Start(); err != nil {
return false
}
done := make(chan struct{})
go func() {
cmd.Wait() //nolint:errcheck // silent: outcome does not matter
close(done)
}()
select {
case <-done:
case <-time.After(timeout):
}
return true
}

// autopaneDebugPath is the trace log written when the debug marker exists.
func autopaneDebugPath() (marker, logPath string, err error) {
dir, err := os.UserHomeDir()
Expand Down
66 changes: 66 additions & 0 deletions cmd/agentpane/autopane_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -635,3 +635,69 @@ func TestAutopaneScriptEscapesHostileInput(t *testing.T) {
t.Errorf("the pane id literal reads %q, want the input back verbatim", got)
}
}

// The whole guard chain, all the way through the act, on a backend that is not
// iTerm2. This is what proves auto-open is not macOS-only: the same fixture
// that drives the AppleScript path drives tmux, with a fake tmux recording the
// argv it was handed.
func TestAutopaneTmuxSplit(t *testing.T) {
argv := setupAutopaneEnv(t)
// Leave iTerm2 detectable: tmux must win anyway (a session inside tmux
// inside iTerm2 lives in a tmux pane).
t.Setenv("TMUX", "/tmp/tmux-501/default,4242,0")
t.Setenv("TMUX_PANE", "%9")
t.Setenv("AGENTPANE_TMUX", strings.TrimSuffix(argv, "osascript-argv.txt")+"fake-osascript")
t.Setenv("AGENTPANE_COLUMNS", "72")

in := strings.NewReader(autopanePayloadJSON("SessionStart", "startup", autopaneTestSession))
if code := cmdAutopane(in, nil); code != 0 {
t.Fatalf("exit %d, want 0", code)
}
raw, err := os.ReadFile(argv)
if err != nil {
t.Fatalf("tmux not invoked on the all-pass path: %v", err)
}
got := string(raw)
exe, _ := os.Executable()
for _, want := range []string{
"split-window",
"-d", // focus stays in the session pane
"-l", // ...at a width we chose
"72", // ...which is AGENTPANE_COLUMNS
"%9", // the session's own pane
exe, // the TUI, by absolute path
"--session", autopaneTestSession, // pinned to this session
} {
if !strings.Contains(got, want) {
t.Fatalf("tmux argv missing %q:\n%s", want, got)
}
}
if strings.Contains(got, "iTerm2") {
t.Fatalf("an AppleScript reached tmux:\n%s", got)
}
if _, err := os.Stat(autopaneLockPath(autopaneTestSession)); err != nil {
t.Fatalf("lockfile not left in place: %v", err)
}
}

// A terminal agentpane cannot drive is a skip, not a failure, and nothing is
// run at all.
func TestAutopaneUnsupportedTerminal(t *testing.T) {
argv := setupAutopaneEnv(t)
t.Setenv("TERM_PROGRAM", "Apple_Terminal")
t.Setenv("ITERM_SESSION_ID", "")

var explain bytes.Buffer
in := strings.NewReader(autopanePayloadJSON("SessionStart", "startup", autopaneTestSession))
runAutopaneMode(in, nil, os.Getpid(), &explain, false)

if autopaneInvoked(argv) {
t.Fatal("something was run in a terminal with no split backend")
}
if !strings.Contains(explain.String(), "(c) no terminal to split") {
t.Fatalf("guard (c) did not report the reason:\n%s", explain.String())
}
if _, err := os.Stat(autopaneLockPath(autopaneTestSession)); err == nil {
t.Fatal("a skipped event took the lock")
}
}
37 changes: 36 additions & 1 deletion cmd/agentpane/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"io"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
Expand Down Expand Up @@ -275,10 +276,44 @@ func doctorReport(w io.Writer, session string) int {
fmt.Fprintln(w, " ● ⚑ ○ ◇ ◌ ◍ ✔ ✖ ⚠ ▪▫· ▇ ▣ ▌ ▏ █ ◐ ↺ ▸ │├╰─╮ ⠹⠿")
fmt.Fprintln(w)

// The terminal backend: what auto-open and ⇥ can drive from here, and
// whether the program that does the driving is actually installed. This
// is the check that answers "why does nothing happen when a session
// starts", which used to need `autopane --explain` to find out.
fmt.Fprintln(w)
backend := detectBackend(os.Getenv)
if backend == backendNone {
warn("terminal: no split backend — %s", backendReason(os.Getenv))
fmt.Fprintln(w, " the TUI works here; auto-open (agentpane install --autopane) and ⇥ do not")
fmt.Fprintln(w, " supported: iTerm2, tmux, WezTerm, kitty — see docs/TERMINALS.md")
fmt.Fprintln(w, " run agentpane in a second pane you split yourself, and answer prompts in the session pane")
} else {
chain := splitCommands(backend, os.Getenv, "agentpane", 64)
var missing []string
for _, c := range chain {
if _, err := exec.LookPath(c.argv[0]); err != nil {
missing = append(missing, c.argv[0])
}
}
switch {
case len(chain) == 0:
bad("terminal: %s detected, but nothing here names a pane to split", backend)
case len(missing) == len(chain):
bad("terminal: %s detected, but none of %s is on PATH — the split cannot run",
backend, strings.Join(missing, ", "))
default:
ok("terminal: %s — auto-open and ⇥ can drive this one", backend)
if len(missing) > 0 {
fmt.Fprintf(w, " %s not on PATH; the next alternative in the chain is used instead\n",
strings.Join(missing, ", "))
}
}
}

// Capabilities that cannot be probed one-shot.
warn("focus reporting (CSI ?1004): cannot verify from a script — check via the pane title test in docs/ITERM2.md")
warn("OSC 8 hyperlinks: cannot verify from a script — ⌘-click a path in the pane to confirm")
warn("OSC 52 clipboard: cannot verify from a script — press y in the pane and check the clipboard (needs iTerm2's clipboard-access preference)")
warn("OSC 52 clipboard: cannot verify from a script — press y in the pane and check the clipboard (iTerm2 gates this behind a preference)")

return 0
}
Expand Down
Loading
Loading