fix(run): stop Start/Resume from silently no-oping on an idle lane session

spawnRun adopted any existing `ccam-lane-<id>` tmux session without looking
at it, so a Resume issued while the session sat at a bare shell prompt (left
by `ccam lanes shell`, or by a `claude` that had already exited) dropped the
whole argv: no `--resume` ran, no initial prompt was typed, and the API still
answered 200. The lane's DB `run_id` is not set in that case, so the ERUNLIVE
guard in the start route never saw it either.

Reuse the pane instead of erroring: when the session exists and
`#{pane_current_command}` is a shell, type the argv into that pane and record
the run. A pane running a program (a live `claude`, an editor, a build) is
still adopted untouched, so attaching shows what is running rather than typing
over it. `sendCommand` POSIX single-quotes every argument and uses
`send-keys -l`, the only place in tmux.js that composes a command line.
This commit is contained in:
2026-08-18 10:50:28 +07:00
parent 174c650624
commit c25008ab19
5 changed files with 110 additions and 23 deletions
+40 -21
View File
@@ -9,6 +9,10 @@
* 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
@@ -30,6 +34,8 @@ try {
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"]);
function runIdForLane(laneId) {
return `ccam-lane-${laneId}`;
@@ -99,31 +105,44 @@ function spawnRun(args) {
const id = runIdForLane(laneId);
const startedAt = Date.now();
const argv = buildArgv({ model, permissionMode, effort, resumeSessionId, initialPrompt });
const record = () => {
if (!dashboardRuns) return;
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,
});
};
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,
});
}
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);
record();
}
// Already running: adopt silently, same convention as this repo's server
// port-adoption logic — no error, no duplicate session.
// 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);
}