feat(run): add tmux-backed run lifecycle (spawn/kill/list computed from tmux state)
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* @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-<laneId>` so a real terminal can attach
|
||||
* to the exact same session (`tmux attach -t ccam-lane-<id>`, 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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user