fd7f57929c
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).
292 lines
9.8 KiB
JavaScript
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,
|
|
};
|