feat(cli): mir opens your machines — the live overview (O1) - #97
Conversation
`mir` with no arguments on a terminal now opens a live picker: one row per machine (online/offline dot, NEW badge, dim tmux window summary), arrows or j/k to move, Enter attaches, r renames inline, x retires with the N2 facts, q quits. Ctrl-O then d detaches back to the overview. Bare `mir attach` continues where you left off (last-used, else the only machine, else the overview). The CLI now consumes the FrameWindows snapshot the agent already pushes. Scripts see no change: non-TTY `mir` keeps the static guide + exit 2, and bare `mir attach` without a TTY keeps its usage error. Built on raw terminal + ANSI only — a menu, not a multiplexer; tmux keeps owning windows, the mux keeps owning machine focus. Closes #94 Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KeiotDVE94wEzvc7wcvm1y
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5f69ca078f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| turnURL := "" | ||
| if first { | ||
| turnURL = defaults.SignalURL() | ||
| } | ||
| warm := ov.app.prewarm(ctx, ov.dir, ov.idn, turnURL, nil) |
There was a problem hiding this comment.
Fetch TURN credentials from the selected relay
When the selected machine's SignalURL differs from defaults.SignalURL(), this prewarm stores TURN credentials minted by the default relay and attach later passes them to client.Attach through ov.ice(). Prewarm.ICEFrom explicitly documents that credentials must not be reused for another relay, so overview attaches that require TURN behind strict NATs will fail for custom/federated-relay machines; resolve or cache ICE using the selected machine's relay before dialing.
Useful? React with 👍 / 👎.
| if name == "" { | ||
| return a.cmdOverview() |
There was a problem hiding this comment.
Pass the selected state directory into the overview
When mir attach --dir <custom> has multiple machines and no valid last-used target, this fallback calls cmdOverview(), which unconditionally loads defaultClientDir() rather than <custom>. The command therefore switches identities and displays the default machine list after having resolved the decision from the custom directory; make the overview accept and retain the parsed directory.
Useful? React with 👍 / 👎.
| func(ctx context.Context, mc peer.MsgConn, sess *noise.Session) error { | ||
| once.Do(func() { client.SaveLastUsed(ov.dir, m.Name) }) | ||
| return runBridgePumped(ctx, mc, sess, in, sink) |
There was a problem hiding this comment.
Stop the prior stdin reader before redialing
After a transport drop, ClientBridgeSink can return from its receive goroutine while its stdin goroutine remains blocked in detachFilter.Read, because canceling the bridge context does not unblock that read. ReconnectLoopWith then invokes this callback again with the same in, leaving multiple goroutines competing for the pump; the stale reader can consume the first keystrokes after reconnection and attempt to send them on the closed connection. Use one persistent input forwarder or make each bridge read cancelable and wait for it to exit before redialing.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 5 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 5f69ca0. Configure here.
| warm := ov.app.prewarm(ctx, ov.dir, ov.idn, turnURL, nil) | ||
| if first && warm.ICEErr == nil && len(warm.ICE) > 0 { | ||
| ov.iceList = warm.ICE | ||
| } |
There was a problem hiding this comment.
TURN credentials freeze after first refresh
Medium Severity
refresh requests TURN only on the first tick and stores the result in ov.iceList for the rest of the run. Later ticks pass an empty turnURL, so the T2 ICE cache is never consulted again. Relay credentials expire after 15 minutes, and a failed first fetch is never retried, so Enter from a long-lived overview can attach with dead or missing TURN.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 5f69ca0. Configure here.
| sink := func(payload []byte) { | ||
| if line := windowsSummary(payload); line != "" { | ||
| ov.windows[m.Name] = line | ||
| } |
There was a problem hiding this comment.
Windows map written without synchronization
Medium Severity
The WindowsSink writes ov.windows from the bridge receive goroutine, while the main loop reads that map after attach returns and again in refresh. ClientBridgeSink does not wait for the receive goroutine to exit, so those accesses overlap. Concurrent map read/write can panic.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 5f69ca0. Configure here.
| func(ctx context.Context, mc peer.MsgConn, sess *noise.Session) error { | ||
| once.Do(func() { client.SaveLastUsed(ov.dir, m.Name) }) | ||
| return runBridgePumped(ctx, mc, sess, in, sink) | ||
| }) |
There was a problem hiding this comment.
Reconnect races the shared detach filter
Medium Severity
One detachFilter is reused for every ReconnectLoopWith session. When a drop ends the bridge via the receive side, the previous stdin goroutine stays blocked in detachFilter.Read. The next session starts a second Read on the same filter. Keystrokes after a reconnect can be stolen by the dead session, and a split Ctrl-O d can miss the detach.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 5f69ca0. Configure here.
| ov.windows[newName] = ov.windows[m.Name] | ||
| } | ||
| ov.refresh(ctx, false) | ||
| } |
There was a problem hiding this comment.
Rename leaves last-used name stale
Low Severity
Last-used is stored by display name and is not updated when that machine is renamed. pickDefaultMachine and the overview cursor both look up LastUsed by name, so after a rename the continue path no longer finds the machine the user was on.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 5f69ca0. Configure here.
| return err | ||
| } | ||
| if name == "" { | ||
| return a.cmdOverview() |
There was a problem hiding this comment.
Overview ignores attach directory flag
Low Severity
When a bare mir attach --dir … has no default target, it calls cmdOverview, which always uses defaultClientDir(). Discovery used --dir, but the picker then shows a different state directory.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 5f69ca0. Configure here.
* assets: demo GIF for the reach-layer flow The old demo told a story the software no longer tells: a `LAN-direct on (mDNS + QUIC)` line for a transport deleted in #92, `wallet …` for what v0.7 renamed to identity, and a `mir up → mir list → mir attach` flow that predates the overview (#97), the first-run pairing QR, the tmux-style aliases, and session sharing (#101–#104). The new take is the current one, in four beats: bare `mir` opens the live overview; Enter attaches and the tmux session with a long-running agent is right there; Ctrl-O d comes back, and the row now remembers what runs over there; then one invite, read-only, gone in an hour. Every line the script prints is a line `mir` prints. The overview screen is overviewModel.Render()'s output, the attach banner is overview.go's, and the whole `mir share` block — header, QR, both invite lines, the wait line — was captured verbatim from a real run against relay.sourceful-labs.net. The QR in the rendered GIF decodes to the join URL printed beside it. Only the tmux screen and the shell prompt are authored, and neither is mir's output. The canvas grew to 1100x880 (43 rows x 102 cols) so the invite QR fits on one screen without scrolling. 219 KB, 12.8 s. Part of #106 Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KeiotDVE94wEzvc7wcvm1y * assets: narrow the demo canvas so every cell reads bigger 1100x880 at 102 columns left the 📱 in the mint header about 8 px wide once GitHub scaled the GIF into its 900px column, small enough to read as a box. No font fixes that: `fc-list ':charset=1F4F1'` finds exactly one face on this machine, Apple Color Emoji, and a 10x crop shows that is already the glyph being drawn — bezel, dark screen, colored icons. It is Apple's black phone on a dark theme, not a tofu box. Naming a font in the tape only makes it worse: every installed monospace face falls back to the same Apple glyph, and a comma list starting with JetBrains Mono breaks the cell metrics outright, because vhs supplies that face as a webfont and the list form defeats it. So the lever is columns, not fonts. 884x878 gives 42 rows x 80 columns: the last beat needs 41 rows, and 80 is the narrowest width that still wraps each invite URL to two lines rather than three. Every cell — the emoji included — grows about a quarter, and the frame stops being mostly empty. Widths must stay even. 883 defeated the palette pass and turned the same recording into 14 MB; 884 renders it at 214 KB. 214 KB, 12.8 s, 884x878. The QR still decodes to the join URL printed beside it. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KeiotDVE94wEzvc7wcvm1y --------- Co-authored-by: Claude Fable 5 <[email protected]>




Implements spec D3 (
docs/superpowers/specs/2026-08-30-invisible-tmux-one-path-design.md). Closes #94.What it looks like
Enter attaches the selected machine;
Ctrl-Othendcomes back.rrenames inline (N1's core),xretires after a one-line y/N with N2's facts. The list refreshes every 3 s through the T2 caches (most ticks cost nothing) and re-renders only on change. The cursor starts on the last-used machine.Decisions
mir attachcontinues: last-used machine (persisted on each successful attach), else the only machine, else the overview. TTY-only — a script's baremir attachkeeps today's usage error, and non-TTYmirkeeps the static guide + exit 2.mir --help/-h/helpnow print the guide (exit 0); they previously fell through to the terse usage + exit 2.WindowsSinkon the bridge — the byte stream is untouched, and the summary is one dim line, not a window strip.golang.org/x/term, same as the mux. A menu, not a multiplexer.Tests
gofmt -lempty,go vetclean,go test ./...green,-racegreen on cli+client, web 152/152 (untouched). New coverage: key decoding incl. split escape sequences, rendering (rows/badges/empty state/prompt), cursor clamping, the windows summary,pickDefaultMachine, the detach filter (pass-through, doubled prefix, pre-gesture bytes, split gesture), and the last-used round trip.🤖 Generated with Claude Code
https://claude.ai/code/session_01KeiotDVE94wEzvc7wcvm1y
Note
Medium Risk
Large new interactive CLI path (raw terminal, attach lifecycle, shared revoke/rename over the network) changes default TTY entry behavior; script/non-TTY paths are preserved but reviewers should sanity-check attach/detach and revocation flows from the overview.
Overview
Adds a TTY-first “your machines” overview when you run bare
mir(or when baremir attachhas no obvious single target). It’s a raw-mode picker—online/offline dots, optional tmux window summary lines, periodic registry refresh via existing prewarm caches, inline rename/retire, and Enter to attach. Ctrl-O then d/q returns to the overview via a stdin pump + detach filter that cancels attach before EOF so reconnect doesn’t fire.Bare
mir attachon a terminal now means “continue”: last-attached machine (newlastused.json), else the only paired machine, else the overview. Scripts/non-TTY behavior is unchanged (usage error / static guide + exit 2).--help/-h/helpprint the guide and exit 0.Shared machine ops: rename and revoke logic move into
renameResolved/retireResolvedfor bothmir machine …and the overview. Successful attaches persist last-used. The client bridge gains an optionalWindowsSinkso overview can show tmux summaries without altering terminal output.UnseenMachinessupports NEW badges for the overview session.Reviewed by Cursor Bugbot for commit 5f69ca0. Bugbot is set up for automated code reviews on this repo. Configure here.