feat(cli): run interactive agents in tmux + viewing-in resolution#731
Conversation
createSession now records the first pane's %id on the SessionMeta (via new-session -P -F), so callers get an exact send-keys/attach handle without a follow-up list-panes. Adds three primitives the interactive spawn-wrap and the "viewing in <app>" resolver need: - listClients() — attached clients (tty, pid, session:win.pane target) - paneExitStatus() — a dead pane's exit code, read while remain-on-exit holds it - setSessionHook() — bind a per-session hook (pane-died -> detach-client) Real-tmux tests cover pane capture, empty client list when detached, and exit-status recovery (exit 3 -> status 3). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On macOS/Linux, an interactive `agents run <agent>` now runs INSIDE a detached tmux session on the shared socket and attaches the current TTY, so every interactive agent gets a unique, addressable %pane — co-located agents in one cwd stop collapsing onto one row, and `agents focus` can re-attach a live session without forking it. - shouldWrapInTmux(ctx): pure gate — interactive AND not win32 AND not already in tmux AND not --raw AND not AGENTS_NO_TMUX=1 AND tmux installed. - buildTmuxAgentCommand(): `exec env K=V … <agent> <args>` — materializes the full agent env into the pane (independent of the shared server's env) while keeping tmux's own $TMUX/$TMUX_PANE for provenance; exec => clean pane_pid. - runInTmux(): createSession -> pane-died hook (detach on agent exit) -> record pid+pane in the registry -> attach -> on return, reap the exit code from the dead pane and tear the session down (a manual detach leaves it alive for focus). Headless runs, Windows, already-in-tmux, --raw and AGENTS_NO_TMUX=1 keep the bare spawn. Verified end-to-end: three `agents run claude` in one cwd yield %0/%1/%2; --raw and AGENTS_NO_TMUX bypass; nested tmux does not double-wrap; the pane-died hook returns the attach client and recovers the exit status. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`agents run <agent> --raw` spawns the agent directly instead of inside the shared tmux session (mirrors AGENTS_NO_TMUX=1). Threads options.raw into ExecOptions for shouldWrapInTmux. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ource getActiveSessions gains a tmux source (listTmuxAgentSessions): list-panes -a on the shared socket enumerates every agent pane whose session meta was stamped (labels.agent + labels.sessionId) by the spawn-wrap, builds a full ActiveSession with exact tmux provenance (mux.pane + reply rail), and is deduped FIRST so a tmux-hosted agent is always captured WITH its %pane even when live-terminals.json is stale. Its pane pids join knownPids so the headless ps-scan doesn't double-count. Also adds the ActiveSession.viewingIn field (app + tab of the current viewer) and exports hostFromPid() — the client-pid -> host-app resolver the viewing-in path reuses. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ions New resolver maps a tmux-hosted session to its attached client(s): the client's terminal pid names the host app (hostFromPid), and the tab resolves per app — Ghostty via the cwd/title matcher, iTerm via the t<n> field of the client's $ITERM_SESSION_ID (1-based), and VS Code/Cursor/Codium via the extension-published tabIndex in live-terminals.json. No attached client => undefined (detached). All probes are injectable so the resolver is unit-tested without a live tmux/ps/osascript. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… rows enrichLocalLocators now resolves the current viewer per tmux socket (listClients + resolveViewingIn, one shared Ghostty enumeration), and the locator badge appends "viewing in <app> tab N" after the %pane target — or "detached" when no client is attached. Renderer-path only (after the --json/--waiting gates), like ghosttyTab. describeWhere folds it into `focus`'s label too: "tmux %3 (viewing in codium tab 2)". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code ReviewerVerdict: Ready to merge Build: PASS —
What works well
Observations that don't block merge1. Fast-exit race window in const before = pane ? await paneExitStatus(pane, socket) : { dead: false };
if (!before.dead) {
await attachTmux({ socket, args: ['attach-session', '-t', name] });
}If the agent exits between the 2. // Non-tmux Ghostty sessions
const surfaces = await enumerateGhosttyTabs();
// ...
// tmux viewing-in block (distinct try-catch, separate call)
const surfaces = await enumerateGhosttyTabs();Two osascript round-trips where one would do. Easy optimization later: hoist a single 3.
if (e?.sessionId === sessionId && typeof e.tabIndex === 'number') return e.tabIndex;
Things to verify manually
Reviewed by Code Reviewer — ran build and targeted tests on this branch. Read: |
What
Phase 2+3 of tmux-standardization (plan: run every interactive agent in tmux on the shared socket so each has an exact
%panehandle, uniform track/resume/terminate, and robust "viewing in tab N"). This PR is the CLI half (apps/cli); the extension half (apps/factory) is a sibling PR.Changes
src/lib/exec.ts): interactive local agents on macOS/Linux now run inside a shared-socket tmux session (viacreateSession), capturing the real%paneinto the pid-registry. Pure, unit-tested gateshouldWrapInTmux(interactive · platform · not-nested · not opted-out · tmux present). Escape hatch:--rawflag +AGENTS_NO_TMUX=1; auto-skips Windows / already-in-tmux / non-interactive / shim passthrough.src/lib/tmux/session.ts): capture pane id fromcreateSession;listClients+ exit/attach helpers.src/lib/session/viewing-in.ts):list-clients→ resolve the attached client's app (process ancestry, reusingHOST_MATCHERS) + tab (Ghostty viaghostty-tabs, iTerm via$ITERM_SESSION_ID, Codium via the extension-publishedtabIndex).ActiveSession.viewingIn?: {app, tab?}.list-panes -a), deduped by sessionId.sessions.ts+go.ts): "viewing in tab N" / "detached" for tmux rows.Data contract (with the extension PR)
live-terminals entries gain
tmuxSession/tmuxPane/tabIndex; the CLI readstabIndexto resolve "Codium tab N".Verification
tsc --noEmitclean onapps/cli.bun test src/lib/tmux/session.test.ts src/lib/session/viewing-in.test.ts src/lib/exec.test.ts→ all feature tests pass;exec.test.ts37/37 on a clean env (the 2DISABLE_AUTOUPDATERfailures reproduce only inside an agent session — pre-existing env pollution, green on CI).agents teamsteammate which verified E2E: a tmux-hosted agent renders a distinct pane target + "viewing in".Note
Replayed onto the post-#724
apps/-restructure layout (the restructure landed mid-build); git rename-detection relocated every edit intoapps/cli/**, no logic changes.