diff --git a/docs/API.md b/docs/API.md index d27cb70..717f09c 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1485,7 +1485,7 @@ GET /api/run/:id Run handle (returns live run state) 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-` 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-` 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. diff --git a/docs/LANES.md b/docs/LANES.md index 35bc60d..bc9cb42 100644 --- a/docs/LANES.md +++ b/docs/LANES.md @@ -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 ` 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-` 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`. ### Finding or adopting a lane: `POST /api/lanes/ensure` diff --git a/server/__tests__/pty-run.test.js b/server/__tests__/pty-run.test.js index 4bbfe9f..a9095c5 100644 --- a/server/__tests__/pty-run.test.js +++ b/server/__tests__/pty-run.test.js @@ -58,15 +58,46 @@ describe("pty-run", () => { 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 = []; tmux.__setExecImpl((args) => { calls.push(args); + if (args[0] === "display-message") return "claude\n"; return ""; // has-session succeeds → already running }); const handle = pty.spawnRun({ laneId: 7, cwd: "/tmp/repo" }); 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] === "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", () => { diff --git a/server/lib/pty-run.js b/server/lib/pty-run.js index 3cc3f19..627ab3c 100644 --- a/server/lib/pty-run.js +++ b/server/lib/pty-run.js @@ -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-` so a real terminal can attach * to the exact same session (`tmux attach -t ccam-lane-`, 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); } diff --git a/server/lib/tmux.js b/server/lib/tmux.js index 26e91fb..9f1336c 100644 --- a/server/lib/tmux.js +++ b/server/lib/tmux.js @@ -6,6 +6,9 @@ * `tmux attach -t ccam-lane-` (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ĩ */ @@ -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,