From 6baad96de831c03db6a093330fb4d40603c261ff Mon Sep 17 00:00:00 2001 From: Muqsit Date: Tue, 7 Jul 2026 04:10:45 -0700 Subject: [PATCH 1/6] feat(tmux): capture pane id + add client/exit/hook primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 " 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) --- apps/cli/src/lib/tmux/session.test.ts | 34 +++++++++ apps/cli/src/lib/tmux/session.ts | 106 +++++++++++++++++++++++++- 2 files changed, 138 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/lib/tmux/session.test.ts b/apps/cli/src/lib/tmux/session.test.ts index 7ba98ac87..30c921fff 100644 --- a/apps/cli/src/lib/tmux/session.test.ts +++ b/apps/cli/src/lib/tmux/session.test.ts @@ -19,7 +19,9 @@ import { hasSession, killAll, killSession, + listClients, listSessions, + paneExitStatus, sendKeys, slugifyName, splitPane, @@ -189,6 +191,38 @@ describe.skipIf(skipReason)('tmux session lifecycle', () => { })).rejects.toThrow(/cwd does not exist/); }); + it('createSession captures the first pane id and records it on the meta', async () => { + const meta = await createSession({ name: 'paneid', cmd: 'sleep 30', socket }); + expect(meta.pane).toMatch(/^%\d+$/); + // The captured pane is a real, addressable pane in the session. + const screen = await capturePane({ name: 'paneid', pane: meta.pane, socket }); + expect(typeof screen).toBe('string'); + }); + + it('listClients returns [] for a detached session (no terminal attached)', async () => { + await createSession({ name: 'noclients', cmd: 'sleep 30', socket }); + // A detached tmux session has no attached clients — the "detached" state the + // viewing-in resolver keys off. + expect(await listClients(socket)).toEqual([]); + }); + + it('paneExitStatus reports the dead pane exit code once the process finishes', async () => { + // remain-on-exit (set globally by createSession) keeps the pane around dead, + // so we can read the wrapped command's exit status — the spawn-wrap's exit-code path. + const meta = await createSession({ name: 'exitcode', cmd: 'sh -c "exit 3"', socket }); + expect(meta.pane).toBeTruthy(); + await wait(400); + const exit = await paneExitStatus(meta.pane!, socket); + expect(exit.dead).toBe(true); + expect(exit.status).toBe(3); + }); + + it('paneExitStatus reports a live pane as not dead', async () => { + const meta = await createSession({ name: 'stillalive', cmd: 'sleep 30', socket }); + const exit = await paneExitStatus(meta.pane!, socket); + expect(exit.dead).toBe(false); + }); + it('remain-on-exit keeps the pane around after the launched command finishes', async () => { // A short-lived command would normally collapse the pane and the session // with it. With remain-on-exit on, the session stays alive. diff --git a/apps/cli/src/lib/tmux/session.ts b/apps/cli/src/lib/tmux/session.ts index 3459240b4..22e7295ee 100644 --- a/apps/cli/src/lib/tmux/session.ts +++ b/apps/cli/src/lib/tmux/session.ts @@ -30,6 +30,13 @@ export interface SessionMeta { source: 'cli' | 'extension' | 'teams' | 'external'; /** Free-form labels callers can stamp (e.g. `{ agent: 'claude', vscodePid: 1234 }`). */ labels?: Record; + /** + * The first pane's id (`%N`) captured at creation. The exact send-keys / + * attach handle for the agent that runs in this session — recorded so + * `agents sessions --active` and the spawn-wrap path (src/lib/exec.ts) don't + * have to re-query it. Absent for pre-existing/`attach-existing` sessions. + */ + pane?: string; } export interface CreateSessionOptions { @@ -130,7 +137,9 @@ export async function createSession(opts: CreateSessionOptions): Promise tab N" + * resolves: a client's `pid` walks the process ancestry to name the host app, + * and its `target` says which session it's attached to. Best-effort — returns + * an empty list on any failure (no server, foreign socket) so the renderer + * degrades to "detached". + */ +export async function listClients(socket?: string): Promise { + let res; + try { + res = await runTmux({ + socket, + args: ['list-clients', '-F', '#{client_tty} #{client_pid} #{session_name}:#{window_index}.#{pane_index}'], + throwOnError: false, + }); + } catch { + return []; + } + if (res.code !== 0) return []; + const out: TmuxClient[] = []; + for (const line of res.stdout.split('\n')) { + const t = line.trim(); + if (!t) continue; + const sp1 = t.indexOf(' '); + if (sp1 < 0) continue; + const sp2 = t.indexOf(' ', sp1 + 1); + if (sp2 < 0) continue; + const tty = t.slice(0, sp1); + const pid = parseInt(t.slice(sp1 + 1, sp2), 10); + const target = t.slice(sp2 + 1).trim(); + if (!Number.isFinite(pid) || !target) continue; + out.push({ tty, pid, target }); + } + return out; +} + +/** A dead pane's exit status, read from tmux while the pane lingers under remain-on-exit. */ +export interface PaneExit { + /** True once the process that ran in the pane has exited (pane is dead). */ + dead: boolean; + /** Exit status of the dead pane's process, when tmux reports it. */ + status?: number; +} + +/** + * Read whether a pane's process has exited and, if so, its exit status. Used by + * the spawn-wrap path to recover the wrapped agent's exit code after the attach + * client returns. Returns `{ dead: false }` when tmux can't answer (session gone, + * pane missing) so the caller treats an unreadable pane as "still alive / detach". + */ +export async function paneExitStatus(pane: string, socket?: string): Promise { + let res; + try { + res = await runTmux({ + socket, + args: ['display-message', '-pt', pane, '-p', '#{pane_dead} #{pane_dead_status}'], + throwOnError: false, + }); + } catch { + return { dead: false }; + } + if (res.code !== 0) return { dead: false }; + const [deadRaw, statusRaw] = res.stdout.trim().split(/\s+/); + const status = statusRaw !== undefined && statusRaw !== '' ? parseInt(statusRaw, 10) : undefined; + return { dead: deadRaw === '1', status: Number.isFinite(status) ? status : undefined }; +} + +/** + * Bind a per-session hook. Used by the spawn-wrap path to install a `pane-died` + * hook that detaches the attach client the instant the wrapped agent exits (the + * global `remain-on-exit on` otherwise leaves the client staring at a dead pane). + * Best-effort — a failed hook just means the user Ctrl-b d's out manually. + */ +export async function setSessionHook(name: string, hook: string, command: string, socket?: string): Promise { + assertValidSessionName(name); + const sock = socket ?? getDefaultSocketPath(); + await runTmux({ socket: sock, args: ['set-hook', '-t', name, hook, command], throwOnError: false }).catch(() => {}); +} + /** * List live sessions on the socket. Reconciles meta JSONs against tmux's view: * - tmux session with no meta → returned without `meta` (external session) From 718b09623327b3df33c5421ee917aaf3c2a46b48 Mon Sep 17 00:00:00 2001 From: Muqsit Date: Tue, 7 Jul 2026 04:10:57 -0700 Subject: [PATCH 2/6] feat(exec): wrap interactive spawns in a shared-socket tmux session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On macOS/Linux, an interactive `agents run ` 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 … ` — 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) --- apps/cli/src/lib/exec.test.ts | 77 +++++++++++++++- apps/cli/src/lib/exec.ts | 165 ++++++++++++++++++++++++++++++++++ 2 files changed, 241 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/lib/exec.test.ts b/apps/cli/src/lib/exec.test.ts index db37fc944..100e5f8be 100644 --- a/apps/cli/src/lib/exec.test.ts +++ b/apps/cli/src/lib/exec.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { shouldTapStdout, resolveInteractive, buildExecCommand, nativeResume, resolveShimSpawn, buildExecEnv } from './exec.js'; +import { shouldTapStdout, resolveInteractive, buildExecCommand, nativeResume, resolveShimSpawn, buildExecEnv, shouldWrapInTmux, buildTmuxAgentCommand, type TmuxWrapContext } from './exec.js'; import type { ExecOptions } from './exec.js'; import { mailboxDir } from './mailbox.js'; @@ -201,3 +201,78 @@ describe('resolveShimSpawn (Windows .cmd shim exec, #shims)', () => { expect(r.shell).toBe(true); }); }); + +describe('shouldWrapInTmux (interactive spawn-wrap gate)', () => { + /** The wrap-eligible baseline: interactive, macOS, not nested, no opt-out, tmux present. */ + const base: TmuxWrapContext = { + interactive: true, + platform: 'darwin', + inTmux: false, + raw: false, + noTmuxEnv: false, + tmuxAvailable: true, + }; + + it('wraps an interactive macOS/Linux run when tmux is available and nothing opts out', () => { + expect(shouldWrapInTmux(base)).toBe(true); + expect(shouldWrapInTmux({ ...base, platform: 'linux' })).toBe(true); + }); + + it('never wraps a headless run (no TTY to attach)', () => { + expect(shouldWrapInTmux({ ...base, interactive: false })).toBe(false); + }); + + it('never wraps on Windows', () => { + expect(shouldWrapInTmux({ ...base, platform: 'win32' })).toBe(false); + }); + + it('never double-wraps when already inside tmux', () => { + expect(shouldWrapInTmux({ ...base, inTmux: true })).toBe(false); + }); + + it('respects the --raw and AGENTS_NO_TMUX escape hatches', () => { + expect(shouldWrapInTmux({ ...base, raw: true })).toBe(false); + expect(shouldWrapInTmux({ ...base, noTmuxEnv: true })).toBe(false); + }); + + it('does not wrap when tmux is not installed', () => { + expect(shouldWrapInTmux({ ...base, tmuxAvailable: false })).toBe(false); + }); +}); + +describe('buildTmuxAgentCommand (env-preserving pane command)', () => { + it('execs the agent with a full env prefix (bare values need no quoting)', () => { + const cmd = buildTmuxAgentCommand('claude', ['--permission-mode', 'plan'], { + CLAUDE_CONFIG_DIR: '/home/me/.agents/versions/claude/2.1/home/.claude', + PATH: '/usr/bin:/bin', + }); + expect(cmd.startsWith('exec env ')).toBe(true); + // Safe values (only [A-Za-z0-9_./:=@%+-]) pass through shellQuote unquoted. + expect(cmd).toContain('CLAUDE_CONFIG_DIR=/home/me/.agents/versions/claude/2.1/home/.claude'); + expect(cmd).toContain('PATH=/usr/bin:/bin'); + // The agent + its args land after the env prefix. + expect(cmd).toMatch(/ claude --permission-mode plan$/); + }); + + it('quotes a value containing spaces and single quotes safely', () => { + const cmd = buildTmuxAgentCommand('claude', ["it's a test"], { FOO: "a b'c" }); + // shellQuote wraps in single quotes and escapes embedded ones — no unquoted breakout. + expect(cmd).toContain("FOO='a b'\\''c'"); + expect(cmd).toContain("'it'\\''s a test'"); + }); + + it('drops non-identifier keys so `env` does not choke on exported shell functions', () => { + const cmd = buildTmuxAgentCommand('claude', [], { + GOOD_KEY: '1', + 'BASH_FUNC_foo%%': '() { echo hi; }', + }); + expect(cmd).toContain('GOOD_KEY='); + expect(cmd).not.toContain('BASH_FUNC_foo'); + }); + + it('does not forward undefined env values', () => { + const cmd = buildTmuxAgentCommand('claude', [], { SET: 'x', UNSET: undefined }); + expect(cmd).toContain('SET='); + expect(cmd).not.toContain('UNSET'); + }); +}); diff --git a/apps/cli/src/lib/exec.ts b/apps/cli/src/lib/exec.ts index 4ba80d3b7..f82533080 100644 --- a/apps/cli/src/lib/exec.ts +++ b/apps/cli/src/lib/exec.ts @@ -20,6 +20,8 @@ import { getShimsDir } from './state.js'; import { writePidSessionEntry, extractSessionIdArg } from './session/pid-registry.js'; import { mailboxDir, isValidMailboxId } from './mailbox.js'; import { composeWin32CommandLine } from './platform/index.js'; +import { isTmuxInstalled } from './tmux/binary.js'; +import { shellQuote } from './ssh-exec.js'; /** * Agent execution modes. Canonical name `skip` (dangerously skip permissions); @@ -168,6 +170,12 @@ export interface ExecOptions { mcpConfigPath?: string; /** Raw args captured after `--` on the command line, forwarded verbatim to the underlying agent CLI. */ passthroughArgs?: string[]; + /** + * Escape hatch for the interactive tmux spawn-wrap (see shouldWrapInTmux): + * when true, spawn the agent directly instead of inside a shared-socket tmux + * session. Also forced off by AGENTS_NO_TMUX=1. No effect on headless runs. + */ + raw?: boolean; } /** @@ -885,6 +893,140 @@ interface SpawnResult { stderr: string; } +/** Inputs that decide whether an interactive spawn is wrapped in a shared-socket tmux session. */ +export interface TmuxWrapContext { + /** resolveInteractive() result — only interactive REPL launches are wrapped. */ + interactive: boolean; + /** process.platform — Windows has no tmux path, always spawns bare. */ + platform: NodeJS.Platform; + /** True when the launcher itself already runs inside tmux ($TMUX set) — never double-wrap. */ + inTmux: boolean; + /** The `--raw` escape hatch. */ + raw: boolean; + /** The AGENTS_NO_TMUX=1 escape hatch. */ + noTmuxEnv: boolean; + /** Whether a tmux binary is on PATH. */ + tmuxAvailable: boolean; +} + +/** + * Decide whether to run an interactive agent INSIDE a detached tmux session on + * the shared socket (then attach the current TTY) instead of a bare spawn. + * + * tmux-wrapping gives every interactive agent an exact, unique `%pane` handle so + * `agents sessions --active` can tell co-located agents apart, and lets `agents + * focus` re-attach a live session without forking it. Pure so the gate is unit- + * tested independently of the (side-effecting) spawn. + * + * All five guards must pass: + * - interactive — a headless `-p` run has no TTY to attach; keep bare spawn. + * - not Windows — no tmux path on win32. + * - not already in tmux — nesting tmux-in-tmux is pointless and confusing. + * - not --raw — explicit opt-out. + * - not AGENTS_NO_TMUX=1 — env opt-out (CI, scripts, the shim passthrough path). + * - tmux installed — otherwise there is nothing to wrap with. + */ +export function shouldWrapInTmux(ctx: TmuxWrapContext): boolean { + if (!ctx.interactive) return false; + if (ctx.platform === 'win32') return false; + if (ctx.inTmux) return false; + if (ctx.raw) return false; + if (ctx.noTmuxEnv) return false; + if (!ctx.tmuxAvailable) return false; + return true; +} + +/** + * Build the shell command that runs an agent inside a tmux pane with the exact + * env the bare spawn would use. tmux runs it via `sh -c `; we `exec env + * K=V … ` so: + * - `env` materializes the full agent env INTO the pane, independent of the + * (possibly stale, shared) tmux server environment — additive, so tmux's own + * $TMUX / $TMUX_PANE still reach the agent for provenance detection; + * - `exec` replaces the shell so the agent is the pane's leaf process (clean + * `#{pane_pid}`, clean signal delivery on detach/kill). + * Keys are filtered to valid identifiers so exported shell functions + * (`BASH_FUNC_*%%`) can't make `env` choke. + */ +export function buildTmuxAgentCommand(executable: string, args: string[], env: NodeJS.ProcessEnv): string { + const envPrefix = Object.entries(env) + .filter(([k, v]) => v !== undefined && EXEC_ENV_KEY_PATTERN.test(k)) + .map(([k, v]) => `${k}=${shellQuote(String(v))}`) + .join(' '); + const agentCmd = [executable, ...args].map(shellQuote).join(' '); + return `exec env ${envPrefix} ${agentCmd}`; +} + +/** + * Run an interactive agent inside a detached tmux session on the shared socket, + * attach the current TTY, and propagate the wrapped agent's exit code. + * + * Lifecycle: + * 1. createSession() launches `sh -c 'exec env … agent'` detached, remain-on-exit + * on (global), and returns the pane id. + * 2. A per-session `pane-died` hook detaches the attach client the instant the + * agent exits, so attach returns instead of parking on a dead pane. + * 3. We record the agent pane's pid → session mapping (WITH the tmux pane) so the + * headless active-scan attributes it, then attach the TTY (blocking). + * 4. On return: if the pane is dead the agent exited — read its status, tear the + * session down, return that code. If the pane is still alive the user detached + * (Ctrl-b d) — return 0 and LEAVE the session for `agents focus` to re-attach. + */ +async function runInTmux(options: ExecOptions, executable: string, args: string[]): Promise { + const { createSession, killSession, paneExitStatus, setSessionHook, slugifyName } = await import('./tmux/session.js'); + const { getDefaultSocketPath } = await import('./tmux/paths.js'); + const { attachTmux, runTmux } = await import('./tmux/binary.js'); + + const socket = getDefaultSocketPath(); + const cwd = options.cwd || process.cwd(); + const idSeed = (options.sessionId ?? randomUUID()).slice(0, 8); + const name = slugifyName(`ag-${options.agent}-${idSeed}`); + const cmd = buildTmuxAgentCommand(executable, args, buildExecEnv(options)); + + const labels: Record = { agent: options.agent }; + if (options.sessionId) labels.sessionId = options.sessionId; + + const meta = await createSession({ name, cmd, cwd, socket, source: 'cli', labels }); + const pane = meta.pane; + + if (pane) { + // Detach the client (don't kill) when the agent exits so the session survives + // just long enough to read the dead pane's exit status below. + await setSessionHook(name, 'pane-died', `detach-client -s =${name}`, socket); + + // Record the agent's OS pid (the pane leaf, thanks to `exec`) WITH its tmux + // pane so the active-scan attributes it exactly and shows the %pane. + let panePid = 0; + try { + const r = await runTmux({ socket, args: ['display-message', '-pt', pane, '-p', '#{pane_pid}'], throwOnError: false }); + panePid = parseInt(r.stdout.trim(), 10) || 0; + } catch { /* best-effort */ } + writePidSessionEntry({ + pid: panePid, + agent: options.agent, + sessionId: options.sessionId, + cwd, + tmuxPane: pane, + startedAtMs: Date.now(), + }); + } + + // The agent could exit before we attach (fast failure). Don't attach to an + // already-dead pane — read its status directly and tear down. + const before = pane ? await paneExitStatus(pane, socket) : { dead: false }; + if (!before.dead) { + await attachTmux({ socket, args: ['attach-session', '-t', name] }); + } + + const after = pane ? await paneExitStatus(pane, socket) : { dead: false }; + if (after.dead) { + await killSession(name, socket).catch(() => {}); + return { exitCode: after.status ?? 0, stderr: '' }; + } + // Pane still alive → the user detached; keep the session for `agents focus`. + return { exitCode: 0, stderr: '' }; +} + /** * Spawn an agent process and return its exit code plus a tee'd copy of stderr. * @@ -936,6 +1078,29 @@ async function spawnAgent(options: ExecOptions): Promise { args: redactArgs(args.slice(0, 10)), }); + // Interactive spawn-wrap: on macOS/Linux, run the agent INSIDE a shared-socket + // tmux session (then attach this TTY) so it gets a unique, addressable %pane. + // Headless runs, Windows, already-in-tmux, --raw, and AGENTS_NO_TMUX=1 keep the + // bare spawn below. See shouldWrapInTmux / runInTmux. + if (shouldWrapInTmux({ + interactive, + platform: process.platform, + inTmux: !!process.env.TMUX, + raw: options.raw === true, + noTmuxEnv: process.env.AGENTS_NO_TMUX === '1', + tmuxAvailable: isTmuxInstalled(), + })) { + timer.mark('startup'); + try { + const result = await runInTmux(options, executable, args); + timer.end({ exitCode: result.exitCode, status: result.exitCode === 0 ? 'success' : 'failed' }); + return result; + } catch (err) { + timer.end({ error: (err as Error).message, exitCode: -1, status: 'error' }); + throw err; + } + } + return new Promise((resolve, reject) => { // Interactive mode inherits all stdio so the CLI owns the TTY (TUI // rendering, raw-mode keystrokes, colored output). Headless mode pipes From 9168a48f5ee951821ecd718ddf79b69db2cd6a06 Mon Sep 17 00:00:00 2001 From: Muqsit Date: Tue, 7 Jul 2026 04:11:02 -0700 Subject: [PATCH 3/6] feat(run): add --raw escape hatch for the tmux spawn-wrap `agents run --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) --- apps/cli/src/commands/exec.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/cli/src/commands/exec.ts b/apps/cli/src/commands/exec.ts index 8a29ffba3..c20f8ee62 100644 --- a/apps/cli/src/commands/exec.ts +++ b/apps/cli/src/commands/exec.ts @@ -38,6 +38,7 @@ interface ExecCommandActionOptions { resume?: string | boolean; sessionId?: string; verbose?: boolean; + raw?: boolean; timeout?: string; fallback?: string; balanced?: boolean; @@ -317,6 +318,7 @@ export function registerRunCommand(program: Command): void { .option('--resume [id]', 'Resume a previous conversation. Accepts a full or partial session id (prefix-matched against the index); omit the id to pick from recent sessions interactively. Resumes under the version that started the session. claude/codex resume natively; other agents replay via a /continue first message. Pair with a prompt to continue headlessly.') .option('--session-id ', 'Force a NEW conversation to use this exact session UUID (Claude only). This CREATES a session — to resume an existing one, use --resume.') .option('--verbose', 'Show detailed execution logs') + .option('--raw', 'Interactive runs on macOS/Linux launch inside a shared tmux session (for %pane addressing + re-attach). Pass --raw to spawn the agent directly instead. Also disabled by AGENTS_NO_TMUX=1.') .option('--timeout ', 'Kill the agent after this duration (e.g., 30m, 1h, 2h30m)') .option( '--fallback ', @@ -1235,6 +1237,7 @@ export function registerRunCommand(program: Command): void { sessionId: resumeSessionId ?? options.sessionId, resume: resumeNative, verbose: options.verbose, + raw: options.raw, timeout: options.timeout, env, toolsRestrict: workflowToolsRestrict, From 4e7b696047889d20e96043299ee4a3ebb26332e0 Mon Sep 17 00:00:00 2001 From: Muqsit Date: Tue, 7 Jul 2026 04:11:09 -0700 Subject: [PATCH 4/6] feat(sessions): make the shared tmux server an authoritative active source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/cli/src/lib/session/active.ts | 106 +++++++++++++++++++++++++++-- 1 file changed, 101 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/lib/session/active.ts b/apps/cli/src/lib/session/active.ts index bfcf38e9d..bb98483c2 100644 --- a/apps/cli/src/lib/session/active.ts +++ b/apps/cli/src/lib/session/active.ts @@ -122,6 +122,14 @@ export interface ActiveSession { * (after the --json/--waiting gates) — NOT emitted on the discovery path. */ tmuxTarget?: string; + /** + * Which host app + tab a tmux-hosted session is currently being VIEWED in, + * resolved from the attached tmux client (its terminal PID -> app via + * HOST_MATCHERS, its tab via the per-app resolver). `undefined` means no + * client is attached — the session is running detached. Transient, + * renderer-set (see src/lib/session/viewing-in.ts) — NOT on the discovery path. + */ + viewingIn?: { app: string; tab?: number }; } export interface ActiveQueryOptions { @@ -638,6 +646,20 @@ function detectHost(pid: number, procByPid: Map): string | unde return undefined; } +/** + * Resolve the host app for a single pid by walking its process ancestry with the + * same HOST_MATCHERS logic `detectHost` uses. Reads the whole process table per + * call, so it's for the low-cardinality renderer path (one tmux client per + * session), not a hot loop. Returns undefined when nothing above the pid is a + * recognised UI. Exported for the "viewing in " resolver. + */ +export async function hostFromPid(pid: number): Promise { + if (!pid || pid < 1) return undefined; + const procByPid = new Map(); + for (const r of await readProcessTable()) procByPid.set(r.pid, r); + return detectHost(pid, procByPid); +} + /** IDE / terminal / multiplexer hosts all count as UI-hosted. Absence = truly headless. */ const UI_HOSTS = new Set([ 'code', 'cursor', 'codium', 'windsurf', @@ -812,24 +834,98 @@ export async function listUnattributedActive(attributed: Set): Promise { + const { getDefaultSocketPath } = await import('../tmux/paths.js'); + const { readSessionMeta } = await import('../tmux/session.js'); + const { runTmux } = await import('../tmux/binary.js'); + const socket = getDefaultSocketPath(); + if (!fs.existsSync(socket)) return []; + + let res; + try { + res = await runTmux({ + socket, + args: ['list-panes', '-a', '-F', '#{pane_id}\t#{session_name}\t#{pane_pid}\t#{pane_current_path}'], + throwOnError: false, + }); + } catch { + return []; + } + if (res.code !== 0) return []; + + const out: ActiveSession[] = []; + const seen = new Set(); + for (const line of res.stdout.split('\n')) { + if (!line.trim()) continue; + const [pane, sessName, pidRaw, curPath] = line.split('\t'); + if (!pane || !sessName) continue; + const meta = readSessionMeta(sessName); + const agent = meta?.labels?.agent; + const sessionId = meta?.labels?.sessionId; + if (!agent || !sessionId) continue; // only our stamped agent sessions + if (meta?.source === 'teams') continue; // teammates come from listTeamsActive + if (seen.has(sessionId)) continue; // first pane per session wins + seen.add(sessionId); + + const pid = parseInt(pidRaw, 10) || undefined; + const cwd = meta?.cwd ?? (curPath || undefined); + const sessionFile = findSessionFileForKind(agent, cwd, sessionId); + const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined; + const state = computeLiveState(agent, sessionFile, cwd, pid ? isPidAlive(pid) : true); + // Provenance is known exactly here (the pane IS a tmux pane) — set it so + // enrichProvenance skips it and the locator/reply rails resolve off the pane. + const provenance: SessionProvenance = { + host: os.hostname(), + transport: 'local', + mux: { kind: 'tmux', socket, pane }, + reply: { rail: 'tmux', target: pane, socket }, + }; + out.push(applyState({ + context: 'terminal', + kind: agent, + host: 'tmux', + pid, + sessionId, + cwd, + topic, + sessionFile, + provenance, + }, state, sessionFile)); + } + return out; +} + +/** + * Union of all sources. Teams and terminals spawn actual CLI processes that + * also show up in `ps`, so headless attribution runs last with the already- + * attributed PIDs removed. The tmux source goes FIRST into the dedupe so a + * tmux-hosted agent's row (which carries the exact `%pane`) wins over a staler + * terminal/headless row for the same session id. */ export async function getActiveSessions(opts: ActiveQueryOptions = {}): Promise { - const [teams, terminals, cloud] = await Promise.all([ + const [tmuxAgents, teams, terminals, cloud] = await Promise.all([ + listTmuxAgentSessions().catch(() => [] as ActiveSession[]), listTeamsActive().catch(() => [] as ActiveSession[]), listTerminalsActive().catch(() => [] as ActiveSession[]), Promise.resolve(listCloudActive()), ]); const knownPids = new Set(); + for (const s of tmuxAgents) if (s.pid) knownPids.add(s.pid); for (const s of teams) if (s.pid) knownPids.add(s.pid); for (const s of terminals) if (s.pid) knownPids.add(s.pid); const unattributed = opts.skipHeadless ? [] : await listUnattributedActive(knownPids); - const merged = dedupeBySession([...teams, ...terminals, ...cloud, ...unattributed]); + const merged = dedupeBySession([...tmuxAgents, ...teams, ...terminals, ...cloud, ...unattributed]); await enrichProvenance(merged); return merged; } From c477ca94798e76545b955eb715457e8a40cc2428 Mon Sep 17 00:00:00 2001 From: Muqsit Date: Tue, 7 Jul 2026 04:11:22 -0700 Subject: [PATCH 5/6] feat(sessions): resolve "viewing in tab N" for tmux-hosted sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- apps/cli/src/lib/session/viewing-in.test.ts | 108 ++++++++++++ apps/cli/src/lib/session/viewing-in.ts | 183 ++++++++++++++++++++ 2 files changed, 291 insertions(+) create mode 100644 apps/cli/src/lib/session/viewing-in.test.ts create mode 100644 apps/cli/src/lib/session/viewing-in.ts diff --git a/apps/cli/src/lib/session/viewing-in.test.ts b/apps/cli/src/lib/session/viewing-in.test.ts new file mode 100644 index 000000000..7f4e73b41 --- /dev/null +++ b/apps/cli/src/lib/session/viewing-in.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from 'vitest'; +import { resolveViewingIn, itermTabFromSessionId, type ViewingInDeps } from './viewing-in.js'; +import type { ActiveSession } from './active.js'; +import type { TmuxClient } from '../tmux/session.js'; +import type { GhosttySurface } from './ghostty-tabs.js'; + +/** A tmux-hosted active session whose pane maps to session `ag-claude-1`. */ +function tmuxSession(over: Partial = {}): ActiveSession { + return { + context: 'terminal', + kind: 'claude', + host: 'tmux', + status: 'running', + sessionId: 'sess-abc', + cwd: '/repo', + provenance: { + host: 'zion', + transport: 'local', + mux: { kind: 'tmux', socket: '/sock', pane: '%3' }, + reply: { rail: 'tmux', target: '%3', socket: '/sock' }, + }, + ...over, + }; +} + +/** pane %3 belongs to session `ag-claude-1`, window 0, pane 0. */ +const paneToTarget = new Map([['%3', 'ag-claude-1:0.0']]); + +function client(over: Partial = {}): TmuxClient { + return { tty: '/dev/ttys004', pid: 4242, target: 'ag-claude-1:0.0', ...over }; +} + +describe('resolveViewingIn', () => { + it('returns undefined for a non-tmux session', async () => { + const s = tmuxSession({ provenance: { host: 'z', transport: 'local', reply: null } }); + expect(await resolveViewingIn(s, [client()], { paneToTarget })).toBeUndefined(); + }); + + it('returns undefined (detached) when no client is attached to the session', async () => { + const s = tmuxSession(); + // A client attached to a DIFFERENT session must not count as viewing this one. + const other = client({ target: 'ag-codex-9:0.0' }); + expect(await resolveViewingIn(s, [other], { paneToTarget })).toBeUndefined(); + }); + + it('names the host app of the attached client (no tab for a plain terminal)', async () => { + const deps: ViewingInDeps = { + paneToTarget, + resolveApp: async () => 'iterm', + readClientEnv: async () => ({}), // no ITERM_SESSION_ID -> no tab + }; + const v = await resolveViewingIn(tmuxSession(), [client()], deps); + expect(v).toEqual({ app: 'iterm', tab: undefined }); + }); + + it('resolves the iTerm tab from the client ITERM_SESSION_ID', async () => { + const deps: ViewingInDeps = { + paneToTarget, + resolveApp: async () => 'iterm', + readClientEnv: async () => ({ ITERM_SESSION_ID: 'w0t2p0:UUID-XYZ' }), + }; + const v = await resolveViewingIn(tmuxSession(), [client()], deps); + // t2 is iTerm's 0-based tab index -> presented 1-based as tab 3. + expect(v).toEqual({ app: 'iterm', tab: 3 }); + }); + + it('resolves a Ghostty tab by cwd match against the surfaces', async () => { + const surfaces: GhosttySurface[] = [ + { windowIndex: 1, tabIndex: 5, cwd: '/repo', title: 'claude' }, + ]; + const deps: ViewingInDeps = { + paneToTarget, + resolveApp: async () => 'ghostty', + ghosttySurfaces: surfaces, + }; + const v = await resolveViewingIn(tmuxSession(), [client()], deps); + expect(v).toEqual({ app: 'ghostty', tab: 5 }); + }); + + it('resolves a VS Code / Codium tab from the extension-published tabIndex', async () => { + const deps: ViewingInDeps = { + paneToTarget, + resolveApp: async () => 'codium', + tabIndexForSession: (id) => (id === 'sess-abc' ? 2 : undefined), + }; + const v = await resolveViewingIn(tmuxSession(), [client()], deps); + expect(v).toEqual({ app: 'codium', tab: 2 }); + }); + + it('falls back to session.tmuxTarget when no paneToTarget is provided', async () => { + const s = tmuxSession({ tmuxTarget: 'ag-claude-1:0.0' }); + const deps: ViewingInDeps = { resolveApp: async () => 'terminal', readClientEnv: async () => ({}) }; + const v = await resolveViewingIn(s, [client()], deps); + expect(v?.app).toBe('terminal'); + }); +}); + +describe('itermTabFromSessionId', () => { + it('parses the t field 1-based, tolerating the full w/t/p:UUID shape', () => { + expect(itermTabFromSessionId('w0t0p0:ABC')).toBe(1); + expect(itermTabFromSessionId('w1t4p2:ABC')).toBe(5); + }); + it('returns undefined for empty or unparseable values', () => { + expect(itermTabFromSessionId(undefined)).toBeUndefined(); + expect(itermTabFromSessionId('')).toBeUndefined(); + expect(itermTabFromSessionId('no-tab-here')).toBeUndefined(); + }); +}); diff --git a/apps/cli/src/lib/session/viewing-in.ts b/apps/cli/src/lib/session/viewing-in.ts new file mode 100644 index 000000000..aad16d3c8 --- /dev/null +++ b/apps/cli/src/lib/session/viewing-in.ts @@ -0,0 +1,183 @@ +/** + * "Viewing in tab N" for tmux-hosted agent sessions. + * + * A tmux-wrapped agent (see src/lib/exec.ts `runInTmux`) runs detached on the + * shared socket; a terminal only *displays* it while a client is attached. This + * resolver answers "which app + tab is looking at this session right now" by + * matching the session to its attached tmux client(s) and reusing the app/tab + * resolvers we already have: + * + * - app — the client's terminal PID walked up the process ancestry via the + * shared HOST_MATCHERS logic (`hostFromPid`). + * - tab — per app: Ghostty via `assignGhosttyTabs` (cwd + title match), iTerm + * via the `t` field of the client's `$ITERM_SESSION_ID`, and + * VS Code / Cursor / Codium via the extension-published `tabIndex` in + * live-terminals.json (keyed by session id). + * + * No client attached => `undefined` (the session is running detached). Every + * lookup is best-effort; a miss degrades to `{ app }` with no tab, never throws. + */ + +import * as path from 'path'; +import * as fs from 'fs'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { readFile } from 'fs/promises'; +import type { TmuxClient } from '../tmux/session.js'; +import type { ActiveSession } from './active.js'; +import { hostFromPid } from './active.js'; +import { enumerateGhosttyTabs, assignGhosttyTabs, type GhosttySurface } from './ghostty-tabs.js'; +import { getTerminalsDir } from '../state.js'; + +const execFileAsync = promisify(execFile); + +/** Where a tmux-hosted session is currently displayed. */ +export interface ViewingIn { + /** Host app of the attached client — 'ghostty', 'iterm', 'code', 'codium', … */ + app: string; + /** 1-based tab number within that app, when it can be resolved. */ + tab?: number; +} + +/** Injection seams so `resolveViewingIn` is unit-testable without a live tmux/ps/osascript. */ +export interface ViewingInDeps { + /** Ghostty surfaces (window/tab/cwd/title). Enumerated once by the caller and shared. */ + ghosttySurfaces?: GhosttySurface[]; + /** pane id -> `session:window.pane`, from `mapPanesToTargets`. Used to find the session name. */ + paneToTarget?: Map; + /** client pid -> host app. Defaults to the real `hostFromPid`. */ + resolveApp?: (pid: number) => Promise; + /** client pid -> raw env. Defaults to reading /proc (Linux) or `ps eww` (macOS). */ + readClientEnv?: (pid: number) => Promise | undefined>; + /** session id -> VS Code editor-tab index, from live-terminals.json. Defaults to the real read. */ + tabIndexForSession?: (sessionId: string | undefined) => number | undefined; +} + +/** Apps whose tab index is published by the extension via live-terminals.json. */ +const EDITOR_APPS = new Set(['code', 'cursor', 'codium', 'windsurf']); + +/** The tmux session name a session's pane belongs to, from `session:window.pane`. */ +function sessionNameFor(session: ActiveSession, paneToTarget?: Map): string | undefined { + const pane = session.provenance?.mux?.pane; + const target = (pane && paneToTarget?.get(pane)) ?? session.tmuxTarget; + if (!target) return undefined; + const name = target.split(':')[0]; + return name || undefined; +} + +/** + * Resolve where a single tmux-hosted session is being viewed. Returns undefined + * when the session isn't tmux-hosted, can't be located, or has no client + * attached (detached). Pure aside from the injected (defaulted) probes. + */ +export async function resolveViewingIn( + session: ActiveSession, + clients: TmuxClient[], + deps: ViewingInDeps = {}, +): Promise { + if (session.provenance?.mux?.kind !== 'tmux' || !session.provenance.mux.pane) return undefined; + const sessName = sessionNameFor(session, deps.paneToTarget); + if (!sessName) return undefined; + + const attached = clients.filter((c) => c.target.split(':')[0] === sessName); + if (attached.length === 0) return undefined; // running detached — no viewer + + const client = attached[0]; + const resolveApp = deps.resolveApp ?? hostFromPid; + const app = (await resolveApp(client.pid)) ?? 'terminal'; + + let tab: number | undefined; + if (app === 'ghostty') { + tab = await ghosttyTab(session, deps.ghosttySurfaces); + } else if (app === 'iterm') { + tab = await itermTab(client.pid, deps.readClientEnv ?? readClientEnv); + } else if (EDITOR_APPS.has(app)) { + const lookup = deps.tabIndexForSession ?? tabIndexFromLiveTerminals; + tab = lookup(session.sessionId); + } + return { app, tab }; +} + +/** Ghostty tab via the existing cwd+title matcher, reusing shared surfaces when provided. */ +async function ghosttyTab(session: ActiveSession, surfaces?: GhosttySurface[]): Promise { + const s = surfaces ?? (await enumerateGhosttyTabs()); + if (s.length === 0) return undefined; + // assignGhosttyTabs only considers host === 'ghostty' sessions; use a probe + // clone so we don't mutate the real row's host. + const probe = { ...session, host: 'ghostty' } as ActiveSession; + return assignGhosttyTabs([probe], s).get(probe); +} + +/** + * iTerm tab from the attaching client's `$ITERM_SESSION_ID` (`wtp:UUID`). + * The `t` field is iTerm2's 0-based tab index; we present it 1-based to match + * Ghostty's `index of tab`. Exported for the parser test. + */ +export function itermTabFromSessionId(value: string | undefined): number | undefined { + if (!value) return undefined; + const m = value.match(/t(\d+)/); + if (!m) return undefined; + const n = parseInt(m[1], 10); + return Number.isFinite(n) ? n + 1 : undefined; +} + +async function itermTab( + pid: number, + readEnv: (pid: number) => Promise | undefined>, +): Promise { + const env = await readEnv(pid); + return itermTabFromSessionId(env?.ITERM_SESSION_ID); +} + +/** Read a process's env (best-effort): /proc on Linux, `ps eww` on macOS. */ +async function readClientEnv(pid: number): Promise | undefined> { + if (process.platform === 'linux') { + try { + const buf = await readFile(`/proc/${pid}/environ`, 'utf8'); + const env: Record = {}; + for (const pair of buf.split('\0')) { + const eq = pair.indexOf('='); + if (eq > 0) env[pair.slice(0, eq)] = pair.slice(eq + 1); + } + return env; + } catch { + return undefined; + } + } + if (process.platform === 'darwin') { + try { + const { stdout } = await execFileAsync('ps', ['eww', '-p', String(pid), '-o', 'command='], { + encoding: 'utf8', + maxBuffer: 1024 * 1024, + }); + // ITERM_SESSION_ID is a single token (no spaces): grab it out of the flat line. + const m = stdout.match(/(?:^|\s)ITERM_SESSION_ID=(\S+)/); + return m ? { ITERM_SESSION_ID: m[1] } : {}; + } catch { + return undefined; + } + } + return undefined; +} + +/** + * VS Code editor-tab index for a session, from the extension's live-terminals.json + * (`tabIndex` per entry — the DATA CONTRACT with the extension teammate). Read + * directly (not via active.ts's readLiveTerminals, which strips tabIndex). + */ +function tabIndexFromLiveTerminals(sessionId: string | undefined): number | undefined { + if (!sessionId) return undefined; + let parsed: any; + try { + parsed = JSON.parse(fs.readFileSync(path.join(getTerminalsDir(), 'live-terminals.json'), 'utf8')); + } catch { + return undefined; + } + if (!parsed || typeof parsed !== 'object') return undefined; + for (const slice of Object.values(parsed) as any[]) { + for (const e of (slice?.entries ?? []) as any[]) { + if (e?.sessionId === sessionId && typeof e.tabIndex === 'number') return e.tabIndex; + } + } + return undefined; +} From 13299bdca6a30dffda23b7fdd8650abc875000cd Mon Sep 17 00:00:00 2001 From: Muqsit Date: Tue, 7 Jul 2026 04:11:28 -0700 Subject: [PATCH 6/6] feat(sessions): render "viewing in tab N" / "detached" for tmux rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit enrichLocalLocators now resolves the current viewer per tmux socket (listClients + resolveViewingIn, one shared Ghostty enumeration), and the locator badge appends "viewing in 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) --- apps/cli/src/commands/go.ts | 7 +++++- apps/cli/src/commands/sessions.ts | 37 ++++++++++++++++++++++--------- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/apps/cli/src/commands/go.ts b/apps/cli/src/commands/go.ts index ccf247bd0..0f253597b 100644 --- a/apps/cli/src/commands/go.ts +++ b/apps/cli/src/commands/go.ts @@ -133,9 +133,14 @@ export function describeWhere(s: ActiveSession, self: string): Where { const remote = s.machine && s.machine !== self ? s.machine : undefined; const mux = s.provenance?.mux; if (mux?.kind === 'tmux' && mux.pane) { + // When the renderer has resolved the current viewer, fold it into the label + // so `focus` reports "tmux %3 (viewing in codium tab 2)" / "(detached)". + const view = s.viewingIn + ? ` (viewing in ${s.viewingIn.app}${s.viewingIn.tab != null ? ` tab ${s.viewingIn.tab}` : ''})` + : ''; return remote ? { label: `tmux ${mux.pane} on ${remote}`, action: `ssh + attach on ${remote}` } - : { label: `tmux ${mux.pane}`, action: 'attach its tmux' }; + : { label: `tmux ${mux.pane}${view}`, action: 'attach its tmux' }; } if (!remote && s.host === 'ghostty') return { label: 'Ghostty', action: 'focus its Ghostty tab' }; if (remote) return { label: `${s.host ?? 'shell'} on ${remote}`, action: `open a shell on ${remote}` }; diff --git a/apps/cli/src/commands/sessions.ts b/apps/cli/src/commands/sessions.ts index b5a741069..2f9ab7fe5 100644 --- a/apps/cli/src/commands/sessions.ts +++ b/apps/cli/src/commands/sessions.ts @@ -21,7 +21,8 @@ import { discoverArtifacts, readArtifact, resolveArtifact } from '../lib/session import { looksLikePath, toComparablePath, homeDir, needsWindowsShell, findExecutable } from '../lib/platform/index.js'; import { getActiveSessions, type ActiveSession } from '../lib/session/active.js'; import { enumerateGhosttyTabs, assignGhosttyTabs } from '../lib/session/ghostty-tabs.js'; -import { mapPanesToTargets } from '../lib/tmux/session.js'; +import { mapPanesToTargets, listClients } from '../lib/tmux/session.js'; +import { resolveViewingIn } from '../lib/session/viewing-in.js'; import { machineId, normalizeHost } from '../lib/session/sync/config.js'; import { gatherRemoteActive, NO_FANOUT_ENV } from '../lib/session/remote-active.js'; import { gatherRemoteList, runOnPeer } from '../lib/session/remote-list.js'; @@ -402,6 +403,15 @@ function locatorBadge(s: ActiveSession): string { if (p?.transport === 'ssh') parts.push(chalk.red('ssh')); if (p?.mux?.kind === 'tmux' && (s.tmuxTarget || p.mux.pane)) { parts.push(chalk.green(s.tmuxTarget ?? p.mux.pane!)); + // For a tmux-hosted session, say which app+tab is looking at it right now + // (or that it's running detached). Only meaningful for tmux (the pane is the + // durable handle; the viewer is transient). + if (s.viewingIn) { + const tab = s.viewingIn.tab != null ? ` tab ${s.viewingIn.tab}` : ''; + parts.push(chalk.gray(`viewing in ${s.viewingIn.app}${tab}`)); + } else { + parts.push(chalk.gray('detached')); + } } else if (p?.mux?.kind === 'screen') { parts.push(chalk.green('screen')); } @@ -723,17 +733,24 @@ async function enrichLocalLocators(local: ActiveSession[]): Promise { } } catch { /* non-fatal */ } - // tmux attach targets, one batched query per distinct socket. + // tmux attach targets + "viewing in tab N", one batched query per socket. try { const tmux = local.filter(s => s.provenance?.mux?.kind === 'tmux' && s.provenance.mux.pane); - const sockets = new Set(tmux.map(s => s.provenance!.mux!.socket)); - for (const socket of sockets) { - const paneMap = await mapPanesToTargets(socket); - if (paneMap.size === 0) continue; - for (const s of tmux) { - if (s.provenance!.mux!.socket !== socket) continue; - const target = paneMap.get(s.provenance!.mux!.pane!); - if (target) s.tmuxTarget = target; + if (tmux.length > 0) { + // One Ghostty enumeration shared across every socket's viewing-in resolve + // (a tmux client can be attached from a Ghostty tab). + const surfaces = await enumerateGhosttyTabs(); + const sockets = new Set(tmux.map(s => s.provenance!.mux!.socket)); + for (const socket of sockets) { + const paneMap = await mapPanesToTargets(socket); + if (paneMap.size === 0) continue; + const clients = await listClients(socket); + for (const s of tmux) { + if (s.provenance!.mux!.socket !== socket) continue; + const target = paneMap.get(s.provenance!.mux!.pane!); + if (target) s.tmuxTarget = target; + s.viewingIn = await resolveViewingIn(s, clients, { paneToTarget: paneMap, ghosttySurfaces: surfaces }); + } } } } catch { /* non-fatal */ }