/** * @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. * * Every session is named `ccam-lane-` so a real terminal can attach * to the exact same session (`tmux attach -t ccam-lane-`, or * `ccam 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. * * @author Nguyễn Ngọc Trí Vĩ */ const tmux = require("./tmux"); let dashboardRuns = null; try { dashboardRuns = require("./dashboard-runs"); } catch { /* db-less environment, skip persistence */ } const RUN_ID_RE = /^ccam-lane-(\d+)$/; const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]); const ALLOWED_PERMISSION_MODES = new Set(["acceptEdits", "default", "plan", "bypassPermissions"]); function runIdForLane(laneId) { return `ccam-lane-${laneId}`; } 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. 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 buildArgv({ model, permissionMode, effort, resumeSessionId, initialPrompt }) { const argv = ["claude"]; argv.push("--permission-mode", permissionMode || "acceptEdits"); 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; } /** * @param {object} args * @param {number} args.laneId * @param {string} args.cwd * @param {string} [args.model] * @param {string} [args.permissionMode] * @param {string} [args.effort] * @param {string} [args.resumeSessionId] * @param {string} [args.initialPrompt] */ function spawnRun(args) { const { laneId, cwd, model, permissionMode, effort, resumeSessionId, initialPrompt } = args || {}; if (typeof laneId !== "number" || !Number.isInteger(laneId)) { throw makeErr("EBADLANE", "laneId must be an integer"); } if (typeof cwd !== "string" || !cwd) { throw makeErr("EBADCWD", "cwd is required"); } 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"); } const id = runIdForLane(laneId); const startedAt = Date.now(); if (!tmux.hasSession(id)) { const argv = buildArgv({ model, permissionMode, effort, resumeSessionId, initialPrompt }); tmux.newSession({ name: id, cwd, argv }); if (dashboardRuns) { dashboardRuns.recordRun({ id, sessionId: resumeSessionId || null, mode: null, cwd, model: model || null, permissionMode: permissionMode || "acceptEdits", effort: effort || null, resumeSessionId: resumeSessionId || null, prompt: initialPrompt || "", status: "running", startedAt, endedAt: null, exitCode: null, laneId, }); } } // Already running: adopt silently, same convention as this repo's server // port-adoption logic — no error, no duplicate session. return getRun(id); } function killRun(id) { if (!id || !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, status: live ? "running" : "gone", 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 (!id) return null; return publicRun(id); } /** Computed fresh from tmux state every call — see file header. */ function listRuns() { return tmux.listSessions("ccam-lane-").map(publicRun); } module.exports = { spawnRun, killRun, getRun, listRuns, laneIdFromRunId, runIdForLane, };