Files
AgentHub/server/lib/pty-run.js
T
nntrivi2001 fd7f57929c fix(lanes): auto-detect and report stage for codex lanes (issue 11)
A lane on the 16-node `ship-feature` template showed an untouched pipeline
map and never declared a stage. Four independent causes, fixed at their
shared choke points:

1. Codex emits no hooks, so `touchLaneFromHook` — the only caller of
   `stage-detect.detect()`, and correctly pinned to `claude` — could never
   see a `codex` lane. New `server/lib/codex-ingest.js` tails Codex's own
   rollout journals (`$CODEX_HOME/sessions/**/rollout-*.jsonl`), maps its
   tool calls onto the hook-shaped `{tool_name, tool_input}` the matcher
   already understands, and feeds the existing detect path plus an
   idle/running status mirror under the same guard hooks use. Polled every
   `DASHBOARD_CODEX_POLL_MS` ms (default 15000, `0` disables).
2. `resolveLaneArg` matched on cwd alone, so with a claude and a codex lane
   at one directory (the composite `idx_lanes_cwd_tool`) a `ccam stage`
   call could drive the sibling lane's card. It now resolves by
   `--lane` → `CCAM_LANE_ID` → `--tool`/harness marker → owning lane, and
   errors naming both candidates rather than picking one. Every pane the
   dashboard starts carries `CCAM_LANE_ID` (`tmux new-session -e`, or an
   assignment prefix when typing into a live shell pane).
3. `recordDetection`'s declared-wins veto had no exit: a lane left at the
   pipeline's last node rejected every detection for the rest of its life.
   A declaration on the LAST node no longer vetoes — the pipeline is over
   there, so a later detection is new work. Nothing else is relaxed, and
   detection still never writes `stage` nor renders `done`.
4. The run console pre-filled the pipeline's Claude slash command into
   codex panes, which Codex reads as prose — so the skill that would have
   called `ccam stage` never ran. Claude lanes only now.

A codex lane's *declared* stage still depends on the session calling
`ccam stage`; `AGENTS.md` is the only lever CCAM has, so it now says so.

Verified: server 1345/1345, client 441/441, and end-to-end against a real
codex lane row (detected_stage/detected_signal set, declared stage
untouched, no node rendered done).
2026-08-27 16:38:23 +07:00

292 lines
9.8 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 `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.
*
* 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 = /^ccam-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 `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 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 `ccam 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/ccam.js's resolveLaneArg).
const env = { CCAM_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
// `ccam 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("ccam-lane-").map(publicRun);
}
module.exports = {
spawnRun,
killRun,
getRun,
listRuns,
laneIdFromRunId,
runIdForLane,
};