Files
AgentHub/server/lib/pty-run.js
T
nntrivi2001 17d56d0e01 refactor!: rebrand CCAM to AgentHub
Clean break, no compatibility alias. Renames the product name, the CLI
binary (`ccam` -> `agenthub`), the env var prefix (`CCAM_` ->
`AGENTHUB_`), the Prometheus metric namespace (`ccam_*` ->
`agenthub_*`), the tmux session prefix (`ccam-lane-<id>` ->
`agenthub-lane-<id>`), the per-repo runtime profile directory
(`.ccam/profile` -> `.agenthub/profile`), the lanes state root
(`~/.claude/ccam-lanes` -> `~/.claude/agenthub-lanes`), the bundled
plugins (`plugins/ccam*` -> `plugins/agenthub*`) and their slash
commands (`/ccam-doctor` -> `/agenthub-doctor`, `/ccam:*` ->
`/agenthub:*`).

Because there is no alias, an existing install has to be updated by
hand: rename its `.ccam/profile` directory and any `CCAM_*` variables,
and reinstall the plugin under its new name.

Two other name families are deliberately left alone here, since they are
not just strings: the `Claude-Code-Monitor` repository/marketplace name,
and the `agent-dashboard` npm package plus its `~/.claude/agent-dashboard`
runtime directory, which holds the live database and needs a migration
rather than a rename.

Verified: server 1406/1406, client 459/459, `grep -i ccam` clean across
tracked files, `openapi.yaml` regenerated (95 paths, unchanged count).
2026-09-07 12:09:15 +07:00

293 lines
9.9 KiB
JavaScript

/**
* @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-<laneId>` so a real terminal can attach
* to the exact same session (`tmux attach -t agenthub-lane-<id>`, 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` or `codex` in the tmux pane based on the tool parameter.
*
* @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 = /^agenthub-lane-(\d+)$/;
const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]);
const ALLOWED_PERMISSION_MODES = new Set(["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"]);
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) {
return `agenthub-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 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 || "acceptEdits");
// 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,
}) {
const argv = ["codex"];
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;
}
function buildArgv(args) {
return args.tool === "codex" ? buildCodexArgv(args) : 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]
*/
function spawnRun(args) {
const {
laneId,
cwd,
tool = "claude",
model,
permissionMode,
effort,
sandbox,
approval,
resumeSessionId,
resumeLast,
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 (!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");
}
const id = runIdForLane(laneId);
const startedAt = Date.now();
const argv = buildArgv({
tool,
model,
permissionMode,
effort,
sandbox,
approval,
resumeSessionId,
resumeLast,
initialPrompt,
});
const record = () => {
if (!dashboardRuns) return;
dashboardRuns.recordRun({
id,
sessionId: resumeSessionId || null,
mode: null,
cwd,
tool: tool === "codex" ? "codex" : "claude",
model: model || null,
permissionMode: permissionMode || "acceptEdits",
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).
const env = { AGENTHUB_LANE_ID: String(laneId) };
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);
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.
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",
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 (!id) 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,
SHELL_COMMANDS,
};