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
+1 -1
View File
@@ -1485,7 +1485,7 @@ GET /api/run/:id Run handle (returns live run state)
DELETE /api/run/:id Kill (SIGTERM → SIGKILL after 5 s) DELETE /api/run/:id Kill (SIGTERM → SIGKILL after 5 s)
``` ```
**`POST /api/run` (start/attach):** Requires `laneId` (the lane this run belongs to). Creates or attaches an existing tmux session named `ccam-lane-<id>` in the lane's working directory. Optionally accepts `initialPrompt` to immediately type/send into the session (if empty or omitted, the session is created/attached with no initial input). Returns `{ id, laneId, status, cwd, model, permissionMode, effort, resumeSessionId, sessionId, startedAt, promptPreview }` where `id` is the tmux session name. The dashboard self-heals a lane's `run_id`/`status` on every read if the tmux session has been killed externally. **`POST /api/run` (start/attach):** Requires `laneId` (the lane this run belongs to). Creates or attaches an existing tmux session named `ccam-lane-<id>` in the lane's working directory. If that session already exists and its pane is idling at a shell prompt, the `claude` command line (including `--resume` and any initial prompt) is typed into that pane instead of being dropped; if the pane is running a program, the request adopts the session unchanged. Optionally accepts `initialPrompt` to immediately type/send into the session (if empty or omitted, the session is created/attached with no initial input). Returns `{ id, laneId, status, cwd, model, permissionMode, effort, resumeSessionId, sessionId, startedAt, promptPreview }` where `id` is the tmux session name. The dashboard self-heals a lane's `run_id`/`status` on every read if the tmux session has been killed externally.
**PTY streaming:** Frames from the tmux pane are streamed to the client over `/ws-pty/:runId` as binary WebSocket frames (not JSON). The Workspace page's TerminalView component feeds these frames to xterm.js for live rendering. Simultaneously, `ccam lanes shell` can attach the same session via a real local terminal, staying in sync with the browser view. **PTY streaming:** Frames from the tmux pane are streamed to the client over `/ws-pty/:runId` as binary WebSocket frames (not JSON). The Workspace page's TerminalView component feeds these frames to xterm.js for live rendering. Simultaneously, `ccam lanes shell` can attach the same session via a real local terminal, staying in sync with the browser view.
+2
View File
@@ -327,6 +327,8 @@ An external session is deduped against a dashboard run with the same `session_id
External rows have **no Attach action**: the dashboard owns no tmux session for them, so there is no PTY to bridge. Their action is **Resume**, which does what resuming from history does — `POST /api/lanes/ensure` for the session's `cwd`, then `POST /api/lanes/:id/start` with `resumeSessionId` — spawning a *new* tmux-backed `claude --resume <session>` in that folder. The original terminal keeps running; resuming gives you a second Claude Code process on the same transcript, not a view of the first one. External rows have **no Attach action**: the dashboard owns no tmux session for them, so there is no PTY to bridge. Their action is **Resume**, which does what resuming from history does — `POST /api/lanes/ensure` for the session's `cwd`, then `POST /api/lanes/:id/start` with `resumeSessionId` — spawning a *new* tmux-backed `claude --resume <session>` in that folder. The original terminal keeps running; resuming gives you a second Claude Code process on the same transcript, not a view of the first one.
**Start and Resume are create-or-reuse, and never silently swallow the request.** When the lane's `ccam-lane-<id>` tmux session does not exist, it is created with the full argv. When it exists but its pane is sitting at a **shell prompt** (a `ccam lanes shell` you opened, or a `claude` that has since exited), the argv is typed into that pane — so `--resume` really runs, and an initial prompt really lands, in the session you are already looking at. Only when the pane is running something (a live `claude`, an editor, a build) is the request adopted as-is: attaching shows you what is running rather than typing over it. Before this, an existing session was always adopted silently, so the first Resume after a `ccam lanes shell` answered `200` while doing nothing at all.
The UI operates on a working directory (`cwd`), not a lane id. Starting a run in a `cwd` that no lane owns calls `POST /api/lanes/ensure` first, to idempotently find or adopt a lane for that path; a `cwd` an existing lane already owns is matched from the loaded lane list without a round trip. Either way the run is then started through `POST /api/lanes/:id/start` rather than directly through `POST /api/run`. The UI operates on a working directory (`cwd`), not a lane id. Starting a run in a `cwd` that no lane owns calls `POST /api/lanes/ensure` first, to idempotently find or adopt a lane for that path; a `cwd` an existing lane already owns is matched from the loaded lane list without a round trip. Either way the run is then started through `POST /api/lanes/:id/start` rather than directly through `POST /api/run`.
### Finding or adopting a lane: `POST /api/lanes/ensure` ### Finding or adopting a lane: `POST /api/lanes/ensure`
+32 -1
View File
@@ -58,15 +58,46 @@ describe("pty-run", () => {
assert.ok(newSessionCall.includes("opus")); assert.ok(newSessionCall.includes("opus"));
}); });
it("spawnRun is a no-op (adopts) when the tmux session already exists", () => { it("spawnRun is a no-op (adopts) when the existing session's pane runs claude", () => {
const calls = []; const calls = [];
tmux.__setExecImpl((args) => { tmux.__setExecImpl((args) => {
calls.push(args); calls.push(args);
if (args[0] === "display-message") return "claude\n";
return ""; // has-session succeeds → already running return ""; // has-session succeeds → already running
}); });
const handle = pty.spawnRun({ laneId: 7, cwd: "/tmp/repo" }); const handle = pty.spawnRun({ laneId: 7, cwd: "/tmp/repo" });
assert.equal(handle.id, "ccam-lane-7"); assert.equal(handle.id, "ccam-lane-7");
assert.ok(!calls.some((c) => c[0] === "new-session"), "must not create a duplicate session"); assert.ok(!calls.some((c) => c[0] === "new-session"), "must not create a duplicate session");
assert.ok(!calls.some((c) => c[0] === "send-keys"), "must not type over a live agent");
});
it("spawnRun types the argv into an existing session idling at a shell prompt", () => {
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
if (args[0] === "display-message") return "bash\n";
return ""; // has-session succeeds → session exists, pane is a shell
});
pty.spawnRun({ laneId: 8, cwd: "/tmp/repo", resumeSessionId: "abc12345" });
assert.ok(!calls.some((c) => c[0] === "new-session"), "must not create a duplicate session");
const literal = calls.find((c) => c[0] === "send-keys" && c[3] === "-l");
assert.ok(literal, "expected a literal send-keys with the command line");
assert.match(literal[4], /^'claude' .*'--resume' 'abc12345'$/);
assert.ok(
calls.some((c) => c[0] === "send-keys" && c[3] === "Enter"),
"expected the command to be submitted"
);
});
it("spawnRun single-quotes an initial prompt typed into an existing shell pane", () => {
let literal = null;
tmux.__setExecImpl((args) => {
if (args[0] === "display-message") return "zsh\n";
if (args[0] === "send-keys" && args[3] === "-l") literal = args[4];
return "";
});
pty.spawnRun({ laneId: 9, cwd: "/tmp/repo", initialPrompt: "don't; rm -rf /" });
assert.ok(literal.endsWith(`'don'\\''t; rm -rf /'`), literal);
}); });
it("spawnRun with resumeSessionId passes --resume in argv", () => { it("spawnRun with resumeSessionId passes --resume in argv", () => {
+40 -21
View File
@@ -9,6 +9,10 @@
* out-of-band (crash, manual `tmux kill-session`, host reboot) self-corrects * out-of-band (crash, manual `tmux kill-session`, host reboot) self-corrects
* on the next read instead of leaving a ghost "running" row. * 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 * 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 * to the exact same session (`tmux attach -t ccam-lane-<id>`, or
* `ccam lanes shell`) — that's the whole point: the dashboard both creates * `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 RUN_ID_RE = /^ccam-lane-(\d+)$/;
const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]); const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]);
const ALLOWED_PERMISSION_MODES = new Set(["acceptEdits", "default", "plan", "bypassPermissions"]); 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) { function runIdForLane(laneId) {
return `ccam-lane-${laneId}`; return `ccam-lane-${laneId}`;
@@ -99,31 +105,44 @@ function spawnRun(args) {
const id = runIdForLane(laneId); const id = runIdForLane(laneId);
const startedAt = Date.now(); 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)) { if (!tmux.hasSession(id)) {
const argv = buildArgv({ model, permissionMode, effort, resumeSessionId, initialPrompt });
tmux.newSession({ name: id, cwd, argv }); tmux.newSession({ name: id, cwd, argv });
if (dashboardRuns) { record();
dashboardRuns.recordRun({ } else if (SHELL_COMMANDS.has(tmux.paneCommand(id) || "")) {
id, // The session exists but its pane is sitting at a bare shell prompt — a
sessionId: resumeSessionId || null, // `ccam lanes shell`, or a `claude` that already exited. Adopting it
mode: null, // silently here would swallow the whole request: a Resume would spawn no
cwd, // `--resume` and an initial prompt would never be typed, while the API
model: model || null, // still answered 200. Run the argv in the pane the user already sees
permissionMode: permissionMode || "acceptEdits", // instead of erroring or opening a second session.
effort: effort || null, tmux.sendCommand(id, argv);
resumeSessionId: resumeSessionId || null, record();
prompt: initialPrompt || "",
status: "running",
startedAt,
endedAt: null,
exitCode: null,
laneId,
});
}
} }
// Already running: adopt silently, same convention as this repo's server // Pane is running something (a live `claude`, an editor, a build): adopt
// port-adoption logic — no error, no duplicate session. // 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); return getRun(id);
} }
+35
View File
@@ -6,6 +6,9 @@
* `tmux attach -t ccam-lane-<id>` (or `ccam lanes shell`). Never builds a * `tmux attach -t ccam-lane-<id>` (or `ccam lanes shell`). Never builds a
* shell string — every call is `execFileSync("tmux", [...argv])` with an * shell string — every call is `execFileSync("tmux", [...argv])` with an
* explicit argument array (matches this repo's rule for git in worktree.js). * explicit argument array (matches this repo's rule for git in worktree.js).
* The one place a command line is composed is `sendCommand`, which types into
* an existing pane's shell: there the shell IS the consumer, so every argument
* is POSIX single-quoted first and sent with `send-keys -l` (literal).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -39,6 +42,36 @@ function newSession({ name, cwd, argv }) {
execImpl(["new-session", "-d", "-s", name, "-c", cwd, "--", ...argv]); execImpl(["new-session", "-d", "-s", name, "-c", cwd, "--", ...argv]);
} }
/**
* The command currently running in the session's active pane (`bash`, `zsh`,
* `claude`, …). Null when tmux can't answer — callers treat that as "unknown,
* don't touch the pane".
*/
function paneCommand(name) {
try {
return (
execImpl(["display-message", "-p", "-t", name, "#{pane_current_command}"]).trim() || null
);
} catch {
return null;
}
}
/** POSIX single-quote escaping — the pane is a shell, so argv must be quoted. */
function shellQuote(arg) {
return `'${String(arg).replace(/'/g, `'\\''`)}'`;
}
/**
* Type `argv` into an existing session's pane and press Enter. Only ever
* called when the pane sits at a shell prompt (see `paneCommand`); `-l` sends
* the string literally so no character is read as a tmux key name.
*/
function sendCommand(name, argv) {
execImpl(["send-keys", "-t", name, "-l", argv.map(shellQuote).join(" ")]);
execImpl(["send-keys", "-t", name, "Enter"]);
}
/** Idempotent — a session that's already gone is not an error. */ /** Idempotent — a session that's already gone is not an error. */
function killSession(name) { function killSession(name) {
try { try {
@@ -74,6 +107,8 @@ function isTmuxAvailable() {
module.exports = { module.exports = {
hasSession, hasSession,
newSession, newSession,
paneCommand,
sendCommand,
killSession, killSession,
listSessions, listSessions,
isTmuxAvailable, isTmuxAvailable,