diff --git a/change-logs/2026/08/02/fix-native-auxiliary-terminal-panes.md b/change-logs/2026/08/02/fix-native-auxiliary-terminal-panes.md new file mode 100644 index 000000000..39e336389 --- /dev/null +++ b/change-logs/2026/08/02/fix-native-auxiliary-terminal-panes.md @@ -0,0 +1,3 @@ +Short: Dev server and Rebase panes work natively + +On a task using the native terminal backend, starting the dev server or running Rebase opens a real visible pane in the task's own terminal instead of failing invisibly — the dev server used to run hidden in a tmux session a native task should never touch, and the git panes errored out. Repeated clicks reuse the one pane, the agent pane keeps its focus, and closing a task now also stops a native dev server instead of leaking its processes and ports. diff --git a/src/bun/__tests__/task-aux-panes.test.ts b/src/bun/__tests__/task-aux-panes.test.ts new file mode 100644 index 000000000..98d2de94d --- /dev/null +++ b/src/bun/__tests__/task-aux-panes.test.ts @@ -0,0 +1,330 @@ +/** + * task-aux-panes tests (seq 1376). + * + * Two guarantees, one per backend: + * • native never reaches tmux, dedups its own pane, and hands focus back; + * • tmux behaves exactly as it did before the module existed. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Task } from "../../shared/types"; + +const { FakeTmuxError } = vi.hoisted(() => ({ + FakeTmuxError: class FakeTmuxError extends Error { + constructor(readonly args: string[], readonly exitCode: number, readonly stderr: string) { + super(`tmux ${args[0] ?? ""} failed (exit ${exitCode}): ${stderr || "unknown error"}`); + this.name = "TmuxError"; + } + }, +})); + +const mocks = vi.hoisted(() => ({ + // tmux singleton — every method is a spy so "no tmux happened" is provable. + tmuxListPanes: vi.fn(), + tmuxSplitWindow: vi.fn(), + tmuxSelectPane: vi.fn(), + tmuxKillPane: vi.fn(), + // native-task-panes + nativeTaskPanesState: vi.fn(), + nativeTaskPaneCommands: vi.fn(), + splitNativeTaskPane: vi.fn(), + closeNativeTaskPane: vi.fn(), + focusNativeTaskPane: vi.fn(), +})); + +/** Every tmux method the module could possibly reach. */ +const TMUX_METHODS = [mocks.tmuxListPanes, mocks.tmuxSplitWindow, mocks.tmuxSelectPane, mocks.tmuxKillPane]; + +vi.mock("../logger", () => ({ + createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }), +})); + +// Nothing in this module may spawn a process directly. +vi.mock("../spawn", () => ({ spawn: vi.fn(), spawnSync: vi.fn() })); + +vi.mock("../tmux", () => ({ + PANE_START_COMMAND_FORMAT: { formatString: "#{pane_id}\t#{pane_start_command}", parse: () => [] }, + TmuxError: FakeTmuxError, + taskSessionName: (taskId: string) => `dev3-${taskId.slice(0, 8)}`, + tmux: { + listPanes: mocks.tmuxListPanes, + splitWindow: mocks.tmuxSplitWindow, + selectPane: mocks.tmuxSelectPane, + killPane: mocks.tmuxKillPane, + }, +})); + +vi.mock("../native-task-panes", () => ({ + nativeTaskPanesState: mocks.nativeTaskPanesState, + nativeTaskPaneCommands: mocks.nativeTaskPaneCommands, + splitNativeTaskPane: mocks.splitNativeTaskPane, + closeNativeTaskPane: mocks.closeNativeTaskPane, + focusNativeTaskPane: mocks.focusNativeTaskPane, +})); + +import { + auxPaneAlive, + auxPaneMarker, + auxPurposeOfCommand, + AuxPaneUnavailableError, + closeAuxPane, + findAuxPane, + nativeAuxPaneShellPid, + openAuxPane, +} from "../task-aux-panes"; +import { spawn } from "../spawn"; + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +const TASK_ID = "aaaaaaaa-0000-0000-0000-000000000001"; +const SESSION = `dev3-${TASK_ID.slice(0, 8)}`; +const SOCKET = "dev3-sock"; + +const nativeTask = { id: TASK_ID, terminalBackend: "native" } as unknown as Task; +const tmuxTask = { id: TASK_ID } as unknown as Task; + +const DEV_MARKER = auxPaneMarker(TASK_ID, "devServer"); + +function spec(task: Task, overrides: Partial[0]> = {}) { + return { + task, + purpose: "devServer" as const, + placement: "below" as const, + size: "20%", + cwd: "/tmp/wt", + env: { DEV3_TASK_ID: TASK_ID }, + socket: SOCKET, + title: "Dev Server", + tmuxCommand: `bash ${DEV_MARKER}`, + nativeLaunch: { executable: "/bin/bash", argv: [DEV_MARKER] }, + ...overrides, + }; +} + +function nativePane(paneId: string, command: string[], alive = true) { + return { paneId, sessionId: `sess-${paneId}`, command, shellPid: 4242, alive }; +} + +function nativeState(paneIds: string[], activePaneId: string | null) { + return { + taskId: TASK_ID, + panes: paneIds.map((paneId) => ({ + paneId, + sessionId: `sess-${paneId}`, + hostPid: 100, + shellPid: 101, + cols: 80, + rows: 24, + alive: true, + })), + layout: null, + activePaneId, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.nativeTaskPanesState.mockResolvedValue(nativeState(["pane-1"], "pane-1")); + mocks.nativeTaskPaneCommands.mockResolvedValue([]); + mocks.splitNativeTaskPane.mockResolvedValue({ paneId: "pane-2", state: nativeState(["pane-1", "pane-2"], "pane-2") }); + mocks.closeNativeTaskPane.mockResolvedValue({ sessionTornDown: false, state: nativeState(["pane-1"], "pane-1") }); + mocks.focusNativeTaskPane.mockResolvedValue(nativeState(["pane-1", "pane-2"], "pane-1")); + mocks.tmuxSplitWindow.mockResolvedValue({ paneId: "%7", stderr: "" }); + mocks.tmuxSelectPane.mockResolvedValue(undefined); + mocks.tmuxKillPane.mockResolvedValue(undefined); + mocks.tmuxListPanes.mockResolvedValue([]); +}); + +// ── Native backend ─────────────────────────────────────────────────────────── + +describe("openAuxPane (native)", () => { + it("splits from the coordinator's active pane and returns the native handle", async () => { + const handle = await openAuxPane(spec(nativeTask)); + + expect(mocks.splitNativeTaskPane).toHaveBeenCalledWith(TASK_ID, "pane-1", "vertical", { + cwd: "/tmp/wt", + env: { DEV3_TASK_ID: TASK_ID }, + launch: { executable: "/bin/bash", argv: [DEV_MARKER] }, + }); + expect(handle).toEqual({ backend: "native", paneId: "pane-2" }); + }); + + it("makes ZERO tmux calls", async () => { + await openAuxPane(spec(nativeTask)); + + for (const method of TMUX_METHODS) expect(method).not.toHaveBeenCalled(); + expect(spawn).not.toHaveBeenCalled(); + }); + + it("hands focus back to the pane that had it before the split", async () => { + await openAuxPane(spec(nativeTask)); + expect(mocks.focusNativeTaskPane).toHaveBeenCalledWith(TASK_ID, "pane-1"); + }); + + it("forces focus nowhere when the coordinator had no active pane", async () => { + mocks.nativeTaskPanesState.mockResolvedValue(nativeState(["pane-1"], null)); + + await openAuxPane(spec(nativeTask)); + + expect(mocks.splitNativeTaskPane).toHaveBeenCalledWith(TASK_ID, "pane-1", "vertical", expect.anything()); + expect(mocks.focusNativeTaskPane).not.toHaveBeenCalled(); + }); + + it("closes the purpose's existing pane before opening the new one", async () => { + mocks.nativeTaskPaneCommands.mockResolvedValue([ + nativePane("pane-1", ["/bin/zsh"]), + nativePane("pane-9", ["/bin/bash", DEV_MARKER]), + ]); + + await openAuxPane(spec(nativeTask)); + + expect(mocks.closeNativeTaskPane).toHaveBeenCalledTimes(1); + expect(mocks.closeNativeTaskPane).toHaveBeenCalledWith(TASK_ID, "pane-9"); + expect(mocks.closeNativeTaskPane.mock.invocationCallOrder[0]).toBeLessThan( + mocks.splitNativeTaskPane.mock.invocationCallOrder[0], + ); + expect(mocks.splitNativeTaskPane).toHaveBeenCalledTimes(1); + }); + + it("sweeps a dead owned pane before opening a new one", async () => { + mocks.nativeTaskPaneCommands.mockResolvedValue([nativePane("pane-9", ["/bin/bash", DEV_MARKER], false)]); + + await openAuxPane(spec(nativeTask)); + + expect(mocks.closeNativeTaskPane).toHaveBeenCalledWith(TASK_ID, "pane-9"); + expect(mocks.splitNativeTaskPane).toHaveBeenCalledTimes(1); + }); + + it("throws AuxPaneUnavailableError when the native terminal is not running", async () => { + mocks.nativeTaskPanesState.mockResolvedValue(null); + + await expect(openAuxPane(spec(nativeTask))).rejects.toBeInstanceOf(AuxPaneUnavailableError); + expect(mocks.splitNativeTaskPane).not.toHaveBeenCalled(); + for (const method of TMUX_METHODS) expect(method).not.toHaveBeenCalled(); + }); + + it("throws AuxPaneUnavailableError when the pane set is empty", async () => { + mocks.nativeTaskPanesState.mockResolvedValue(nativeState([], null)); + + await expect(openAuxPane(spec(nativeTask))).rejects.toBeInstanceOf(AuxPaneUnavailableError); + for (const method of TMUX_METHODS) expect(method).not.toHaveBeenCalled(); + }); +}); + +describe("native pane lookup by launch-command marker", () => { + it("findAuxPane resolves the pane carrying the marker", async () => { + mocks.nativeTaskPaneCommands.mockResolvedValue([ + nativePane("pane-1", ["/bin/zsh"]), + nativePane("pane-9", ["/bin/bash", DEV_MARKER]), + ]); + + await expect(findAuxPane(nativeTask, "devServer", SOCKET)).resolves.toEqual({ + backend: "native", + paneId: "pane-9", + }); + }); + + it("findAuxPane returns null for an ordinary pane", async () => { + mocks.nativeTaskPaneCommands.mockResolvedValue([nativePane("pane-1", ["/bin/zsh"])]); + await expect(findAuxPane(nativeTask, "devServer", SOCKET)).resolves.toBeNull(); + }); + + it("auxPaneAlive is true only while the marked pane's process runs", async () => { + mocks.nativeTaskPaneCommands.mockResolvedValue([nativePane("pane-9", ["/bin/bash", DEV_MARKER])]); + await expect(auxPaneAlive(nativeTask, "devServer", SOCKET)).resolves.toBe(true); + + mocks.nativeTaskPaneCommands.mockResolvedValue([nativePane("pane-9", ["/bin/bash", DEV_MARKER], false)]); + await expect(auxPaneAlive(nativeTask, "devServer", SOCKET)).resolves.toBe(false); + }); + + it("auxPaneAlive is false for an ordinary pane", async () => { + mocks.nativeTaskPaneCommands.mockResolvedValue([nativePane("pane-1", ["/bin/zsh"])]); + await expect(auxPaneAlive(nativeTask, "devServer", SOCKET)).resolves.toBe(false); + }); + + it("nativeAuxPaneShellPid returns the live pane's pid, null otherwise", async () => { + mocks.nativeTaskPaneCommands.mockResolvedValue([nativePane("pane-9", ["/bin/bash", DEV_MARKER])]); + await expect(nativeAuxPaneShellPid(nativeTask, "devServer")).resolves.toBe(4242); + + mocks.nativeTaskPaneCommands.mockResolvedValue([nativePane("pane-1", ["/bin/zsh"])]); + await expect(nativeAuxPaneShellPid(nativeTask, "devServer")).resolves.toBeNull(); + }); + + it("auxPurposeOfCommand labels a command only by its own marker", () => { + expect(auxPurposeOfCommand(TASK_ID, ["/bin/bash", DEV_MARKER])).toBe("devServer"); + expect(auxPurposeOfCommand(TASK_ID, ["/bin/bash", auxPaneMarker(TASK_ID, "gitOp") + "rebase.sh"])).toBe("gitOp"); + expect(auxPurposeOfCommand(TASK_ID, ["/bin/zsh"])).toBeNull(); + }); +}); + +// ── tmux backend (regression guard: behaviour must be unchanged) ────────────── + +describe("openAuxPane (tmux)", () => { + it("splits the task session below at the requested size", async () => { + const handle = await openAuxPane(spec(tmuxTask)); + + expect(mocks.tmuxSplitWindow).toHaveBeenCalledWith({ + target: SESSION, + orientation: "vertical", + size: "20%", + printPaneId: true, + env: { DEV3_TASK_ID: TASK_ID }, + cwd: "/tmp/wt", + command: `bash ${DEV_MARKER}`, + socket: SOCKET, + }); + expect(handle).toEqual({ backend: "tmux", paneId: "%7" }); + }); + + it("maps placement 'right' to a horizontal split", async () => { + await openAuxPane(spec(tmuxTask, { placement: "right", size: "50%" })); + + expect(mocks.tmuxSplitWindow).toHaveBeenCalledWith( + expect.objectContaining({ orientation: "horizontal", size: "50%" }), + ); + }); + + it("titles the new pane", async () => { + await openAuxPane(spec(tmuxTask)); + expect(mocks.tmuxSelectPane).toHaveBeenCalledWith("%7", { socket: SOCKET, title: "Dev Server" }); + }); + + it("surfaces a failed split as a readable error", async () => { + mocks.tmuxSplitWindow.mockRejectedValue(new FakeTmuxError(["split-window"], 1, "no such session")); + + await expect(openAuxPane(spec(tmuxTask))).rejects.toThrow(/tmux split-window failed/); + }); + + it("makes ZERO native calls", async () => { + await openAuxPane(spec(tmuxTask)); + + expect(mocks.nativeTaskPanesState).not.toHaveBeenCalled(); + expect(mocks.splitNativeTaskPane).not.toHaveBeenCalled(); + expect(mocks.closeNativeTaskPane).not.toHaveBeenCalled(); + expect(mocks.focusNativeTaskPane).not.toHaveBeenCalled(); + expect(mocks.nativeTaskPaneCommands).not.toHaveBeenCalled(); + }); +}); + +describe("closeAuxPane", () => { + it("kills the tmux pane best-effort", async () => { + mocks.tmuxListPanes.mockResolvedValue([{ paneId: "%7", startCommand: `bash ${DEV_MARKER}` }]); + + await closeAuxPane(tmuxTask, "devServer", SOCKET); + + expect(mocks.tmuxKillPane).toHaveBeenCalledWith("%7", { socket: SOCKET, bestEffort: true }); + }); + + it("closes the native pane through the native backend", async () => { + mocks.nativeTaskPaneCommands.mockResolvedValue([nativePane("pane-9", ["/bin/bash", DEV_MARKER])]); + + await closeAuxPane(nativeTask, "devServer", SOCKET); + + expect(mocks.closeNativeTaskPane).toHaveBeenCalledWith(TASK_ID, "pane-9"); + expect(mocks.tmuxKillPane).not.toHaveBeenCalled(); + }); + + it("does nothing when the purpose owns no pane", async () => { + await closeAuxPane(tmuxTask, "devServer", SOCKET); + expect(mocks.tmuxKillPane).not.toHaveBeenCalled(); + }); +}); diff --git a/src/bun/lifecycle/__tests__/native-teardown.test.ts b/src/bun/lifecycle/__tests__/native-teardown.test.ts index 1b2b765e2..fef912890 100644 --- a/src/bun/lifecycle/__tests__/native-teardown.test.ts +++ b/src/bun/lifecycle/__tests__/native-teardown.test.ts @@ -154,16 +154,21 @@ describe("destroyTaskPty", () => { }); describe("killDevServer", () => { - it("is skipped for a native task, which owns no tmux dev session", async () => { - await executeLifecycleEffect(effect("killDevServer"), context(task({ terminalBackend: "native" }))); - - expect(killDevServerSession).not.toHaveBeenCalled(); + // A native task hosts its dev server in an auxiliary pane rather than a tmux + // session, so teardown must still run — skipping it here leaked the whole + // dev-server process tree, ports included. + it("tears the dev server down for a native task too", async () => { + const nativeTask = task({ terminalBackend: "native" }); + await executeLifecycleEffect(effect("killDevServer"), context(nativeTask)); + + expect(killDevServerSession).toHaveBeenCalledWith(nativeTask, "dev3", "/tmp/wt"); }); it("still tears the dev session down for an unmarked task", async () => { - await executeLifecycleEffect(effect("killDevServer"), context(task())); + const tmuxTask = task(); + await executeLifecycleEffect(effect("killDevServer"), context(tmuxTask)); - expect(killDevServerSession).toHaveBeenCalledWith(TASK_ID, "dev3", "/tmp/wt"); + expect(killDevServerSession).toHaveBeenCalledWith(tmuxTask, "dev3", "/tmp/wt"); }); }); diff --git a/src/bun/lifecycle/executor.ts b/src/bun/lifecycle/executor.ts index fc64d4515..e89c27d35 100644 --- a/src/bun/lifecycle/executor.ts +++ b/src/bun/lifecycle/executor.ts @@ -763,11 +763,11 @@ export async function executeLifecycleEffect( } return {}; case "killDevServer": - // A dev server is a tmux session; a native task has none, and probing tmux - // for it is exactly what the native path must never do. - if (taskTerminalBackendIdentity(ctx.sourceTask) === "native") return {}; + // Both backends host a dev server (tmux: a nested session, native: an + // auxiliary pane), and killDevServerSession picks the right one. Skipping + // native here used to leak the whole dev-server process tree on teardown. await killDevServerSession( - ctx.sourceTask.id, + ctx.sourceTask, ctx.sourceTask.tmuxSocket ?? DEFAULT_TMUX_SOCKET, ctx.sourceTask.worktreePath, ); diff --git a/src/bun/lifecycle/service.ts b/src/bun/lifecycle/service.ts index 944a4c380..8f67306cf 100644 --- a/src/bun/lifecycle/service.ts +++ b/src/bun/lifecycle/service.ts @@ -30,7 +30,6 @@ export interface LifecycleActorRuntime { prPending?: boolean; prPromoted?: boolean; prSignalKey?: string; - gitOpPaneId?: string; branchChecks?: Map>; activeActivities?: Set; } @@ -230,7 +229,6 @@ class LifecycleService { clearTaskRuntime: (id) => { const runtime = this.actors.runtime(id); delete runtime.mergePromptReservation; - delete runtime.gitOpPaneId; runtime.branchChecks?.clear(); delete runtime.branchChecks; delete runtime.mergeNextDue; diff --git a/src/bun/native-task-panes.ts b/src/bun/native-task-panes.ts index 440f99cdd..3596171d8 100644 --- a/src/bun/native-task-panes.ts +++ b/src/bun/native-task-panes.ts @@ -269,6 +269,11 @@ export async function focusNativeTaskPane(taskId: string, paneId: string): Promi return buildState(taskId); } +/** Type into one native pane, exactly as a viewer's keystrokes would. */ +export async function writeNativeTaskPane(taskId: string, paneId: string, data: string): Promise { + await getBackend().writePane(coordinatorId(taskId), paneId, data); +} + /** * Tear down every pane in a task's pane set and VERIFY they are gone. * Always verifies — an unconfirmed teardown throws whether or not this process @@ -288,6 +293,30 @@ export async function stopNativeTaskPanes(taskId: string): Promise { log.info("Native task panes stopped", { taskId: taskId.slice(0, 8) }); } +/** + * The launch command behind every pane of a task, read from the per-pane + * registry records. + * + * This is the native counterpart of tmux's `#{pane_start_command}` listing: it + * lets a caller re-find a pane it started earlier — after an app restart, when + * no in-memory ownership map survives — by matching the command it launched. + * Panes whose record is unreadable are reported with an empty command rather + * than dropped, so the caller still sees the pane exists. + */ +export async function nativeTaskPaneCommands( + taskId: string, +): Promise> { + const state = await nativeTaskPanesState(taskId); + if (!state) return []; + return state.panes.map((pane) => ({ + paneId: pane.paneId, + sessionId: pane.sessionId, + command: readRecord(pane.sessionId)?.shell.command ?? [], + shellPid: pane.shellPid, + alive: pane.alive, + })); +} + /** * True when the coordinator record exists and contains at least one owned pane. * Read-only: does NOT register or cache the recovered coordinator as a side effect. diff --git a/src/bun/rpc-handlers/__tests__/dev-server-backend-matrix.test.ts b/src/bun/rpc-handlers/__tests__/dev-server-backend-matrix.test.ts new file mode 100644 index 000000000..227ac3615 --- /dev/null +++ b/src/bun/rpc-handlers/__tests__/dev-server-backend-matrix.test.ts @@ -0,0 +1,268 @@ +/** + * The dev server on BOTH terminal backends. + * + * The regression this guards: a native task used to get a `dev3-dev-` tmux + * session behind its back (the viewer split failed inside a best-effort catch, + * so the dev script kept running invisibly). A native start must touch no tmux + * at all and must open a real auxiliary pane instead. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mocks = vi.hoisted(() => ({ + // data + getProject: vi.fn(), + getTask: vi.fn(), + // settings-config + resolveOperationalProjectConfig: vi.fn(), + // task-terminal-backend + taskTerminalBackendIdentity: vi.fn(), + // task-aux-panes seam + auxPaneAlive: vi.fn(), + closeAuxPane: vi.fn(), + findAuxPane: vi.fn(), + nativeAuxPaneShellPid: vi.fn(), + openAuxPane: vi.fn(), + // port pool / scanner / reaper + getPortAssignments: vi.fn(() => [] as number[]), + allocatePorts: vi.fn(), + buildPortEnv: vi.fn(() => ({})), + buildProcessTree: vi.fn(async () => new Map()), + collectDescendants: vi.fn(() => [] as number[]), + collectTaskPids: vi.fn(async () => new Set()), + findPortHolders: vi.fn(async () => []), + getLsofOutput: vi.fn(async () => ""), + getPortsForTask: vi.fn(() => []), + getSessionPanePids: vi.fn(async () => [] as number[]), + parseLsofOutput: vi.fn(() => []), + scanTaskPorts: vi.fn(async () => []), + waitForPortsFree: vi.fn(async () => []), + clearPortDataForTask: vi.fn(), + getPidCwd: vi.fn(async () => null), + terminatePidsVerified: vi.fn(async () => [] as number[]), + getResourceUsage: vi.fn(() => undefined), + writeLaunchScript: vi.fn(async () => {}), + // tmux singleton — every method the dev-server paths could reach + tmuxHasSession: vi.fn(async () => false), + tmuxNewSessionDetached: vi.fn(async () => ({ stdout: "", stderr: "" })), + tmuxSplitWindow: vi.fn(async () => ({ paneId: "%7", stdout: "", stderr: "" })), + tmuxKillSession: vi.fn(async () => {}), + tmuxKillPane: vi.fn(async () => {}), + tmuxSelectPane: vi.fn(async () => {}), + tmuxSetOption: vi.fn(async () => {}), + tmuxListPanes: vi.fn(async () => []), + tmuxBinaryPath: vi.fn(() => "/opt/homebrew/bin/tmux"), +})); + +vi.mock("../../data", () => ({ getProject: mocks.getProject, getTask: mocks.getTask })); +vi.mock("../settings-config", () => ({ resolveOperationalProjectConfig: mocks.resolveOperationalProjectConfig })); +vi.mock("../../task-terminal-backend", () => ({ taskTerminalBackendIdentity: mocks.taskTerminalBackendIdentity })); + +vi.mock("../../task-aux-panes", () => ({ + auxPaneAlive: mocks.auxPaneAlive, + auxPaneTitle: (purpose: string) => (purpose === "devServer" ? "Dev Server" : "Git"), + closeAuxPane: mocks.closeAuxPane, + findAuxPane: mocks.findAuxPane, + nativeAuxPaneShellPid: mocks.nativeAuxPaneShellPid, + openAuxPane: mocks.openAuxPane, +})); + +vi.mock("../../port-pool", () => ({ + getPortAssignments: mocks.getPortAssignments, + allocatePorts: mocks.allocatePorts, + buildPortEnv: mocks.buildPortEnv, +})); + +vi.mock("../../port-scanner", () => ({ + buildProcessTree: mocks.buildProcessTree, + clearPortDataForTask: mocks.clearPortDataForTask, + collectDescendants: mocks.collectDescendants, + collectTaskPids: mocks.collectTaskPids, + findPortHolders: mocks.findPortHolders, + getLsofOutput: mocks.getLsofOutput, + getPortsForTask: mocks.getPortsForTask, + getSessionPanePids: mocks.getSessionPanePids, + parseLsofOutput: mocks.parseLsofOutput, + scanTaskPorts: mocks.scanTaskPorts, + waitForPortsFree: mocks.waitForPortsFree, +})); + +vi.mock("../../process-reaper", () => ({ getPidCwd: mocks.getPidCwd, terminatePidsVerified: mocks.terminatePidsVerified })); +vi.mock("../../resource-monitor", () => ({ getResourceUsage: mocks.getResourceUsage })); + +vi.mock("../../pty-server", () => ({})); +vi.mock("../../agents", () => ({})); +vi.mock("../../repo-config", () => ({})); +vi.mock("../../settings", () => ({ loadSettings: vi.fn(), recordFavoriteUsages: vi.fn() })); +vi.mock("../../shell-env", () => ({ getUserShell: vi.fn(() => "/bin/bash") })); +vi.mock("../../spawn", () => ({ spawn: vi.fn() })); +vi.mock("../../agent-hooks", () => ({ setupAgentHooks: vi.fn() })); +vi.mock("../../agent-transcripts", () => ({ resolveResumableSessionId: vi.fn() })); +vi.mock("../../artifact-template", () => ({ ensureArtifactTemplateEnv: vi.fn() })); +vi.mock("../../agent-prompt", () => ({ markAgentPane: vi.fn() })); +vi.mock("../../native-task-panes", () => ({ nativeTaskPanesAlive: vi.fn(async () => false) })); + +vi.mock("../shared-pure", async (importOriginal) => ({ + ...(await importOriginal()), + writeLaunchScript: mocks.writeLaunchScript, +})); + +vi.mock("../../tmux", async (importOriginal) => ({ + ...(await importOriginal()), + tmux: { + hasSession: mocks.tmuxHasSession, + newSessionDetached: mocks.tmuxNewSessionDetached, + splitWindow: mocks.tmuxSplitWindow, + killSession: mocks.tmuxKillSession, + killPane: mocks.tmuxKillPane, + selectPane: mocks.tmuxSelectPane, + setOption: mocks.tmuxSetOption, + listPanes: mocks.tmuxListPanes, + binaryPath: mocks.tmuxBinaryPath, + }, +})); + +import { runDevServer, stopDevServer, cleanupTaskTmuxState } from "../tmux-pty"; + +const TASK_ID = "abcdef12-0000-0000-0000-000000000001"; +const DEV_SESSION = "dev3-dev-abcdef12"; +const TASK_SESSION = "dev3-abcdef12"; +const PROJECT = { id: "proj-1", name: "p", path: "/repo" } as any; +const TASK = { id: TASK_ID, title: "Dev server task", branchName: "feat/x", worktreePath: "/repo/wt", tmuxSocket: "dev3" } as any; + +/** Every tmux method the mocked singleton exposes — "no tmux at all" asserts on all of them. */ +const ALL_TMUX_CALLS = [ + mocks.tmuxHasSession, + mocks.tmuxNewSessionDetached, + mocks.tmuxSplitWindow, + mocks.tmuxKillSession, + mocks.tmuxKillPane, + mocks.tmuxSelectPane, + mocks.tmuxSetOption, + mocks.tmuxListPanes, + mocks.tmuxBinaryPath, +]; + +function useBackend(backend: "native" | "tmux"): void { + mocks.taskTerminalBackendIdentity.mockReturnValue(backend); +} + +/** Track dev-server liveness the way the real backends do: the pane/session IS the server. */ +function trackNativeLiveness(paneId = "%42", shellPid = 4242): { alive: () => boolean } { + let alive = false; + mocks.auxPaneAlive.mockImplementation(async () => alive); + mocks.findAuxPane.mockImplementation(async () => (alive ? { backend: "native", paneId } : null)); + mocks.nativeAuxPaneShellPid.mockImplementation(async () => (alive ? shellPid : null)); + mocks.openAuxPane.mockImplementation(async () => { + alive = true; + return { backend: "native", paneId }; + }); + mocks.closeAuxPane.mockImplementation(async () => { + alive = false; + }); + return { alive: () => alive }; +} + +beforeEach(() => { + vi.clearAllMocks(); + cleanupTaskTmuxState(TASK_ID); + mocks.getProject.mockResolvedValue(PROJECT); + mocks.getTask.mockResolvedValue(TASK); + mocks.resolveOperationalProjectConfig.mockResolvedValue({ devScript: "npm run dev", portCount: 0, env: {} }); + mocks.getPortAssignments.mockReturnValue([]); + mocks.getLsofOutput.mockResolvedValue(""); + mocks.buildProcessTree.mockResolvedValue(new Map()); + mocks.collectDescendants.mockReturnValue([]); + mocks.terminatePidsVerified.mockResolvedValue([]); + mocks.waitForPortsFree.mockResolvedValue([]); + mocks.findPortHolders.mockResolvedValue([]); + mocks.tmuxHasSession.mockResolvedValue(false); + mocks.tmuxNewSessionDetached.mockResolvedValue({ stdout: "", stderr: "" }); + mocks.tmuxSplitWindow.mockResolvedValue({ paneId: "%7", stdout: "", stderr: "" }); +}); + +describe("runDevServer — native backend", () => { + it("makes no tmux calls at all", async () => { + useBackend("native"); + trackNativeLiveness(); + + await runDevServer({ taskId: TASK_ID, projectId: PROJECT.id }); + + for (const call of ALL_TMUX_CALLS) expect(call).not.toHaveBeenCalled(); + }); + + it("opens the dev-server pane through the auxiliary-pane seam", async () => { + useBackend("native"); + trackNativeLiveness(); + + await runDevServer({ taskId: TASK_ID, projectId: PROJECT.id }); + + expect(mocks.openAuxPane).toHaveBeenCalledWith( + expect.objectContaining({ purpose: "devServer", placement: "right", cwd: "/repo/wt" }), + ); + }); + + it("reports a native status with no tmux session names and the pane as viewer", async () => { + useBackend("native"); + trackNativeLiveness("%99", 777); + + const status = await runDevServer({ taskId: TASK_ID, projectId: PROJECT.id }); + + expect(status).toMatchObject({ + backend: "native", + running: true, + taskSessionName: "", + devSessionName: "", + viewerPaneId: "%99", + panePids: [777], + }); + }); +}); + +describe("runDevServer — tmux backend is unchanged", () => { + it("creates the dev session and splits the viewer pane, reporting backend tmux", async () => { + useBackend("tmux"); + + const status = await runDevServer({ taskId: TASK_ID, projectId: PROJECT.id }); + + expect(mocks.tmuxNewSessionDetached).toHaveBeenCalledWith( + expect.objectContaining({ sessionName: DEV_SESSION, cwd: "/repo/wt" }), + ); + expect(mocks.tmuxSplitWindow).toHaveBeenCalledWith(expect.objectContaining({ target: TASK_SESSION })); + expect(status.backend).toBe("tmux"); + expect(mocks.openAuxPane).not.toHaveBeenCalled(); + }); +}); + +describe("stopDevServer", () => { + it("closes the native pane and never kills a tmux session", async () => { + useBackend("native"); + const liveness = trackNativeLiveness(); + await runDevServer({ taskId: TASK_ID, projectId: PROJECT.id }); + + await stopDevServer({ taskId: TASK_ID, projectId: PROJECT.id }); + + expect(mocks.closeAuxPane).toHaveBeenCalledWith(TASK, "devServer", "dev3"); + expect(mocks.tmuxKillSession).not.toHaveBeenCalled(); + expect(liveness.alive()).toBe(false); + }); + + it("still kills the dev tmux session on the tmux backend", async () => { + useBackend("tmux"); + + await stopDevServer({ taskId: TASK_ID, projectId: PROJECT.id }); + + expect(mocks.tmuxKillSession).toHaveBeenCalledWith(DEV_SESSION, expect.objectContaining({ socket: "dev3" })); + expect(mocks.closeAuxPane).not.toHaveBeenCalled(); + }); + + it("reaps the native pane's shell pid and its descendants", async () => { + useBackend("native"); + trackNativeLiveness("%42", 555); + mocks.collectDescendants.mockReturnValue([556, 557]); + await runDevServer({ taskId: TASK_ID, projectId: PROJECT.id }); + + await stopDevServer({ taskId: TASK_ID, projectId: PROJECT.id }); + + expect(mocks.terminatePidsVerified).toHaveBeenCalledWith([555, 556, 557], expect.anything()); + }); +}); diff --git a/src/bun/rpc-handlers/__tests__/task-panes.test.ts b/src/bun/rpc-handlers/__tests__/task-panes.test.ts index d70bc96af..ca21913e4 100644 --- a/src/bun/rpc-handlers/__tests__/task-panes.test.ts +++ b/src/bun/rpc-handlers/__tests__/task-panes.test.ts @@ -32,6 +32,7 @@ const mocks = vi.hoisted(() => ({ taskTerminalBackendIdentity: vi.fn(), // native-task-panes nativeTaskPanesState: vi.fn(), + nativeTaskPaneCommands: vi.fn(async () => []), splitNativeTaskPane: vi.fn(), closeNativeTaskPane: vi.fn(), focusNativeTaskPane: vi.fn(), @@ -89,6 +90,7 @@ vi.mock("../../task-terminal-backend", () => ({ vi.mock("../../native-task-panes", () => ({ nativeTaskPanesState: mocks.nativeTaskPanesState, + nativeTaskPaneCommands: mocks.nativeTaskPaneCommands, splitNativeTaskPane: mocks.splitNativeTaskPane, closeNativeTaskPane: mocks.closeNativeTaskPane, focusNativeTaskPane: mocks.focusNativeTaskPane, diff --git a/src/bun/rpc-handlers/git-operations.ts b/src/bun/rpc-handlers/git-operations.ts index d7d9c98dd..f2273949d 100644 --- a/src/bun/rpc-handlers/git-operations.ts +++ b/src/bun/rpc-handlers/git-operations.ts @@ -12,9 +12,10 @@ import { import * as data from "../data"; import * as git from "../git"; import * as github from "../github"; -import { tmux, DEFAULT_TMUX_SOCKET, TmuxError, taskSessionName, PANE_ID_FORMAT, PANE_START_COMMAND_FORMAT } from "../tmux"; +import { DEFAULT_TMUX_SOCKET } from "../tmux"; import { dev3TaskTempPath } from "../temp-paths"; import { deliverAgentPrompt } from "../agent-prompt-delivery"; +import { auxPaneAlive, auxPaneTitle, openAuxPane } from "../task-aux-panes"; import { scheduleMessage as scheduleMessageCore, cancelScheduledMessage as cancelScheduledMessageCore, @@ -53,57 +54,34 @@ function assertGitTask(project: Project, task: Task): asserts task is Task & { w // Bound concurrent heavy branch-status runs across all tasks (see getBranchStatus). const GIT_STATUS_MAX_CONCURRENCY = 4; const branchStatusSemaphore = new Semaphore(GIT_STATUS_MAX_CONCURRENCY); -async function killExistingGitPane(taskId: string, tmuxSession: string, socket: string): Promise { - const runtime = lifecycleActorRuntime(taskId); - const existingPane = runtime.gitOpPaneId; - if (existingPane) { - await tmux.killPane(existingPane, { socket, bestEffort: true }); - delete runtime.gitOpPaneId; - log.info("Killed existing git op pane (from map)", { taskId: taskId.slice(0, 8), paneId: existingPane }); - return; - } - - // A failed listing behaves like an empty one — nothing to kill. - let rows: Array<{ paneId: string; startCommand: string }> = []; - try { - rows = await tmux.listPanes(PANE_START_COMMAND_FORMAT, { target: tmuxSession, socket }); - } catch (err) { - if (!(err instanceof TmuxError)) throw err; - } - for (const row of rows) { - if (!row.startCommand.includes(`dev3-${taskId}-git-`)) continue; - await tmux.killPane(row.paneId, { socket, bestEffort: true }); - log.info("Killed existing git op pane (from tmux scan)", { taskId: taskId.slice(0, 8), paneId: row.paneId }); - } -} - -async function openGitOpPane(tmuxSession: string, cwd: string, scriptPath: string, socket: string): Promise { - try { - const { paneId, stderr } = await tmux.splitWindow({ - target: tmuxSession, - orientation: "vertical", - size: "20%", - printPaneId: true, - cwd, - command: `bash "${scriptPath}"`, - socket, - }); - if (stderr.trim()) { - log.warn("openGitOpPane tmux stderr", { stderr: stderr.trim() }); - } - return paneId; - } catch (err) { - if (!(err instanceof TmuxError)) throw err; - if (err.stderr) { - log.warn("openGitOpPane tmux stderr", { stderr: err.stderr }); - } - throw new Error(`tmux split-window failed (exit ${err.exitCode}): ${err.stderr || "unknown error"}`); - } +/** + * Run a git operation's script in the task's auxiliary pane and watch it to + * completion. Backend-neutral: the pane is a tmux split or a native SplitTree + * pane depending on the task, and the seam replaces the pane this task already + * owns so repeated clicks never stack two. + * + * The script is the same for both backends — it prints its own result and holds + * the pane open (a keypress on failure, a few seconds on success) so the output + * stays readable after the command itself has finished. + */ +async function openGitOpPane(task: Task, cwd: string, scriptPath: string, socket: string): Promise { + const handle = await openAuxPane({ + task, + purpose: "gitOp", + placement: "below", + size: "20%", + cwd, + socket, + title: auxPaneTitle("gitOp"), + tmuxCommand: `bash "${scriptPath}"`, + nativeLaunch: { executable: "/bin/bash", argv: [scriptPath] }, + }); + return handle.paneId || null; } -function monitorGitPane(paneId: string | null, taskId: string, projectId: string, operation: string, socket: string): void { +function monitorGitPane(paneId: string | null, task: Task, projectId: string, operation: string, socket: string): void { if (!paneId) return; - const tmuxSession = taskSessionName(taskId); + const taskId = task.id; const exitFilePath = dev3TaskTempPath(taskId, `git-${operation}.sh.exit`); let interval: ReturnType | undefined; @@ -119,20 +97,12 @@ function monitorGitPane(paneId: string | null, taskId: string, projectId: string try { interval = setInterval(async () => { try { - // A failed listing (session gone) counts as "no panes" so the + // An unreadable pane set (session gone) counts as "no panes" so the // completion event still fires, exactly like the empty output did. - let paneIds: string[] = []; - try { - paneIds = (await tmux.listPanes(PANE_ID_FORMAT, { target: tmuxSession, socket })).map((row) => row.paneId); - } catch (err) { - if (!(err instanceof TmuxError)) throw err; - } - - const paneStillExists = paneIds.includes(paneId); + const paneStillExists = await auxPaneAlive(task, "gitOp", socket); if (!paneStillExists) { cleanup(); - delete lifecycleActorRuntime(taskId).gitOpPaneId; let ok = false; try { @@ -349,10 +319,8 @@ async function rebaseTask(params: { taskId: string; projectId: string; compareRe const baseBranch = resolveTaskCompareBaseBranch(task, project); const rebaseTarget = params.compareRef || `origin/${baseBranch}`; - const tmuxSession = taskSessionName(task.id); const scriptPath = dev3TaskTempPath(task.id, "git-rebase.sh"); const socket = task.tmuxSocket ?? DEFAULT_TMUX_SOCKET; - await killExistingGitPane(task.id, tmuxSession, socket); // Fetch the ref we will actually rebase onto, not just baseBranch. // rebaseTarget may be a custom compareRef (e.g. origin/develop) that differs from baseBranch. @@ -385,9 +353,8 @@ async function rebaseTask(params: { taskId: string; projectId: string; compareRe ].join("\n") + "\n"; await writeLaunchScript(scriptPath, script); - const paneId = await openGitOpPane(tmuxSession, task.worktreePath, scriptPath, socket); - if (paneId) lifecycleActorRuntime(task.id).gitOpPaneId = paneId; - monitorGitPane(paneId, task.id, params.projectId, "rebase", socket); + const paneId = await openGitOpPane(task, task.worktreePath, scriptPath, socket); + monitorGitPane(paneId, task, params.projectId, "rebase", socket); log.info("← rebaseTask (pane opened)", { paneId }); } @@ -412,10 +379,8 @@ async function mergeTask(params: { taskId: string; projectId: string }): Promise const status = await git.getBranchStatus(task.worktreePath, rebaseCheckRef); if (status.behind > 0) throw new Error("Branch is not rebased — rebase first"); - const tmuxSession = taskSessionName(task.id); const scriptPath = dev3TaskTempPath(task.id, "git-merge.sh"); const socket = task.tmuxSocket ?? DEFAULT_TMUX_SOCKET; - await killExistingGitPane(task.id, tmuxSession, socket); const escapedPath = project.path.replace(/'/g, "'\\''"); const escapedBaseBranch = baseBranch.replace(/'/g, "'\\''"); @@ -483,9 +448,8 @@ async function mergeTask(params: { taskId: string; projectId: string }): Promise ].join("\n") + "\n"; await writeLaunchScript(scriptPath, script); - const paneId = await openGitOpPane(tmuxSession, project.path, scriptPath, socket); - if (paneId) lifecycleActorRuntime(task.id).gitOpPaneId = paneId; - monitorGitPane(paneId, task.id, params.projectId, "merge", socket); + const paneId = await openGitOpPane(task, project.path, scriptPath, socket); + monitorGitPane(paneId, task, params.projectId, "merge", socket); log.info("← mergeTask (pane opened)", { paneId }); } @@ -497,10 +461,8 @@ async function pushTask(params: { taskId: string; projectId: string }): Promise< assertGitTask(project, task); - const tmuxSession = taskSessionName(task.id); const scriptPath = dev3TaskTempPath(task.id, "git-push.sh"); const socket = task.tmuxSocket ?? DEFAULT_TMUX_SOCKET; - await killExistingGitPane(task.id, tmuxSession, socket); const script = [ `#!/bin/bash`, @@ -521,9 +483,8 @@ async function pushTask(params: { taskId: string; projectId: string }): Promise< ].join("\n") + "\n"; await writeLaunchScript(scriptPath, script); - const paneId = await openGitOpPane(tmuxSession, task.worktreePath, scriptPath, socket); - if (paneId) lifecycleActorRuntime(task.id).gitOpPaneId = paneId; - monitorGitPane(paneId, task.id, params.projectId, "push", socket); + const paneId = await openGitOpPane(task, task.worktreePath, scriptPath, socket); + monitorGitPane(paneId, task, params.projectId, "push", socket); log.info("← pushTask (pane opened)", { paneId }); } @@ -604,10 +565,8 @@ async function openPullRequest(params: { taskId: string; projectId: string }): P assertGitTask(project, task); - const tmuxSession = taskSessionName(task.id); const scriptPath = dev3TaskTempPath(task.id, "git-openPR.sh"); const socket = task.tmuxSocket ?? DEFAULT_TMUX_SOCKET; - await killExistingGitPane(task.id, tmuxSession, socket); const githubEnvExports = await github.getGitHubShellExports(project); const script = [ @@ -630,9 +589,8 @@ async function openPullRequest(params: { taskId: string; projectId: string }): P ].join("\n") + "\n"; await writeLaunchScript(scriptPath, script); - const paneId = await openGitOpPane(tmuxSession, task.worktreePath, scriptPath, socket); - if (paneId) lifecycleActorRuntime(task.id).gitOpPaneId = paneId; - monitorGitPane(paneId, task.id, params.projectId, "openPR", socket); + const paneId = await openGitOpPane(task, task.worktreePath, scriptPath, socket); + monitorGitPane(paneId, task, params.projectId, "openPR", socket); log.info("← openPullRequest (pane opened)", { paneId }); } diff --git a/src/bun/rpc-handlers/task-panes.ts b/src/bun/rpc-handlers/task-panes.ts index f84a19a57..1ab1b3967 100644 --- a/src/bun/rpc-handlers/task-panes.ts +++ b/src/bun/rpc-handlers/task-panes.ts @@ -18,7 +18,9 @@ import { } from "../tmux"; import { taskTerminalBackendIdentity } from "../task-terminal-backend"; import { buildTaskLifecycleEnv } from "./shared-pure"; +import { auxPaneTitle, auxPurposeOfCommand } from "../task-aux-panes"; import { + nativeTaskPaneCommands, nativeTaskPanesState, splitNativeTaskPane, closeNativeTaskPane, @@ -180,10 +182,26 @@ function detectLayoutPreset(tree: SplitTree): TaskPaneLayoutPreset | null { return null; } +/** + * Titles for the native panes an action owns, so the pane pager and the + * close-pane picker can name "Dev Server" instead of "Pane 2". Derived from each + * pane's launch command — nothing is stored, so a label survives an app restart + * exactly as long as the pane itself does. + */ +async function nativeAuxPaneLabels(taskId: string): Promise> { + const labels = new Map(); + for (const pane of await nativeTaskPaneCommands(taskId)) { + const purpose = auxPurposeOfCommand(taskId, pane.command); + if (purpose) labels.set(pane.paneId, auxPaneTitle(purpose)); + } + return labels; +} + /** Build TaskPaneState from a NativeTaskPanesState. */ function nativeStateToTaskPaneState( nativeState: import("../native-task-panes").NativeTaskPanesState, tree: SplitTree | null, + labels?: Map, ): TaskPaneState { const rects = tree ? getPaneRects(tree) : new Map(); const zoomedPaneId = tree?.zoomedPaneId ?? null; @@ -193,7 +211,7 @@ function nativeStateToTaskPaneState( const panes: TaskPaneInfo[] = nativeState.panes.map((p, i) => ({ paneId: p.paneId, index: i, - label: "", + label: labels?.get(p.paneId) ?? "", active: p.paneId === activePaneId, zoomed: p.paneId === zoomedPaneId, rect: rects.get(p.paneId) ?? { x: 0, y: 0, width: 1, height: 1 }, @@ -262,7 +280,7 @@ async function taskPaneState(params: { taskId: string }): Promise }; } const tree = nativeState.layout ? restoreSplitTree(nativeState.layout) : null; - return nativeStateToTaskPaneState(nativeState, tree); + return nativeStateToTaskPaneState(nativeState, tree, await nativeAuxPaneLabels(params.taskId)); } return tmuxTaskPaneState(params.taskId); diff --git a/src/bun/rpc-handlers/tmux-pty.ts b/src/bun/rpc-handlers/tmux-pty.ts index b525a0200..de52872fb 100644 --- a/src/bun/rpc-handlers/tmux-pty.ts +++ b/src/bun/rpc-handlers/tmux-pty.ts @@ -44,6 +44,7 @@ import { markAgentPane } from "../agent-prompt"; import { dev3TaskTempPath } from "../temp-paths"; import { taskTerminalBackendIdentity } from "../task-terminal-backend"; import { nativeTaskPanesAlive } from "../native-task-panes"; +import { auxPaneAlive, auxPaneTitle, closeAuxPane, findAuxPane, nativeAuxPaneShellPid, openAuxPane } from "../task-aux-panes"; import { getPushMessage, isActive, buildAgentEnv, buildAgentRetryWrapper, buildCmdScript, buildSetupStartupWrapper, buildEnvExports, buildScriptRunnerCommand, buildTaskLifecycleEnv, log, resolveBinaryPath, shellQuote, writeLaunchScript } from "./shared-pure"; import { assertPosixLaunchDialect, launchDialect } from "../../shared/platform-launch"; import { resolveOperationalProjectConfig } from "./settings-config"; @@ -91,13 +92,20 @@ function isSelfHostedByTask(taskId: string): boolean { // that the session is gone before anyone re-inspects it. const SELF_HOSTED_STOP_ACK_MS = 500; -async function isDevServerRunning(taskId: string, socket: string): Promise { - const devSession = devServerSessionName(taskId); +/** + * Is this task's dev server up? A tmux task hosts it in its own nested session; + * a native task runs it directly in the auxiliary pane, so the pane being alive + * IS the dev server being alive. + */ +async function isDevServerRunning(task: Task, socket: string): Promise { + if (taskTerminalBackendIdentity(task) === "native") { + return auxPaneAlive(task, "devServer", socket); + } // A launch-time tmux failure surfaces as a typed TmuxSpawnError (clear // FDA-pointing message) instead of a raw `posix_spawn ENOENT`. This is // the first — and gating — tmux call in the status path, so catching it in // buildDevServerStatus covers the whole read. - return tmux.hasSession(devSession, { socket }); + return tmux.hasSession(devServerSessionName(task.id), { socket }); } async function findDevServerViewerPaneId(taskId: string, taskSession: string, devSession: string, socket: string): Promise { @@ -140,6 +148,18 @@ const DEV_SERVER_TERM_GRACE_MS = 1500; const DEV_SERVER_KILL_WAIT_MS = 2000; const DEV_SERVER_PORT_RELEASE_WAIT_MS = 3000; +/** + * The dev-server process tree of a NATIVE task: the pane's own shell plus every + * descendant, from the same single `ps` snapshot the tmux walk uses (see the + * note on collectDevServerTreePids for why `pgrep` is unusable here). + */ +async function collectNativeDevServerTreePids(task: Task): Promise { + const rootPid = await nativeAuxPaneShellPid(task, "devServer"); + if (rootPid === null || rootPid <= 0) return []; + const processTree = await buildProcessTree(); + return [rootPid, ...collectDescendants(rootPid, processTree)]; +} + async function collectDevServerTreePids(devSession: string, socket: string): Promise { const panePids = await getSessionPanePids(socket, devSession); if (panePids.length === 0) return []; @@ -227,17 +247,21 @@ async function findOrphanedPortHolders( return { orphanPids: [...orphanPids], foreignHolders }; } -export async function killDevServerSession(taskId: string, socket: string, worktreePath?: string | null): Promise { +export async function killDevServerSession(task: Task, socket: string, worktreePath?: string | null): Promise { + const taskId = task.id; + const native = taskTerminalBackendIdentity(task) === "native"; const devSession = devServerSessionName(taskId); const taskSession = taskSessionName(taskId); - // Snapshot the process tree while the dev session still exists — afterwards - // its pane PIDs are unreachable via tmux. - const treePids = await collectDevServerTreePids(devSession, socket); + // Snapshot the process tree while the dev server is still up — afterwards its + // root pid is unreachable (tmux forgets the session; the native pane is gone). + const treePids = native + ? await collectNativeDevServerTreePids(task) + : await collectDevServerTreePids(devSession, socket); // Detached/daemonized devScript children are missed by the tree walk — find // them by pool-port ownership. Processes in the TASK session tree (agent // panes) are excluded: an agent-launched server on a pool port is not the // dev server's to kill. - const taskTreePids = await collectTaskPids(socket, taskSession); + const taskTreePids = native ? new Set() : await collectTaskPids(socket, taskSession); for (const pid of treePids) taskTreePids.add(pid); const { orphanPids, foreignHolders } = await findOrphanedPortHolders(taskId, worktreePath ?? undefined, taskTreePids); if (orphanPids.length > 0) { @@ -247,8 +271,14 @@ export async function killDevServerSession(taskId: string, socket: string, workt log.warn("Assigned ports held by foreign processes — not killing", { taskId: taskId.slice(0, 8), foreignHolders }); } - await killDevServerViewerPane(taskId, taskSession, devSession, socket); - await tmux.killSession(devSession, { socket, bestEffort: true }); + if (native) { + // The pane IS the dev server: closing it kills the script, and the reap + // below finishes off anything it left behind. No tmux is touched. + await closeAuxPane(task, "devServer", socket); + } else { + await killDevServerViewerPane(taskId, taskSession, devSession, socket); + await tmux.killSession(devSession, { socket, bestEffort: true }); + } const leftovers = await reapDevServerTree([...treePids, ...orphanPids], devSession); // "Stop returned" must mean "the next start can bind": wait for the pool @@ -282,9 +312,10 @@ async function buildDevServerStatus(task: Task, projectId: string, hasDevScript: // the read-only status with a raw `posix_spawn ENOENT`. Degrade instead: keep // the tmux-free facts, mark the live state unknown, and carry the diagnostic // in `tmuxError` for the caller to surface. Non-tmux errors still propagate. + const native = taskTerminalBackendIdentity(task) === "native"; let running: boolean; try { - running = await isDevServerRunning(task.id, resolvedSocket); + running = await isDevServerRunning(task, resolvedSocket); } catch (err) { if (!isTmuxSpawnError(err)) throw err; log.error("dev-server status degraded — tmux unreachable", { @@ -300,6 +331,7 @@ async function buildDevServerStatus(task: Task, projectId: string, hasDevScript: tmuxSocket: resolvedSocket, taskSessionName: taskSession, devSessionName: devSession, + backend: "tmux", viewerPaneId: null, panePids: [], assignedPorts, @@ -311,14 +343,25 @@ async function buildDevServerStatus(task: Task, projectId: string, hasDevScript: } const viewerPaneId = running - ? await findDevServerViewerPaneId(task.id, taskSession, devSession, resolvedSocket) + ? native + ? (await findAuxPane(task, "devServer", resolvedSocket))?.paneId ?? null + : await findDevServerViewerPaneId(task.id, taskSession, devSession, resolvedSocket) : null; - const panePids = running ? await getSessionPanePids(resolvedSocket, devSession) : []; + const nativeRootPid = running && native ? await nativeAuxPaneShellPid(task, "devServer") : null; + const panePids = running + ? native + ? (nativeRootPid ? [nativeRootPid] : []) + : await getSessionPanePids(resolvedSocket, devSession) + : []; // One live lsof snapshot shared by the dev-port scan, the conflict check, // and the whole-task-session fallback below. Skipped entirely when there is // nothing to look at (stopped + no assigned ports). const lsofOutput = running || assignedPorts.length > 0 ? await getLsofOutput() : ""; - const devTreePids = running ? await collectTaskPids(resolvedSocket, devSession) : new Set(); + const devTreePids = running + ? native + ? new Set(await collectNativeDevServerTreePids(task)) + : await collectTaskPids(resolvedSocket, devSession) + : new Set(); const devPorts = running && lsofOutput ? parseLsofOutput(lsofOutput, devTreePids) : []; // An assigned pool port bound by a PID outside the dev-server tree is a // conflict: either a foreign squatter, or (when stopped) a leftover that @@ -329,7 +372,10 @@ async function buildDevServerStatus(task: Task, projectId: string, hasDevScript: const ports = running ? await (async () => { const cached = getPortsForTask(task.id); - return cached.length > 0 ? cached : scanTaskPorts(resolvedSocket, taskSession, lsofOutput); + if (cached.length > 0) return cached; + // The fallback scan walks a tmux session; a native task has none, so its + // dev-port scan above is already the whole answer. + return native ? devPorts : scanTaskPorts(resolvedSocket, taskSession, lsofOutput); })() : []; const resourceUsage = running ? getResourceUsage(task.id) : undefined; @@ -341,8 +387,9 @@ async function buildDevServerStatus(task: Task, projectId: string, hasDevScript: hasDevScript, worktreePath: task.worktreePath ?? null, tmuxSocket: resolvedSocket, - taskSessionName: taskSession, - devSessionName: devSession, + taskSessionName: native ? "" : taskSession, + devSessionName: native ? "" : devSession, + backend: native ? "native" : "tmux", viewerPaneId, panePids, assignedPorts, @@ -920,9 +967,8 @@ export function cleanupTaskTmuxState(taskId: string): void { export async function runDevServer(params: { taskId: string; projectId: string }): Promise { log.info("→ runDevServer", params); - // The dev server lives in a tmux session with an attached viewer pane; the - // wrapper below is bash and the viewer is a tmux re-attach loop. - assertPosixLaunchDialect("the dev-server tmux session"); + // Both backends run the same bash wrapper; only its host differs. + assertPosixLaunchDialect("the dev-server pane"); try { const project = await data.getProject(params.projectId); const task = await data.getTask(project, params.taskId); @@ -931,11 +977,12 @@ export async function runDevServer(params: { taskId: string; projectId: string } if (!resolved.devScript.trim()) throw new Error("No dev script configured"); if (!task.worktreePath) throw new Error("Task has no worktree"); + const native = taskTerminalBackendIdentity(task) === "native"; const devSession = devServerSessionName(task.id); const devScriptPath = dev3TaskTempPath(task.id, "dev.sh"); const socket = task.tmuxSocket ?? DEFAULT_TMUX_SOCKET; - if (await isDevServerRunning(task.id, socket)) { + if (await isDevServerRunning(task, socket)) { if (isSelfHostedByTask(task.id)) { throw new Error( "The running dev server hosts the dev3 app instance serving this request " @@ -944,7 +991,7 @@ export async function runDevServer(params: { taskId: string; projectId: string } + "\"dev3 dev-server stop\" first and then \"dev3 dev-server start\".", ); } - await killDevServerSession(task.id, socket, task.worktreePath); + await killDevServerSession(task, socket, task.worktreePath); } // Ensure pool ports exist for this task before launching. allocatePorts is @@ -1001,14 +1048,38 @@ export async function runDevServer(params: { taskId: string; projectId: string } ` echo "Process exited with code $EXIT_CODE. Press any key to close."`, ` read -n 1 -s`, `fi`, - `# Detach the outer viewer pane before this pane closes so inner tmux redraws`, - `# without a watching client — prevents escape sequence corruption in outer tmux.`, - `# Use the app-resolved binary: a PATH tmux of a different version cannot`, - `# talk to this server at all ("server exited unexpectedly").`, - `"${tmux.binaryPath()}" detach-client 2>/dev/null || true`, + // Detaching the outer viewer pane before this pane closes lets the inner + // tmux redraw without a watching client — it prevents escape-sequence + // corruption in the outer tmux. A native pane has no nesting and no + // tmux binary to call, so the line is tmux-only. + // Use the app-resolved binary: a PATH tmux of a different version cannot + // talk to this server at all ("server exited unexpectedly"). + ...(native ? [] : [`"${tmux.binaryPath()}" detach-client 2>/dev/null || true`]), ].join("\n") + "\n"; await writeLaunchScript(devScriptPath, wrappedScript); + // A native task has no tmux anything. The dev script runs directly in a + // real auxiliary pane of the task's own terminal: that pane IS the dev + // server, so its output is live, closing it stops the server, and a second + // viewer of the same task sees the same pane. The seam replaces any pane + // this task already owns, so repeated starts never stack two. + if (native) { + const handle = await openAuxPane({ + task, + purpose: "devServer", + placement: "right", + size: "50%", + cwd: task.worktreePath, + env: { DEV3_TASK_ID: task.id, DEV3_WORKTREE_ROOT: task.worktreePath }, + socket, + title: auxPaneTitle("devServer"), + tmuxCommand: `bash "${devScriptPath}"`, + nativeLaunch: { executable: "/bin/bash", argv: [devScriptPath] }, + }); + log.info("← runDevServer done (native pane)", { paneId: handle.paneId }); + return buildDevServerStatus(task, project.id, !!resolved.devScript.trim(), socket); + } + try { // Client cwd is pinned inside newSessionDetached — never a mortal // worktree, or a tmux server started by this client keeps it forever. @@ -1085,7 +1156,7 @@ async function checkDevServer(params: { taskId: string; projectId: string }): Pr const project = await data.getProject(params.projectId); const task = await data.getTask(project, params.taskId); const socket = task.tmuxSocket ?? DEFAULT_TMUX_SOCKET; - const running = await isDevServerRunning(task.id, socket); + const running = await isDevServerRunning(task, socket); log.info("← checkDevServer", { running }); return { running }; } catch { @@ -1101,6 +1172,12 @@ export async function stopDevServer(params: { taskId: string; projectId: string const resolved = await resolveOperationalProjectConfig(project, task.worktreePath ?? undefined); const socket = task.tmuxSocket ?? DEFAULT_TMUX_SOCKET; const taskSession = taskSessionName(task.id); + const native = taskTerminalBackendIdentity(task) === "native"; + // The pane border only exists to title the tmux viewer split. + const clearPaneBorder = () => + native + ? Promise.resolve() + : tmux.setOption(taskSession, "pane-border-status", "off", { socket }); if (isSelfHostedByTask(task.id)) { // Tearing the session down now would reap this very process before the @@ -1112,15 +1189,15 @@ export async function stopDevServer(params: { taskId: string; projectId: string }); const status = await buildDevServerStatus(task, project.id, !!resolved.devScript.trim(), socket); setTimeout(() => { - killDevServerSession(task.id, socket, task.worktreePath) - .then(() => tmux.setOption(taskSession, "pane-border-status", "off", { socket })) + killDevServerSession(task, socket, task.worktreePath) + .then(clearPaneBorder) .catch((err) => log.error("Deferred self-hosted dev-server teardown failed", { error: String(err) })); }, SELF_HOSTED_STOP_ACK_MS); return { ...status, running: false, viewerPaneId: null, panePids: [], devPorts: [], resourceUsage: undefined }; } - await killDevServerSession(task.id, socket, task.worktreePath); - tmux.setOption(taskSession, "pane-border-status", "off", { socket }).catch(() => {}); + await killDevServerSession(task, socket, task.worktreePath); + clearPaneBorder().catch(() => {}); log.info("← stopDevServer done"); return buildDevServerStatus(task, project.id, !!resolved.devScript.trim(), socket); } catch (err) { @@ -2217,12 +2294,14 @@ async function exitCopyModeAllPanes(params: { taskId: string }): Promise<{ panes const devSession = devServerSessionName(params.taskId); // dev-server lives in a separate tmux session (dev3-dev-) — the user's - // scroll-mode is typically there, not in the agent session. Hit both. + // scroll-mode is typically there, not in the agent session. Hit both. Copy + // mode is a tmux concept, so a native task has neither session to visit and + // this whole handler is a no-op for it. const sessions: string[] = []; if (await pty.tmuxSessionExists(params.taskId, socket)) { sessions.push(taskSession); } - if (await isDevServerRunning(params.taskId, socket)) { + if (await tmux.hasSession(devSession, { socket })) { sessions.push(devSession); } diff --git a/src/bun/task-aux-panes.ts b/src/bun/task-aux-panes.ts new file mode 100644 index 000000000..ea6e95c40 --- /dev/null +++ b/src/bun/task-aux-panes.ts @@ -0,0 +1,249 @@ +/** + * Backend-neutral ownership of AUXILIARY task panes (seq 1376). + * + * An auxiliary pane is a visible pane in the task's own terminal that one action + * owns while it runs: the dev-server output, a git operation. Before this module + * every such pane was a raw `tmux split-window` against `dev3-task-`, so on a + * native task the split hit a session that does not exist — the git panes threw, + * and the dev-server pane failed inside a best-effort catch, leaving the dev + * script running invisibly. See the audit note on task 987a4829. + * + * OWNERSHIP IS DERIVED, NOT REMEMBERED. A pane is re-found by the command it was + * launched with, exactly as the tmux code has always re-found its own panes with + * `#{pane_start_command}`. Nothing is cached in RAM (which an app restart would + * lose while the pane lives on) and nothing new is written under `~/.dev3.0/`. + * + * The caller supplies what each backend runs, because the two are not always the + * same program: the tmux dev-server pane runs a re-attach loop into a nested + * session, while the native pane runs the dev script itself. Everything around + * that — placement, dedup, focus safety, labels — lives here. + * + * The native path NEVER calls tmux, and the tmux path is byte-identical to what + * it did before. + */ + +import type { Task } from "../shared/types"; +import type { TaskPaneBackendKind } from "../shared/task-panes"; +import type { SplitOrientation } from "../shared/split-tree"; +import { taskTerminalBackendIdentity } from "./task-terminal-backend"; +import type { TerminalLaunchSpec } from "./task-terminal-backend"; +import { tmux, taskSessionName, TmuxError, PANE_START_COMMAND_FORMAT } from "./tmux"; +import { + closeNativeTaskPane, + focusNativeTaskPane, + nativeTaskPaneCommands, + nativeTaskPanesState, + splitNativeTaskPane, +} from "./native-task-panes"; +import { dev3TaskTempPath } from "./temp-paths"; +import { createLogger } from "./logger"; + +const log = createLogger("task-aux-panes"); + +/** Which action owns the pane. One live pane per purpose per task, at most. */ +export type AuxPanePurpose = "devServer" | "gitOp"; + +/** Where the new pane lands relative to the pane it splits off. */ +export type AuxPanePlacement = "right" | "below"; + +export interface AuxPaneHandle { + backend: TaskPaneBackendKind; + paneId: string; +} + +export interface OpenAuxPaneSpec { + task: Task; + purpose: AuxPanePurpose; + placement: AuxPanePlacement; + /** tmux-only pane size (e.g. "50%"); the native SplitTree always splits evenly. */ + size: string; + cwd: string; + env?: Record; + socket: string; + /** English pane title; tmux sets it on the pane, native derives it back from the command. */ + title?: string; + /** What each backend runs. Often the same script, but not always. */ + tmuxCommand: string; + nativeLaunch: TerminalLaunchSpec; +} + +/** + * A pane was asked for on a backend that cannot provide one right now. Callers + * turn this into a disabled control with a reason — never into a silent no-op, + * and never into a tmux fallback. + */ +export class AuxPaneUnavailableError extends Error { + constructor(readonly reason: "terminal-not-running") { + super("the task terminal is not running, so it has no pane to split"); + this.name = "AuxPaneUnavailableError"; + } +} + +function backendOf(task: Task): TaskPaneBackendKind { + return taskTerminalBackendIdentity(task); +} + +/** + * The substring that identifies a purpose's pane in a launch command. Both + * backends launch a script under the task's temp prefix, so the prefix alone is + * a stable, per-task, per-purpose marker. + */ +export function auxPaneMarker(taskId: string, purpose: AuxPanePurpose): string { + return purpose === "devServer" + ? dev3TaskTempPath(taskId, "dev.sh") + : `${dev3TaskTempPath(taskId, "git-")}`; +} + +/** The English label shown for an auxiliary pane in the pager and pane picker. */ +export function auxPaneTitle(purpose: AuxPanePurpose): string { + return purpose === "devServer" ? "Dev Server" : "Git"; +} + +/** + * The purpose a native pane's launch command belongs to, or null for an ordinary + * pane. Used to label native panes without storing anything. + */ +export function auxPurposeOfCommand(taskId: string, command: string[]): AuxPanePurpose | null { + const joined = command.join(" "); + if (joined.includes(auxPaneMarker(taskId, "devServer"))) return "devServer"; + if (joined.includes(auxPaneMarker(taskId, "gitOp"))) return "gitOp"; + return null; +} + +/** + * Both backends spell the split the same way: `horizontal` puts the new pane to + * the right, `vertical` puts it below (tmux `-h`/`-v`, SplitTree orientation). + */ +function orientationFor(placement: AuxPanePlacement): SplitOrientation { + return placement === "right" ? "horizontal" : "vertical"; +} + +// ── Finding an existing pane ────────────────────────────────────────────────── + +async function findTmuxAuxPane(task: Task, purpose: AuxPanePurpose, socket: string): Promise { + const marker = auxPaneMarker(task.id, purpose); + try { + const rows = await tmux.listPanes(PANE_START_COMMAND_FORMAT, { target: taskSessionName(task.id), socket }); + return rows.find((row) => row.startCommand.includes(marker))?.paneId ?? null; + } catch (err) { + if (err instanceof TmuxError) return null; + throw err; + } +} + +async function findNativeAuxPane(task: Task, purpose: AuxPanePurpose): Promise<{ paneId: string; shellPid: number; alive: boolean } | null> { + const marker = auxPaneMarker(task.id, purpose); + const panes = await nativeTaskPaneCommands(task.id); + const found = panes.find((pane) => pane.command.join(" ").includes(marker)); + return found ? { paneId: found.paneId, shellPid: found.shellPid, alive: found.alive } : null; +} + +/** The pane this purpose currently owns, or null. */ +export async function findAuxPane(task: Task, purpose: AuxPanePurpose, socket: string): Promise { + if (backendOf(task) === "native") { + const found = await findNativeAuxPane(task, purpose); + return found ? { backend: "native", paneId: found.paneId } : null; + } + const paneId = await findTmuxAuxPane(task, purpose, socket); + return paneId ? { backend: "tmux", paneId } : null; +} + +/** + * True when the purpose owns a pane whose process is still running. A native + * pane whose command exited lingers as a dead pane showing its last output — + * visible, but not alive. + */ +export async function auxPaneAlive(task: Task, purpose: AuxPanePurpose, socket: string): Promise { + if (backendOf(task) === "native") { + const found = await findNativeAuxPane(task, purpose); + return found?.alive === true; + } + return (await findTmuxAuxPane(task, purpose, socket)) !== null; +} + +/** The pid of the process running in the purpose's native pane, or null. */ +export async function nativeAuxPaneShellPid(task: Task, purpose: AuxPanePurpose): Promise { + const found = await findNativeAuxPane(task, purpose); + return found && found.alive ? found.shellPid : null; +} + +// ── Opening and closing ─────────────────────────────────────────────────────── + +/** + * Close whatever pane this purpose owns. Idempotent, and best-effort by design: + * a pane that is already gone is the desired end state, not an error. + */ +export async function closeAuxPane(task: Task, purpose: AuxPanePurpose, socket: string): Promise { + const handle = await findAuxPane(task, purpose, socket); + if (!handle) return; + if (handle.backend === "native") { + await closeNativeTaskPane(task.id, handle.paneId).catch((err) => + log.warn("closeAuxPane: native pane close failed", { taskId: task.id.slice(0, 8), purpose, error: String(err) }), + ); + } else { + await tmux.killPane(handle.paneId, { socket, bestEffort: true }); + } + log.info("Closed auxiliary pane", { taskId: task.id.slice(0, 8), purpose, backend: handle.backend, paneId: handle.paneId }); +} + +/** + * Open the purpose's pane, replacing any pane it already owns so a repeated + * click can never stack a second one (this also sweeps a native pane left dead + * by a previous run). + * + * On native, focus is handed back to the pane that had it — a new pane becomes + * the coordinator's active pane on split, and the agent must not lose input just + * because a dev server started. + */ +export async function openAuxPane(spec: OpenAuxPaneSpec): Promise { + const { task, purpose, placement, size, cwd, env, socket, title } = spec; + await closeAuxPane(task, purpose, socket); + + if (backendOf(task) === "native") { + const state = await nativeTaskPanesState(task.id); + if (!state || state.panes.length === 0) throw new AuxPaneUnavailableError("terminal-not-running"); + const anchor = state.activePaneId || state.panes[0].paneId; + const previouslyActive = state.activePaneId || null; + + const { paneId } = await splitNativeTaskPane(task.id, anchor, orientationFor(placement), { + cwd, + env: env ?? {}, + launch: spec.nativeLaunch, + }); + + if (previouslyActive && previouslyActive !== paneId) { + await focusNativeTaskPane(task.id, previouslyActive).catch((err) => + log.warn("openAuxPane: could not restore focus to the previous pane", { + taskId: task.id.slice(0, 8), + previouslyActive, + error: String(err), + }), + ); + } + log.info("Opened native auxiliary pane", { taskId: task.id.slice(0, 8), purpose, paneId, anchor }); + return { backend: "native", paneId }; + } + + try { + const { paneId, stderr } = await tmux.splitWindow({ + target: taskSessionName(task.id), + orientation: orientationFor(placement), + size, + printPaneId: true, + env, + cwd, + command: spec.tmuxCommand, + socket, + }); + if (stderr.trim()) log.warn("openAuxPane tmux stderr", { stderr: stderr.trim() }); + if (paneId && title) { + tmux.selectPane(paneId, { socket, title }).catch(() => {}); + } + log.info("Opened tmux auxiliary pane", { taskId: task.id.slice(0, 8), purpose, paneId }); + return { backend: "tmux", paneId: paneId ?? "" }; + } catch (err) { + if (!(err instanceof TmuxError)) throw err; + if (err.stderr) log.warn("openAuxPane tmux stderr", { stderr: err.stderr }); + throw new Error(`tmux split-window failed (exit ${err.exitCode}): ${err.stderr || "unknown error"}`); + } +} diff --git a/src/bun/terminal-backend/native-backend.ts b/src/bun/terminal-backend/native-backend.ts index 95b2f2a71..327c4a843 100644 --- a/src/bun/terminal-backend/native-backend.ts +++ b/src/bun/terminal-backend/native-backend.ts @@ -246,6 +246,13 @@ export class NativeTerminalBackend implements TerminalBackend { return coordinator.layout; } + /** Type into one pane without attaching a view (agent hand-off prompts). */ + async writePane(id: TerminalSessionId, viewId: TerminalViewId, data: string): Promise { + const coordinator = await this.getOrRecover(id); + if (!coordinator) throw sessionNotFound(id); + await this.guard("writePane", id, () => coordinator.writePane(viewId, data)); + } + /** Publish a geometry-only layout change via the coordinator. */ async publishPaneGeometry(id: TerminalSessionId, tree: SplitTree): Promise { const coordinator = await this.getOrRecover(id); diff --git a/src/cli/__tests__/dev-server.test.ts b/src/cli/__tests__/dev-server.test.ts index f6943d08b..a8d6bf7c5 100644 --- a/src/cli/__tests__/dev-server.test.ts +++ b/src/cli/__tests__/dev-server.test.ts @@ -40,6 +40,7 @@ const STATUS: DevServerStatus = { hasDevScript: true, worktreePath: "/tmp/worktrees/proj/aaaaaaaa/worktree", tmuxSocket: "dev3", + backend: "tmux", taskSessionName: "dev3-aaaaaaaa", devSessionName: "dev3-dev-aaaaaaaa", viewerPaneId: "%17", @@ -121,6 +122,7 @@ describe("dev-server status", () => { hasDevScript: true, worktreePath: "/tmp/worktrees/proj/aaaaaaaa/worktree", tmuxSocket: "dev3", + backend: "tmux" as const, taskSessionName: "dev3-aaaaaaaa", devSessionName: "dev3-dev-aaaaaaaa", viewerPaneId: null, diff --git a/src/cli/commands/dev-server.ts b/src/cli/commands/dev-server.ts index 9a3277863..6340d2fac 100644 --- a/src/cli/commands/dev-server.ts +++ b/src/cli/commands/dev-server.ts @@ -103,12 +103,16 @@ function printStatusLine(action: string, status: DevServerStatus): void { } function printStatusDetails(status: DevServerStatus): void { + // A native task hosts the dev server in a pane of its own terminal, so it has + // no tmux session and no socket to report. + const native = status.backend === "native"; const fields: Array<[string, string]> = [ ["State:", status.tmuxError ? "unknown (tmux unavailable)" : status.running ? "running" : "stopped"], ["Task:", status.taskId.slice(0, 8)], - ["Session:", status.devSessionName], - ["Viewer Pane:", status.viewerPaneId ?? "(none)"], - ["Socket:", status.tmuxSocket], + ["Backend:", status.backend], + ...(native ? [] : [["Session:", status.devSessionName] as [string, string]]), + ["Pane:", status.viewerPaneId ?? "(none)"], + ...(native ? [] : [["Socket:", status.tmuxSocket] as [string, string]]), ["Worktree:", status.worktreePath ?? "(none)"], ["Pane PIDs:", formatPids(status)], ["Assigned Ports:", formatAssignedPorts(status)], diff --git a/src/mainview/components/__tests__/ClosePanePicker.test.tsx b/src/mainview/components/__tests__/ClosePanePicker.test.tsx index 037051e98..77966cd94 100644 --- a/src/mainview/components/__tests__/ClosePanePicker.test.tsx +++ b/src/mainview/components/__tests__/ClosePanePicker.test.tsx @@ -247,6 +247,17 @@ describe("ClosePanePicker (native backend)", () => { expect(toast.error).not.toHaveBeenCalled(); }); + it("names an app-owned auxiliary pane instead of the Pane N fallback", async () => { + vi.mocked(api.request.taskPaneState).mockResolvedValue({ + ...NATIVE_STATE, + panes: [NATIVE_STATE.panes[0], { ...NATIVE_STATE.panes[1], label: "Dev Server" }], + }); + renderPicker(); + startClosePanePicker("task-1"); + await waitFor(() => expect(screen.getByLabelText("Close Dev Server")).toBeInTheDocument()); + expect(screen.queryByLabelText("Close Pane 2")).toBeNull(); + }); + it("draws a single full-cover box while a native pane is zoomed", async () => { vi.mocked(api.request.taskPaneState).mockResolvedValue({ ...NATIVE_STATE, zoomedPaneId: "pane-2" }); renderPicker(); diff --git a/src/mainview/components/__tests__/MobilePaneCarousel.test.tsx b/src/mainview/components/__tests__/MobilePaneCarousel.test.tsx index f188ad62b..7fe31d6b6 100644 --- a/src/mainview/components/__tests__/MobilePaneCarousel.test.tsx +++ b/src/mainview/components/__tests__/MobilePaneCarousel.test.tsx @@ -118,6 +118,15 @@ describe("MobilePaneCarousel", () => { }))); }); + it("names an app-owned auxiliary pane instead of the Pane N fallback", async () => { + vi.mocked(api.request.taskPaneState).mockResolvedValue(makeState(2, 1, true, ["claude", "Dev Server"])); + renderCarousel(); + await waitFor(() => expect(screen.getByLabelText("Switch pane")).toBeInTheDocument()); + + expect(screen.getByLabelText("Switch pane")).toHaveTextContent("2. Dev Server"); + expect(screen.queryByText(/Pane 2/)).not.toBeInTheDocument(); + }); + it("the pane overview button opens a spatial map that jumps by pane id", async () => { // PaneMapSheet now calls taskPaneState (not tmuxLayout) vi.mocked(api.request.taskPaneState).mockResolvedValue(makeState(2, 0, true, ["claude", "bash"])); diff --git a/src/mainview/components/__tests__/TaskInfoPanel.test.tsx b/src/mainview/components/__tests__/TaskInfoPanel.test.tsx index 543d8c11b..82d39ca86 100644 --- a/src/mainview/components/__tests__/TaskInfoPanel.test.tsx +++ b/src/mainview/components/__tests__/TaskInfoPanel.test.tsx @@ -180,6 +180,7 @@ const defaultDevServerStatus: DevServerStatus = { hasDevScript: true, worktreePath: "/tmp/wt/t1", tmuxSocket: "dev3", + backend: "tmux", taskSessionName: "dev3-t1", devSessionName: "dev3-dev-t1", viewerPaneId: "%17", @@ -1069,6 +1070,28 @@ describe("TaskInfoPanel", () => { }); }); + it("renders the running state for a native-backend dev server", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + mockedApi.request.checkDevServer.mockResolvedValue({ running: false }); + mockedApi.request.runDevServer.mockResolvedValue({ + ...defaultDevServerStatus, + backend: "native", + taskSessionName: "", + devSessionName: "", + viewerPaneId: "pane-3", + }); + + await act(async () => { + renderPanel(makeTask(), { project: { ...project, devScript: "bun run dev" } }); + }); + + await user.click(screen.getAllByText("Dev Server")[0].closest("button")!); + + await waitFor(() => + expect(screen.getByLabelText("Dev server running — click for options")).toBeInTheDocument(), + ); + }); + it("shows running menu when dev server is already running", async () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); mockedApi.request.checkDevServer.mockResolvedValue({ running: true }); diff --git a/src/shared/types.ts b/src/shared/types.ts index dbb2803b3..675f7086a 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -3,7 +3,7 @@ import type { ConversationMatch } from "./conversation-search-core"; import type { AgentRateLimitsReport } from "./rate-limits"; import type { AgentAccount, AgentAccountKind, AgentAccountsState, ClaudeSlotModels } from "./agent-accounts"; import type { TerminalBackendIdentity } from "./terminal-backend-identity"; -import type { TaskPaneState, TaskPaneAction } from "./task-panes"; +import type { TaskPaneState, TaskPaneAction, TaskPaneBackendKind } from "./task-panes"; // ---- Changelog ---- @@ -2531,8 +2531,12 @@ export interface DevServerStatus { hasDevScript: boolean; worktreePath: string | null; tmuxSocket: string; + /** tmux only — empty on a native task, which has no tmux session of any kind. */ taskSessionName: string; + /** tmux only — empty on a native task, whose dev server runs in its pane. */ devSessionName: string; + /** Which terminal backend hosts this dev server. */ + backend: TaskPaneBackendKind; viewerPaneId: string | null; panePids: number[]; assignedPorts: number[];