/** * @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"); const { getCodexHome } = require("./codex-home"); const aiTools = require("./ai-tools"); 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 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). */ /** * A prompt as an argv element no CLI can read as an option: one that starts * with "-" (`-c fix it`) would otherwise be parsed as flags — `claude -c` * continues the last conversation, `-p` turns the pane into print mode. A * leading space keeps it a positional and changes nothing the model reads. */ function promptArg(p) { return p.startsWith("-") ? ` ${p}` : p; } function buildClaudeArgv({ model, permissionMode, effort, resumeSessionId, forkSession = false, worktreeName = null, initialPrompt, addDirs = [], systemPromptFile = null, }) { const argv = ["claude"]; // `--add-dir` is VARIADIC (``): it swallows every following // argument up to the next option, so it goes first, where `--permission-mode` // always ends it — after the prompt it would eat the prompt as a directory. if (addDirs.length) argv.push("--add-dir", ...addDirs); argv.push("--permission-mode", permissionMode || "auto"); // Claude Code makes the worktree itself (`.claude/worktrees/`, branch // `worktree-`, `.worktreeinclude`, the user's WorktreeCreate hook). // `=` form: `-w [name]` takes an OPTIONAL value and would otherwise swallow // the prompt as the worktree's name. if (worktreeName) argv.push(`--worktree=${worktreeName}`); // From a file: the instructions can be long, and the whole argv has to fit // in one tmux message. `=` form, so the path is never read as an option. if (systemPromptFile) argv.push(`--append-system-prompt-file=${systemPromptFile}`); // 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); // A fork resumes the history into a NEW session id; the original is left as // it was. if (resumeSessionId && forkSession) argv.push("--fork-session"); if (initialPrompt) argv.push(promptArg(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, bypass, initialPrompt, routerProfile, addDirs = [], systemPrompt = null, systemPromptFile = null, }) { 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); // The bypass flag turns off both sandbox and approvals, so the two flags it // replaces are not sent beside it. if (bypass) argv.push(...(aiTools.get("codex")?.bypass || [])); else { 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}`); for (const dir of addDirs) argv.push("--add-dir", dir); // `-c` parses its value as TOML; a JSON string literal is a valid TOML basic // string, so any text — quotes, newlines — arrives as one string. if (systemPrompt) { const { codexInstructions, tomlString } = require("./lane-sources"); const text = codexInstructions(systemPrompt, systemPromptFile); if (text) argv.push("-c", `developer_instructions=${tomlString(text)}`); } if (initialPrompt) argv.push(promptArg(initialPrompt)); return argv; } /** * Any catalog tool without a hand-written launcher: `[bin, ...args, ...resume?, * ...bypass?, ...prompt]` (`[flag, model]` right after the fixed args when the * catalog declares a model flag). Every piece but the prompt and the * validated model comes from the catalog * file, and the prompt is one argv element — or, for `after-start`, nothing * here: spawnRun pastes it into the pane once the CLI is up. */ function buildCatalogArgv(entry, bin, { model, resumeLast, bypass, initialPrompt }) { const argv = [bin, ...entry.args]; if (model && entry.model) argv.push(entry.model.flag, model); if (resumeLast && entry.resume) argv.push(...entry.resume); if (bypass && entry.bypass) argv.push(...entry.bypass); const p = entry.prompt; if (initialPrompt && p.mode === "flag") argv.push(p.flag, promptArg(initialPrompt)); if (initialPrompt && p.mode === "argv") // A `--` separator already ends the options; without one, guard the prompt. argv.push(...(p.separator ? [p.separator, initialPrompt] : [promptArg(initialPrompt)])); return argv; } function buildArgv(args, entry) { if (entry.launcher === "codex") return buildCodexArgv(args); if (entry.launcher === "claude") return buildClaudeArgv(args); const d = aiTools.detect(entry.id); // By absolute path when found; the bare name otherwise, so a CLI missing // from PATH dies in the pane with the shell's own "not found". return buildCatalogArgv(entry, d.path || entry.detect[0], 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.forkSession] resume `resumeSessionId` into a new * session id (`claude --fork-session`); Claude only. * @param {boolean} [args.worktree] start a NEW Claude session in its own git * worktree (`claude --worktree=`, name made here); Claude only. * @param {boolean} [args.inWorktree] `cwd` is a session's worktree (a resume * into it, resolved by the caller from the session row) — recorded only. * @param {boolean} [args.resumeLast] * @param {boolean} [args.bypass] the user ticked "bypass permissions" for THIS * run; appends the catalog's bypass args/env. Claude has no catalog bypass. * @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, forkSession, worktree, inWorktree, resumeLast, bypass, 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"); } // A lane whose tool has since left the catalog keeps its row; it just // cannot start until the catalog knows the tool again. const entry = aiTools.get(tool); if (!entry) throw makeErr("ENOTOOL", `unknown AI tool: ${tool}`); 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(", ")}` ); } // A model is one argv element after a model flag; one starting with `-` // would be read as a flag of its own. if (model != null && model !== "" && !aiTools.validModel(model)) { throw makeErr("EBADMODEL", "model is not a valid model id"); } 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"); } if (worktree === true && entry.launcher !== "claude") { throw makeErr("EUNSUPPORTED", `${tool} cannot start a session in its own worktree`); } if (forkSession === true) { if (!resumeSessionId) throw makeErr("EBADSESSION", "a fork needs resumeSessionId"); if (entry.launcher !== "claude") { throw makeErr("EUNSUPPORTED", `${tool} cannot fork a session`); } } // 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(); // Claude Code does not exclude the worktrees it makes, so the main repo // would list `.claude/` as untracked; the root's info/exclude hides them. if (worktree === true) { require("./git-exclude").excludeFromRepoRoot(cwd, [".claude/worktrees/"]); } // The workspace's own folders, context files and instructions — read from // its rows here, the one place both spawn paths pass through, never from a // request (server/lib/lane-sources.js). const { addDirs, systemPrompt, systemPromptFile } = require("./lane-sources").launchExtras( laneId, tool ); const argv = buildArgv( { addDirs, systemPrompt, systemPromptFile, tool, model, permissionMode, effort, sandbox, approval, resumeSessionId, forkSession: forkSession === true, worktreeName: worktree === true ? `ah-${laneId}-${Date.now().toString(36)}` : null, resumeLast, bypass: bypass === true, initialPrompt, routerProfile, }, entry ); const pasteLater = entry.prompt?.mode === "after-start" && initialPrompt ? initialPrompt : null; const record = () => { if (!dashboardRuns) return; dashboardRuns.recordRun({ id, // A fork's own id arrives with its first hook (bindRunSession). sessionId: (forkSession !== true && 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, worktree: worktree === true || inWorktree === true, 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. // PATH is the dashboard's own, hydrated from the login shell: a pane's env // comes from the tmux server, and an npm-installed CLI (`#!/usr/bin/env // node`) dies there without the `node` the user's shell would have found. const env = { AGENTHUB_LANE_ID: String(laneId), // Which pane this is: the CLI's hooks inherit it, so the dashboard learns // which session each pane holds (routes/hooks.js → bindRunSession) — the // sidebar opens a session's own pane by it. AGENTHUB_RUN_ID: id, ...(process.env.PATH ? { PATH: process.env.PATH } : {}), ...desktopEnv(), // A codex pane must read the same home the dashboard reads — its rollout // journals and the router layer `-p ` loads live there — and the // tmux server's own env may predate, or never have had, CODEX_HOME. ...(tool === "codex" ? { CODEX_HOME: getCodexHome() } : {}), ...routerEnvForSpawn({ tool, router_profile: routerProfile || null }), ...(bypass === true && entry.bypassEnv ? entry.bypassEnv : {}), }; // `after-start` tools take no prompt on their command line: paste it once // the CLI has had `delayMs` to draw its input box. ponytail: fixed delay, // add readiness detection (Orca's) if a tool drops prompts. const schedulePaste = () => { if (!pasteLater) return; const timer = setTimeout(() => { try { if (tmux.hasSession(id)) tmux.pasteText(id, pasteLater); } catch { /* pane gone mid-paste — nothing to deliver to */ } }, entry.prompt.delayMs); if (timer.unref) timer.unref(); }; if (!tmux.hasSession(id)) { tmux.newSession({ name: id, cwd, argv, env }); record(); schedulePaste(); } 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(); schedulePaste(); } // 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", `workspace ${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, // The pane works in a git worktree of its own (`claude --worktree`). worktree: !!row?.worktree, 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, };