/** * @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 ten sessions (`MAX_PANE_SLOT`): slot 0 is * `agenthub-lane-` (the lane's own `run_id`), slots 1–9 are * `agenthub-lane--`. A slot holds an AI tool (`spawnRun`) or a * plain shell (`spawnShell`, a Terminal tab). * * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const path = require("node:path"); const tmux = require("./tmux"); const { resolveProxy, proxyForSpawn } = require("./proxy-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-9]))?$/; /** Highest pane slot a lane may use: ten sessions per workspace, 0–9 (the * Workspace groups them into tabs of up to four split panes each). */ const MAX_PANE_SLOT = 9; 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"]); /** * `sh` that waits until a client is attached to the pane — 2 s at most * ({@link CLIENT_WAIT_MS}) — before what follows it runs. A session is born * 80×24 and takes the browser's size only on attach (and `enableMouse`'s * scrollbar narrows it before that). Anything drawn before then is wrong: * a shell's prompt (issue #55), and an inline `claude`'s banner — tmux * re-wraps the 80-column rules into two lines each on the resize, pushing the * banner's first lines into history, and claude's redraw clears only the * visible screen, so they stay above it as a duplicate. Not "the first * SIGWINCH": the scrollbar fires one before any client is there. A tmux that * cannot answer ends the wait at once, i.e. the old behaviour. */ const WAIT_FOR_CLIENT = 'i=0; while [ "$(tmux display -p -t "$TMUX_PANE" \'#{session_attached}\' 2>/dev/null)" = 0 ]' + " && [ $i -lt 20 ]; do sleep 0.1; i=$((i+1)); done;"; const CLIENT_WAIT_MS = 2000; /** * A pane's tmux command: `argv` behind {@link WAIT_FOR_CLIENT}, or — with no * `argv` — the login shell behind it. Windows has no `sh` (a WinGet tmux runs * native programs), and an `sh -c` there died at once and took the session * with it, so it gets the command itself, or nothing for tmux's own default * shell. * ponytail: no wait for the attach on Windows, so a pane may first draw at * 80×24 (issue #55); port the wait to PowerShell if that shows. */ function paneArgv(argv, platform = process.platform) { if (platform === "win32") return argv || []; // argv rides as sh's positional parameters, never inside the script text. return argv ? ["sh", "-c", `${WAIT_FOR_CLIENT} exec "$@"`, "sh", ...argv] : ["sh", "-c", `${WAIT_FOR_CLIENT} exec "$SHELL" -l`]; } 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; } // A prompt past INLINE_PROMPT_BYTES reaches the pane in a 0600 file, not in // tmux's argv: one tmux message holds 16 KB with the env and the rest of the // argv beside it (`command too long`, issue #68). The pane's own sh reads it // back as the CLI's last argument and deletes the file. No CLI can take one // argument past Linux's MAX_ARG_STRLEN (128 KB, E2BIG), so that is refused. const INLINE_PROMPT_BYTES = 4 * 1024; const MAX_PROMPT_BYTES = 128 * 1024 - 2; // the NUL, and promptArg's space const READ_PROMPT = 'p=$(cat "$1") && rm -f "$1" && shift && exec "$@" "$p"'; function promptFileArgv(id, argv) { const dir = path.join(require("./claude-home").getDataDir(), "run-prompts"); fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); const file = path.join(dir, `${id}.txt`); fs.writeFileSync(file, argv[argv.length - 1], { mode: 0o600 }); return ["sh", "-c", READ_PROMPT, "sh", file, ...argv.slice(0, -1)]; } function buildClaudeArgv({ model, permissionMode, effort, resumeSessionId, forkSession = false, newSessionId = null, worktreeName = null, initialPrompt, addDirs = [], systemPromptFile = null, proxyArgv = [], }) { 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"); // The session's AI Proxy: `--settings=` (proxy-apply.js). argv.push(...proxyArgv); // 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); else if (newSessionId) argv.push("--session-id", newSessionId); // 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, proxyArgv = [], 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(...proxyArgv); 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.proxy] AI Proxy profile THIS session runs on — a * name only, its key and endpoint come from the store; null/"native" = the * vendor's own API. Claude and Codex only (`EUNSUPPORTED`). */ function spawnRun(args) { const { laneId, slot = 0, cwd, tool = "claude", model, permissionMode, effort, sandbox, approval, resumeSessionId, forkSession, worktree, inWorktree, resumeLast, bypass, initialPrompt, proxy, } = 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 (typeof initialPrompt === "string" && Buffer.byteLength(initialPrompt) > MAX_PROMPT_BYTES) { throw makeErr( "EBADPROMPT", `the prompt is ${Math.ceil(Buffer.byteLength(initialPrompt) / 1024)} KB; an AI CLI takes at most 128 KB as its first message — attach the text as a file instead` ); } // 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"); } // Only the hand-written launchers can resume a NAMED session; a catalog // tool would silently start fresh under a "resume". if (resumeSessionId && !entry.launcher) { throw makeErr("EUNSUPPORTED", `${tool} cannot resume a session by id`); } if (worktree === true && entry.launcher !== "claude") { throw makeErr("EUNSUPPORTED", `${tool} cannot start a session in its own worktree`); } const proxyProfile = resolveProxy(tool, proxy); // ENOTFOUND, EUNSUPPORTED // A Claude session on the vendor's own API runs on the least-loaded pool // account, if the admin added any (claude-pool.js); an AI Proxy brings its // own key, and an empty pool leaves the machine's login in charge. const poolAccount = entry.launcher === "claude" && !proxyProfile ? require("./claude-pool").pickAccount() : null; const paneCred = require("./auth").newPaneToken(); 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 proxySpawn = proxyForSpawn(tool, proxyProfile); // A new Claude session gets its id from us, so the run knows the session it // holds from the start — a hook that lost AGENTHUB_RUN_ID (an older hook // handler) still finds its pane by it (routes/hooks.js → bindRunSession). const newSessionId = entry.launcher === "claude" && !resumeSessionId ? require("crypto").randomUUID() : null; const argv = buildArgv( { addDirs, systemPrompt, systemPromptFile, tool, model, permissionMode, effort, sandbox, approval, resumeSessionId, forkSession: forkSession === true, newSessionId, worktreeName: worktree === true ? `ah-${laneId}-${Date.now().toString(36)}` : null, resumeLast, bypass: bypass === true, initialPrompt, proxyArgv: proxySpawn.argv, }, entry ); const pasteLater = entry.prompt?.mode === "after-start" && initialPrompt ? initialPrompt : null; // Every launcher puts the prompt LAST; Windows has no sh to read it back. const paneCmd = initialPrompt && !pasteLater && process.platform !== "win32" && Buffer.byteLength(argv[argv.length - 1]) > INLINE_PROMPT_BYTES ? promptFileArgv(id, argv) : argv; const record = () => { if (!dashboardRuns) return; dashboardRuns.recordRun({ id, // A fork's own id arrives with its first hook (bindRunSession). sessionId: newSessionId || (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, proxy: proxyProfile ? proxyProfile.name : null, account: poolAccount ? poolAccount.name : null, paneTokenHash: paneCred ? paneCred.hash : 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 session on an AI Proxy also needs the key its layer's `env_key` // names. A claude one needs nothing here — its `--settings` file carries the // proxy — and a session 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 proxy 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() } : {}), // No CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: an inline claude duplicated its // output whenever the pane narrowed (tmux re-wraps the full-width rules and // pushes lines into history its redraw cannot clear). The user's own // `"tui"` setting decides; under fullscreen the pane scrolls in Claude. ...proxySpawn.env, // The pool account's setup-token: outranks the machine's `/login` for // this process only. Through tmux `-e`, never typed into the pane. ...(poolAccount ? { CLAUDE_CODE_OAUTH_TOKEN: poolAccount.token } : {}), // Server mode: the CLI, MCP server and statusline in this pane act as the // workspace's owner with it (server/lib/auth.js userFromPaneToken). ...(paneCred ? { AGENTHUB_PANE_TOKEN: paneCred.token } : {}), ...(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 + CLIENT_WAIT_MS); // the CLI may start up to that late if (timer.unref) timer.unref(); }; if (!tmux.hasSession(id)) { tmux.newSession({ name: id, cwd, argv: paneArgv(paneCmd), 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, paneCmd, 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); } /** * A Workspace "Terminal" tab: the user's own default shell (tmux's, run with * nothing after `--`) in a lane's pane slot. No AI tool, no prompt, nothing * from the request but the slot — the cwd is the lane's. Recorded as tool * `shell` so the tab can say what it is. The caller has already made sure the * slot holds no live session. */ function spawnShell({ laneId, slot = 0, cwd }) { if (!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 (!tmux.isTmuxAvailable()) throw makeErr("ETMUXMISSING", "tmux is not installed"); const id = runIdForLane(laneId, slot); const paneCred = require("./auth").newPaneToken(); const env = { AGENTHUB_LANE_ID: String(laneId), AGENTHUB_RUN_ID: id, ...(process.env.PATH ? { PATH: process.env.PATH } : {}), ...desktopEnv(), ...(paneCred ? { AGENTHUB_PANE_TOKEN: paneCred.token } : {}), }; // Started once the pane has its real size (WAIT_FOR_CLIENT, issue #55), as // the login shell tmux would have run (tmux sets SHELL in the pane). tmux.newSession({ name: id, cwd, argv: paneArgv(null), env }); dashboardRuns?.recordRun({ id, sessionId: null, mode: "interactive", cwd, tool: "shell", model: null, permissionMode: null, effort: null, resumeSessionId: null, worktree: false, proxy: null, paneTokenHash: paneCred ? paneCred.hash : null, prompt: "", status: "running", startedAt: Date.now(), endedAt: null, exitCode: null, laneId, }); 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, live = tmux.hasSession(id)) { const laneId = laneIdFromRunId(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. A listed * session is live by definition, so no has-session per row. */ function listRuns() { return tmux.listSessions("agenthub-lane-").map((id) => publicRun(id, true)); } module.exports = { spawnRun, spawnShell, killRun, getRun, listRuns, laneIdFromRunId, runIdForLane, MAX_PANE_SLOT, SHELL_COMMANDS, paneArgv, };