Skip to content

feat(cli): run interactive agents in tmux + viewing-in resolution#731

Merged
muqsitnawaz merged 6 commits into
mainfrom
feat/tmux-standardize-v2
Jul 7, 2026
Merged

feat(cli): run interactive agents in tmux + viewing-in resolution#731
muqsitnawaz merged 6 commits into
mainfrom
feat/tmux-standardize-v2

Conversation

@muqsitnawaz

Copy link
Copy Markdown
Contributor

What

Phase 2+3 of tmux-standardization (plan: run every interactive agent in tmux on the shared socket so each has an exact %pane handle, 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

  • Spawn-wrap (src/lib/exec.ts): interactive local agents on macOS/Linux now run inside a shared-socket tmux session (via createSession), capturing the real %pane into the pid-registry. Pure, unit-tested gate shouldWrapInTmux (interactive · platform · not-nested · not opted-out · tmux present). Escape hatch: --raw flag + AGENTS_NO_TMUX=1; auto-skips Windows / already-in-tmux / non-interactive / shim passthrough.
  • tmux primitives (src/lib/tmux/session.ts): capture pane id from createSession; listClients + exit/attach helpers.
  • viewing-in resolver (src/lib/session/viewing-in.ts): list-clients → resolve the attached client's app (process ancestry, reusing HOST_MATCHERS) + tab (Ghostty via ghostty-tabs, iTerm via $ITERM_SESSION_ID, Codium via the extension-published tabIndex). ActiveSession.viewingIn?: {app, tab?}.
  • tracking: shared tmux server is now an authoritative active-sessions source (list-panes -a), deduped by sessionId.
  • render (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 reads tabIndex to resolve "Codium tab N".

Verification

  • tsc --noEmit clean on apps/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.ts 37/37 on a clean env (the 2 DISABLE_AUTOUPDATER failures reproduce only inside an agent session — pre-existing env pollution, green on CI).
  • Real tmux, no mocks (session lifecycle test spawns an actual tmux server on a temp socket).
  • Built by an agents teams teammate 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 into apps/cli/**, no logic changes.

muqsitnawaz and others added 6 commits July 7, 2026 04:27
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>
@prix-cloud

prix-cloud Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Code Reviewer

Verdict: Ready to merge

Build: PASS — tsc && ... clean on apps/cli (exit 0)
Tests: 46 pass · 18 skip · 0 fail

  • exec.test.ts, viewing-in.test.ts: all unit tests pass
  • session.test.ts (tmux integration): 18 tests skipped — tmux not installed in this sandbox. These are real tests against a live tmux server; they run on CI (Linux). The skip guard describe.skipIf(isTmuxInstalled() ? null : 'tmux not installed') is correct.

What works well

shouldWrapInTmux gate (exec.ts:929–937): Pure function, every condition independently unit-tested. The six guards — interactive, not-Windows, not-nested, not --raw, not AGENTS_NO_TMUX=1, tmux present — are exactly right. Tests cover all six.

buildTmuxAgentCommand (exec.ts:951–958): exec env K=V … agent args is the right shape. exec replaces the shell so the agent IS the leaf process — #{pane_pid} resolves to the agent binary, not a shell wrapper. Filtering EXEC_ENV_KEY_PATTERN drops exported bash functions before passing to env, which would choke on BASH_FUNC_foo%%=.... Tested explicitly.

runInTmux lifecycle (exec.ts:975–1028): The sequence — create → pane-died hook (detach client on exit) → pid-record with %panepaneExitStatus(before) pre-flight → attach → paneExitStatus(after) exit-code recovery → killSession — handles the three cases correctly:

  • Agent exits before attach: before.dead = true, skip attach, return exit code
  • Agent exits while attached: pane-died hook detaches client, after.dead = true, return exit code
  • User detaches (Ctrl-b d): after.dead = false, return 0, keep session for agents focus

paneExitStatus (tmux/session.ts:308–323): Reads #{pane_dead} #{pane_dead_status} from the lingering pane under remain-on-exit on. Tested against sh -c "exit 3" and confirmed: dead: true, status: 3. This is the mechanism that makes exit-code propagation work through the tmux detach layer.

listTmuxAgentSessions ordering (active.ts:914): Goes first in the getActiveSessions union, so the tmux row (with exact %pane from session meta) wins dedupeBySession over any staler terminal/headless row for the same sessionId. Correct.

resolveViewingIn (viewing-in.ts:73–99): Pure aside from defaulted probes. The injection seam (ViewingInDeps) makes every resolution path unit-testable without a live tmux/ps/osascript. Tests cover Ghostty (cwd+title match), iTerm ($ITERM_SESSION_ID parser), Codium (extension-published tabIndex), tmuxTarget fallback, and the detached case.

Observations that don't block merge

1. Fast-exit race window in runInTmux (exec.ts:1016–1019):

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 before check and attachTmux actually connecting, the pane-died hook tries to detach-client a client that hasn't arrived yet. The user sees "Pane is dead" and must manually Ctrl-b d. Exit code is still correctly recovered by paneExitStatus(after) — so correctness is fine. Low-frequency (only fast-exiting agents), acknowledged in the comment. Acceptable.

2. enumerateGhosttyTabs() called twice (sessions.ts:731, 742):

// 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 surfaces variable if both blocks fire. Not blocking.

3. tabIndex data contract with the extension (viewing-in.ts:168–183):

tabIndexFromLiveTerminals returns the raw tabIndex from live-terminals.json unchanged:

if (e?.sessionId === sessionId && typeof e.tabIndex === 'number') return e.tabIndex;

itermTabFromSessionId converts from 0-based to 1-based (n + 1). If the extension PR publishes a 0-based tabIndex, "Codium tab N" would be off by one vs. Ghostty/iTerm's 1-based presentation. Verify against the extension teammate's tabIndex convention before the extension PR merges.

Things to verify manually

  • The E2E path: ag run claude (without --raw) spawns inside the shared tmux session and agents sessions --active shows the session with %pane. Could not exercise this in the sandbox (tmux absent).
  • The tabIndex data contract point above: iTerm is 0-based → 1-based adjusted; confirm the extension publishes the same convention.

Reviewed by Code Reviewer — ran build and targeted tests on this branch. Read: CLAUDE.md (root), apps/cli/src/lib/exec.ts, apps/cli/src/lib/tmux/session.ts, apps/cli/src/lib/session/viewing-in.ts, apps/cli/src/lib/session/active.ts, apps/cli/src/commands/sessions.ts, apps/cli/src/commands/go.ts, and all three new/modified test files.

@muqsitnawaz muqsitnawaz merged commit d3ffcef into main Jul 7, 2026
4 checks passed
@muqsitnawaz muqsitnawaz deleted the feat/tmux-standardize-v2 branch July 7, 2026 11:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant