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
3 changes: 3 additions & 0 deletions apps/cli/src/commands/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ interface ExecCommandActionOptions {
resume?: string | boolean;
sessionId?: string;
verbose?: boolean;
raw?: boolean;
timeout?: string;
fallback?: string;
balanced?: boolean;
Expand Down Expand Up @@ -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 <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 <duration>', 'Kill the agent after this duration (e.g., 30m, 1h, 2h30m)')
.option(
'--fallback <agents>',
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion apps/cli/src/commands/go.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}` };
Expand Down
37 changes: 27 additions & 10 deletions apps/cli/src/commands/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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'));
}
Expand Down Expand Up @@ -723,17 +733,24 @@ async function enrichLocalLocators(local: ActiveSession[]): Promise<void> {
}
} catch { /* non-fatal */ }

// tmux attach targets, one batched query per distinct socket.
// tmux attach targets + "viewing in <app> 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 */ }
Expand Down
77 changes: 76 additions & 1 deletion apps/cli/src/lib/exec.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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');
});
});
165 changes: 165 additions & 0 deletions apps/cli/src/lib/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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 <cmd>`; we `exec env
* K=V … <agent> <args…>` 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<SpawnResult> {
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<string, string> = { 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.
*
Expand Down Expand Up @@ -936,6 +1078,29 @@ async function spawnAgent(options: ExecOptions): Promise<SpawnResult> {
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
Expand Down
Loading