/** * @file pty-run.js * @description Owns the tmux-backed run lifecycle for the dashboard's * terminal-run feature: Start (create-or-adopt), Resume (`--resume`), Kill, * and List. Unlike the old run-spawner.js, there is no in-memory handle Map — * `listRuns`/`getRun` are computed fresh from `tmux list-sessions` on every * call, the same "computed fact, never a stored one" principle this repo * already applies to lane runtime up/down (see CLAUDE.md). A session killed * out-of-band (crash, manual `tmux kill-session`, host reboot) self-corrects * on the next read instead of leaving a ghost "running" row. * * Start/Resume is create-or-reuse: when the lane's tmux session already * exists but its pane sits at a shell prompt, the argv is typed into that * pane instead of being dropped on the floor by a silent adopt. * * Every session is named `agenthub-lane-` so a real terminal can attach * to the exact same session (`tmux attach -t agenthub-lane-`, or * `agenthub lanes shell`) — that's the whole point: the dashboard both creates * the session (one-click Start/Resume) and is just one of possibly several * attached clients tmux already keeps in sync. * * Spawns `claude`, `codex` or `agy` (Antigravity) in the tmux pane based on * the tool parameter. * * A lane can hold up to four sessions, one per Workspace split pane: pane 0 is * `agenthub-lane-` (the lane's own `run_id`), panes 1–3 are * `agenthub-lane--`. * * @author Nguyễn Ngọc Trí Vĩ */ const tmux = require("./tmux"); const { routerArgvForSpawn, routerEnvForSpawn } = require("./router-apply"); const { desktopEnv } = require("./desktop-env"); let dashboardRuns = null; try { dashboardRuns = require("./dashboard-runs"); } catch { /* db-less environment, skip persistence */ } const RUN_ID_RE = /^agenthub-lane-(\d+)(?:-([1-3]))?$/; /** Highest pane slot a lane may use (the Workspace's 4-pane grid is 0–3). */ const MAX_PANE_SLOT = 3; const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]); // `auto` is the CLI's own default-ish mode (`claude --permission-mode auto`): // it asks only for what its classifier reads as risky. `default` is kept for // callers that stored it, though this CLI now spells that one `manual`. const ALLOWED_PERMISSION_MODES = new Set([ "auto", "acceptEdits", "default", "plan", "bypassPermissions", ]); // Pane commands that mean "idle shell prompt, safe to type a command into". const SHELL_COMMANDS = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh"]); const VALID_TOOLS = new Set(["claude", "codex", "antigravity"]); const CODEX_SANDBOXES = new Set(["read-only", "workspace-write", "danger-full-access"]); const CODEX_APPROVALS = new Set(["on-request", "never"]); // Codex has no `--effort` flag; reasoning effort is a config override. const CODEX_EFFORTS = new Set(["low", "medium", "high", "xhigh"]); function runIdForLane(laneId, slot = 0) { return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`; } function slotFromRunId(id) { const m = typeof id === "string" ? id.match(RUN_ID_RE) : null; return m ? Number(m[2] || 0) : null; } function laneIdFromRunId(id) { const m = typeof id === "string" ? id.match(RUN_ID_RE) : null; return m ? Number(m[1]) : null; } function makeErr(code, message) { const err = new Error(message); err.code = code; return err; } /** * Build the pane's command argv for Claude. Unlike the old stream-json spawner * there is no headless/conversation split — every run is a live interactive * pane, so an initial prompt (when given) is a trailing POSITIONAL argument: * `claude` treats a bare positional as the first turn's message and stays * interactive afterward (unlike `-p`, which forces print-and-exit and closes * stdin). */ function buildClaudeArgv({ model, permissionMode, effort, resumeSessionId, initialPrompt }) { const argv = ["claude"]; argv.push("--permission-mode", permissionMode || "auto"); // Puts "bypass permissions" INTO the pane's shift+tab cycle without turning // it on: `claude --help` — "Enable bypassing all permission checks as an // option, without it being enabled by default." Without it the cycle is only // plan/auto/manual/acceptEdits, so a run started in any non-bypass mode could // never reach bypass from inside the pane, while a real terminal could. // The starting mode is still whatever --permission-mode says above. argv.push("--allow-dangerously-skip-permissions"); if (model) argv.push("--model", model); if (effort && EFFORT_LEVELS.has(effort)) argv.push("--effort", effort); if (resumeSessionId) argv.push("--resume", resumeSessionId); if (initialPrompt) argv.push(initialPrompt); return argv; } /** * Codex's CLI shape differs from Claude's in two ways that matter here: * `resume` is a SUBCOMMAND (so it precedes every flag) and reasoning effort has * no flag of its own — it rides on a `-c` config override. The prompt stays a * trailing positional, same as Claude, so the pane stays interactive. */ function buildCodexArgv({ model, effort, sandbox, approval, resumeSessionId, resumeLast, initialPrompt, routerProfile, }) { const argv = ["codex"]; // Before the subcommand and before the prompt: `-p` layers // `$CODEX_HOME/.config.toml` over the base config, and an option that // lands after the positional prompt is not an option any more. argv.push(...routerArgvForSpawn({ tool: "codex", router_profile: routerProfile })); if (resumeSessionId) argv.push("resume", resumeSessionId); else if (resumeLast) argv.push("resume", "--last"); if (model) argv.push("-m", model); if (sandbox) argv.push("-s", sandbox); if (approval) argv.push("-a", approval); if (effort && CODEX_EFFORTS.has(effort)) argv.push("-c", `model_reasoning_effort=${effort}`); if (initialPrompt) argv.push(initialPrompt); return argv; } /** * Antigravity's `agy` has no model, effort or permission-mode flag, so only * resume and the first prompt reach it. `-i` keeps the session interactive * after that prompt (`-p` would print and exit). `--dangerously-skip-permissions` * is never emitted, same rule as Codex's bypass flags. */ function buildAntigravityArgv({ resumeLast, initialPrompt }) { const argv = ["agy"]; if (resumeLast) argv.push("--continue"); if (initialPrompt) argv.push("-i", initialPrompt); return argv; } function buildArgv(args) { if (args.tool === "codex") return buildCodexArgv(args); if (args.tool === "antigravity") return buildAntigravityArgv(args); return buildClaudeArgv(args); } /** * @param {object} args * @param {number} args.laneId * @param {string} args.cwd * @param {string} [args.tool="claude"] * @param {string} [args.model] * @param {string} [args.permissionMode] * @param {string} [args.effort] * @param {string} [args.sandbox] * @param {string} [args.approval] * @param {string} [args.resumeSessionId] * @param {boolean} [args.resumeLast] * @param {string} [args.initialPrompt] * @param {string} [args.routerProfile] AI Router profile the LANE runs against * (`lanes.router_profile`), never a request field. */ function spawnRun(args) { const { laneId, slot = 0, cwd, tool = "claude", model, permissionMode, effort, sandbox, approval, resumeSessionId, resumeLast, initialPrompt, routerProfile, } = args || {}; if (typeof laneId !== "number" || !Number.isInteger(laneId)) { throw makeErr("EBADLANE", "laneId must be an integer"); } if (!Number.isInteger(slot) || slot < 0 || slot > MAX_PANE_SLOT) { throw makeErr("EBADSLOT", `slot must be an integer 0–${MAX_PANE_SLOT}`); } if (typeof cwd !== "string" || !cwd) { throw makeErr("EBADCWD", "cwd is required"); } if (!VALID_TOOLS.has(tool)) { throw makeErr("EBADTOOL", `tool must be one of: ${Array.from(VALID_TOOLS).join(", ")}`); } if (tool === "codex") { if (sandbox != null && sandbox !== "" && !CODEX_SANDBOXES.has(sandbox)) { throw makeErr( "EBADSANDBOX", `sandbox must be one of: ${Array.from(CODEX_SANDBOXES).join(", ")}` ); } if (approval != null && approval !== "" && !CODEX_APPROVALS.has(approval)) { throw makeErr( "EBADAPPROVAL", `approval must be one of: ${Array.from(CODEX_APPROVALS).join(", ")}` ); } } if (permissionMode != null && !ALLOWED_PERMISSION_MODES.has(permissionMode)) { throw makeErr( "EBADMODE", `permissionMode must be one of: ${Array.from(ALLOWED_PERMISSION_MODES).join(", ")}` ); } if (effort != null && effort !== "" && !EFFORT_LEVELS.has(effort)) { throw makeErr("EBADEFFORT", `effort must be one of: ${Array.from(EFFORT_LEVELS).join(", ")}`); } if ( resumeSessionId != null && (typeof resumeSessionId !== "string" || !/^[A-Za-z0-9-]{8,}$/.test(resumeSessionId)) ) { throw makeErr("EBADSESSION", "resumeSessionId is not a valid session id"); } // A run IS a tmux session, so an absent binary is a precondition failure, not // a spawn failure. Without this the caller gets `spawnSync tmux ENOENT` from // three directories down — a message that names no fix — and the route maps // it to 400, blaming the request for the host's missing package. Checked here // rather than in each route because both spawn paths (POST /api/run and // POST /api/lanes/:id/start) come through this function. if (!tmux.isTmuxAvailable()) { throw makeErr( "ETMUXMISSING", "tmux is not installed, and every run the dashboard starts is a tmux session. " + "Install it (sudo apt install tmux / brew install tmux; on Windows run AgentHub under WSL2), " + "then restart the dashboard. Everything else keeps working without it." ); } const id = runIdForLane(laneId, slot); const startedAt = Date.now(); const argv = buildArgv({ tool, model, permissionMode, effort, sandbox, approval, resumeSessionId, resumeLast, initialPrompt, routerProfile, }); const record = () => { if (!dashboardRuns) return; dashboardRuns.recordRun({ id, sessionId: resumeSessionId || null, // `dashboard_runs.mode` is NOT NULL. Passing null made every insert throw // inside recordRun's best-effort catch, so no tmux-backed run was ever // persisted: publicRun then read its cwd/model/prompt/startedAt off a row // that did not exist (a blank row dated Jan 1 1970 in the runs modal), and // session_id never landed either, so a resumed session stayed listed as a // separate `external` row. Every run here is one interactive pane. mode: "interactive", cwd, tool, model: model || null, permissionMode: permissionMode || "auto", effort: effort || null, resumeSessionId: resumeSessionId || null, prompt: initialPrompt || "", status: "running", startedAt, endedAt: null, exitCode: null, laneId, }); }; // Every pane the dashboard starts carries its lane id. A `agenthub stage` call // from inside it then resolves the lane exactly, instead of matching on cwd — // which is ambiguous now that one directory can back both a `claude` lane and // a `codex` lane (see bin/agenthub.js's resolveLaneArg). // A codex lane on an AI Router also needs the key its layer's `env_key` // names. A claude lane needs nothing here — its settings file already carries // the router — and a lane on the vendor's own API needs nothing either. // The graphical session's own DISPLAY/WAYLAND_DISPLAY goes in too: the // server normally runs as a systemd user service with neither, so a pane it // starts has no clipboard at all — pasting an image into `claude` there // answers "no image found in clipboard" while the same CLI pastes fine in a // terminal. Never overrides what this spawn already needs. const env = { AGENTHUB_LANE_ID: String(laneId), ...desktopEnv(), ...routerEnvForSpawn({ tool, router_profile: routerProfile || null }), }; if (!tmux.hasSession(id)) { tmux.newSession({ name: id, cwd, argv, env }); record(); } else if (SHELL_COMMANDS.has(tmux.paneCommand(id) || "")) { // The session exists but its pane is sitting at a bare shell prompt — a // `agenthub lanes shell`, or a `claude` that already exited. Adopting it // silently here would swallow the whole request: a Resume would spawn no // `--resume` and an initial prompt would never be typed, while the API // still answered 200. Run the argv in the pane the user already sees // instead of erroring or opening a second session. tmux.sendCommand(id, argv, env, cwd); record(); } // Pane is running something (a live `claude`, an editor, a build): adopt // silently, same convention as this repo's server port-adoption logic — no // error, no duplicate session. Attaching shows the user what is running. // // Adopting is only honest when the request asked for nothing more than a // pane. A Resume or an initial prompt cannot be delivered into a pane that is // already running an agent (typing over it is what the shell-pane branch // above exists to avoid), so answering 200 there reports work that never // happened: the user sees "resumed" while no `--resume` was ever spawned. else if (resumeSessionId || initialPrompt) { throw makeErr( "ERUNLIVE", `lane ${laneId}'s pane is already running \`${tmux.paneCommand(id) || "a process"}\` — stop it before resuming a session or sending a prompt` ); } return getRun(id); } // Only a dashboard lane pane is a run. Without this, `DELETE /api/run/main` // would kill the user's own tmux session named `main`. function killRun(id) { if (laneIdFromRunId(id) === null || !tmux.hasSession(id)) return false; tmux.killSession(id); if (dashboardRuns) { dashboardRuns.patchRun({ id, status: "killed", endedAt: Date.now() }); } return true; } function publicRun(id) { const laneId = laneIdFromRunId(id); const live = tmux.hasSession(id); const row = dashboardRuns ? dashboardRuns.getRun(id) : null; return { id, laneId, slot: slotFromRunId(id), status: live ? "running" : "gone", tool: row?.tool || "claude", cwd: row?.cwd || null, model: row?.model || null, permissionMode: row?.permission_mode || null, effort: row?.effort || null, resumeSessionId: row?.resume_session_id || null, sessionId: row?.session_id || null, startedAt: row?.started_at || null, promptPreview: row?.prompt_preview || null, }; } function getRun(id) { if (laneIdFromRunId(id) === null) return null; return publicRun(id); } /** Computed fresh from tmux state every call — see file header. */ function listRuns() { return tmux.listSessions("agenthub-lane-").map(publicRun); } module.exports = { spawnRun, killRun, getRun, listRuns, laneIdFromRunId, runIdForLane, MAX_PANE_SLOT, SHELL_COMMANDS, };