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:
+40
-21
@@ -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);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
* `tmux attach -t ccam-lane-<id>` (or `ccam lanes shell`). Never builds a
|
||||
* shell string — every call is `execFileSync("tmux", [...argv])` with an
|
||||
* 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>
|
||||
*/
|
||||
|
||||
@@ -39,6 +42,36 @@ function newSession({ name, 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. */
|
||||
function killSession(name) {
|
||||
try {
|
||||
@@ -74,6 +107,8 @@ function isTmuxAvailable() {
|
||||
module.exports = {
|
||||
hasSession,
|
||||
newSession,
|
||||
paneCommand,
|
||||
sendCommand,
|
||||
killSession,
|
||||
listSessions,
|
||||
isTmuxAvailable,
|
||||
|
||||
Reference in New Issue
Block a user