/** * @file pty-run.test.js * @description Unit tests for the tmux-backed run lifecycle. Injects a fake * tmux exec implementation (via tmux.js's test seam) so no real tmux binary * is invoked. * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it, beforeEach, before, after } = require("node:test"); const assert = require("node:assert/strict"); const path = require("node:path"); const fs = require("node:fs"); const os = require("node:os"); const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "pty-run-test-")); process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db"); const tmux = require("../lib/tmux"); const pty = require("../lib/pty-run"); describe("pty-run", () => { after(() => { try { fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } catch { /* best-effort */ } }); beforeEach(() => { tmux.__reset(); }); it("spawnRun creates a new tmux session named agenthub-lane- when none exists", () => { const calls = []; tmux.__setExecImpl((args) => { calls.push(args); if (args[0] === "has-session") { const e = new Error("no such session"); e.status = 1; throw e; } return ""; }); const handle = pty.spawnRun({ laneId: 42, cwd: "/tmp/repo", model: "opus" }); assert.equal(handle.id, "agenthub-lane-42"); const newSessionCall = calls.find((c) => c[0] === "new-session"); assert.ok(newSessionCall, "expected a new-session call"); assert.deepEqual(newSessionCall.slice(0, 6), [ "new-session", "-d", "-s", "agenthub-lane-42", "-c", "/tmp/repo", ]); assert.ok(newSessionCall.includes("claude")); assert.ok(newSessionCall.includes("--model")); assert.ok(newSessionCall.includes("opus")); }); it("spawnShell starts the login shell only once the pane has its real size (issue #55)", async () => { const calls = []; tmux.__setExecImpl((args) => { calls.push(args); return ""; }); pty.spawnShell({ laneId: 43, cwd: "/tmp/repo" }); const call = calls.find((c) => c[0] === "new-session"); const argv = call.slice(call.indexOf("--") + 1); assert.equal(argv[0], "sh"); assert.equal(argv[1], "-c"); // Run the wrapper with a stand-in shell and a fake `tmux` that reports no // client until a flag file appears: the shell must wait for the attach, // then start at once rather than at the 2 s cap. const { spawn } = require("node:child_process"); const bin = fs.mkdtempSync(path.join(os.tmpdir(), "fake-tmux-")); const flag = path.join(bin, "attached"); fs.writeFileSync( path.join(bin, "tmux"), `#!/bin/sh\n[ -e '${flag}' ] && echo 1 || echo 0\n`, { mode: 0o755 } ); try { const t0 = Date.now(); const child = spawn("sh", ["-c", argv[2]], { env: { ...process.env, SHELL: "/bin/echo", PATH: `${bin}:${process.env.PATH}` }, }); let out = ""; let startedAt = 0; child.stdout.on("data", (d) => { out += d; startedAt ||= Date.now() - t0; }); setTimeout(() => fs.writeFileSync(flag, ""), 400); await new Promise((r) => child.on("exit", r)); assert.equal(out.trim(), "-l"); assert.ok(startedAt >= 400 && startedAt < 1500, `shell started after ${startedAt} ms`); } finally { fs.rmSync(bin, { recursive: true, force: true }); } }); it("spawnRun names a split pane's session agenthub-lane--, and refuses a slot past 9 (ten per workspace)", () => { tmux.__setExecImpl((args) => { if (args[0] === "has-session") { const e = new Error("no such session"); e.status = 1; throw e; } return ""; }); const handle = pty.spawnRun({ laneId: 42, slot: 2, cwd: "/tmp/repo" }); assert.equal(handle.id, "agenthub-lane-42-2"); assert.equal(handle.laneId, 42); assert.equal(handle.slot, 2); assert.equal(pty.laneIdFromRunId("agenthub-lane-42-2"), 42); assert.equal(pty.spawnRun({ laneId: 42, slot: 9, cwd: "/tmp/repo" }).id, "agenthub-lane-42-9"); assert.equal(pty.laneIdFromRunId("agenthub-lane-42-9"), 42); assert.throws( () => pty.spawnRun({ laneId: 42, slot: 10, cwd: "/tmp/repo" }), (err) => err.code === "EBADSLOT" ); }); it("spawnRun refuses with ETMUXMISSING, naming the fix, when tmux is not installed", () => { // Before this guard the ENOENT escaped from three calls down as // `spawnSync tmux ENOENT` and the route answered 400 — blaming the request // for a missing host package, with no hint of what to install. const calls = []; tmux.__setExecImpl((args) => { calls.push(args); const e = new Error("spawnSync tmux ENOENT"); e.code = "ENOENT"; throw e; }); assert.throws( () => pty.spawnRun({ laneId: 42, cwd: "/tmp/repo" }), (err) => err.code === "ETMUXMISSING" && /install/i.test(err.message), "a missing tmux must be reported as such, with the install command" ); assert.ok( !calls.some((c) => c[0] === "new-session"), "must not attempt a session it already knows cannot be created" ); }); 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, "agenthub-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 runs the argv in 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 respawn = calls.find((c) => c[0] === "respawn-pane"); assert.ok(respawn, "expected the idle shell to be replaced by the argv"); const argv = respawn.slice(respawn.indexOf("--") + 1); assert.equal(argv[0], "claude"); assert.deepEqual(argv.slice(-2), ["--resume", "abc12345"]); }); it("spawnRun passes an initial prompt as one argv element, never a shell line", () => { // No shell is involved any more, so a prompt full of shell metacharacters // is just a string — but it must still arrive whole and unmangled. let respawn = null; tmux.__setExecImpl((args) => { if (args[0] === "display-message") return "zsh\n"; if (args[0] === "respawn-pane") respawn = args; return ""; }); pty.spawnRun({ laneId: 9, cwd: "/tmp/repo", initialPrompt: "don't; rm -rf /" }); assert.equal(respawn.at(-1), "don't; rm -rf /"); }); it("spawnRun puts AGENTHUB_LANE_ID in the new session's environment", () => { let newSessionArgv = null; tmux.__setExecImpl((args) => { if (args[0] === "has-session") { const e = new Error("gone"); e.status = 1; throw e; } if (args[0] === "new-session") newSessionArgv = args; return ""; }); pty.spawnRun({ laneId: 33, cwd: "/tmp/repo", tool: "codex" }); const at = newSessionArgv.indexOf("-e"); assert.ok(at > -1, "expected a tmux -e environment flag"); assert.equal(newSessionArgv[at + 1], "AGENTHUB_LANE_ID=33"); // Must stay ahead of the `--` that starts the pane's own argv. assert.ok(at < newSessionArgv.indexOf("--")); }); it("spawnRun passes AGENTHUB_LANE_ID through tmux -e when reusing a shell pane", () => { let respawn = null; tmux.__setExecImpl((args) => { if (args[0] === "display-message") return "bash\n"; if (args[0] === "respawn-pane") respawn = args; return ""; }); pty.spawnRun({ laneId: 12, cwd: "/tmp/repo", tool: "codex" }); // Through argv, never as a shell assignment typed into the pane: this is // also the path an AI Router's API key takes, and a typed line would be // rendered to every attached browser and land in the shell history. const at = respawn.indexOf("-e"); assert.ok(at > -1, "expected a tmux -e environment flag"); assert.equal(respawn[at + 1], "AGENTHUB_LANE_ID=12"); assert.ok(at < respawn.indexOf("--")); assert.equal(respawn.at(-1), "codex"); }); it("spawnRun with resumeSessionId passes --resume in argv", () => { let newSessionArgv = null; tmux.__setExecImpl((args) => { if (args[0] === "has-session") { const e = new Error("gone"); e.status = 1; throw e; } if (args[0] === "new-session") newSessionArgv = args; return ""; }); pty.spawnRun({ laneId: 1, cwd: "/tmp/repo", resumeSessionId: "abc12345" }); assert.ok(newSessionArgv.includes("--resume")); assert.ok(newSessionArgv.includes("abc12345")); }); it("spawnRun forks a resumed Claude session into a new id, and refuses a fork elsewhere", () => { let newSessionArgv = null; tmux.__setExecImpl((args) => { if (args[0] === "has-session") { const e = new Error("gone"); e.status = 1; throw e; } if (args[0] === "new-session") newSessionArgv = args; return ""; }); pty.spawnRun({ laneId: 31, cwd: "/tmp/repo", resumeSessionId: "abc12345", forkSession: true }); const at = newSessionArgv.indexOf("--resume"); assert.deepEqual(newSessionArgv.slice(at, at + 3), ["--resume", "abc12345", "--fork-session"]); // The pane holds the FORK, whose id only its first hook reports. const dashboardRuns = require("../lib/dashboard-runs"); assert.equal(dashboardRuns.getRun("agenthub-lane-31").session_id, null); assert.throws( () => pty.spawnRun({ laneId: 32, cwd: "/tmp/repo", forkSession: true }), (e) => e.code === "EBADSESSION" ); assert.throws( () => pty.spawnRun({ laneId: 33, cwd: "/tmp/repo", tool: "codex", resumeSessionId: "abc12345", forkSession: true, }), (e) => e.code === "EUNSUPPORTED" ); }); it("spawnRun starts a Claude session in its own worktree, Claude's way", () => { let newSessionArgv = null; tmux.__setExecImpl((args) => { if (args[0] === "has-session") { const e = new Error("gone"); e.status = 1; throw e; } if (args[0] === "new-session") newSessionArgv = args; return ""; }); const repo = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "pty-wt-"))); // Inside a git hook GIT_DIR names the repo being committed; drop it. const env = Object.fromEntries( Object.entries(process.env).filter(([k]) => !k.startsWith("GIT_")) ); require("node:child_process").execFileSync("git", ["init", "-q", repo], { env }); try { pty.spawnRun({ laneId: 41, cwd: repo, worktree: true, initialPrompt: "fix it" }); // `=` form: `-w [name]` takes an optional value and would eat the prompt. const flag = newSessionArgv.find((a) => a.startsWith("--worktree=")); assert.match(flag, /^--worktree=ah-41-[a-z0-9]+$/); assert.equal(newSessionArgv.at(-1), "fix it"); // Claude does not exclude its worktrees; the main repo would list them. const exclude = fs.readFileSync(path.join(repo, ".git", "info", "exclude"), "utf8"); assert.ok(exclude.split("\n").includes("/.claude/worktrees/"), exclude); const dashboardRuns = require("../lib/dashboard-runs"); assert.equal(dashboardRuns.getRun("agenthub-lane-41").worktree, 1); assert.throws( () => pty.spawnRun({ laneId: 42, cwd: repo, tool: "codex", worktree: true }), (e) => e.code === "EUNSUPPORTED" ); } finally { fs.rmSync(repo, { recursive: true, force: true }); } }); it("spawnRun starts a session on ITS router: Claude by --settings, Codex by -p", () => { let newSessionArgv = null; tmux.__setExecImpl((args) => { if (args[0] === "has-session") { const e = new Error("gone"); e.status = 1; throw e; } if (args[0] === "new-session") newSessionArgv = args; return ""; }); // The router store and the files a spawn writes live under these homes. const saved = ["CLAUDE_HOME", "CODEX_HOME", "DASHBOARD_DATA_DIR"].map((k) => [ k, process.env[k], ]); process.env.CLAUDE_HOME = path.join(TMP, "claude"); process.env.CODEX_HOME = path.join(TMP, "codex"); process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data"); try { const { name } = require("../lib/ai-routers").createRouter({ label: "Router", baseUrl: "http://localhost:20128/v1", apiKey: "sk-secret-value", model: "M", }); pty.spawnRun({ laneId: 51, cwd: "/tmp/repo", router: name, initialPrompt: "hi" }); const flag = newSessionArgv.find((a) => a.startsWith("--settings=")); assert.equal(flag, `--settings=${path.join(TMP, "data", "router-settings", `${name}.json`)}`); assert.ok(!newSessionArgv.join(" ").includes("sk-secret-value")); assert.equal(require("../lib/dashboard-runs").getRun("agenthub-lane-51").router, name); pty.spawnRun({ laneId: 52, cwd: "/tmp/repo", tool: "codex", router: name }); const at = newSessionArgv.indexOf("-p"); assert.deepEqual(newSessionArgv.slice(at, at + 2), ["-p", name]); // The key rides tmux's -e, never the command line. assert.ok(newSessionArgv.includes("AGENTHUB_ROUTER_KEY=sk-secret-value")); pty.spawnRun({ laneId: 53, cwd: "/tmp/repo" }); assert.ok(!newSessionArgv.some((a) => a.startsWith("--settings="))); assert.equal(require("../lib/dashboard-runs").getRun("agenthub-lane-53").router, null); assert.throws( () => pty.spawnRun({ laneId: 54, cwd: "/tmp/repo", router: "nope" }), (e) => e.code === "EBADROUTER" ); } finally { for (const [k, v] of saved) { if (v === undefined) delete process.env[k]; else process.env[k] = v; } } }); it("spawnRun offers bypass in the pane's shift+tab cycle without starting in it", () => { let newSessionArgv = null; tmux.__setExecImpl((args) => { if (args[0] === "has-session") { const e = new Error("gone"); e.status = 1; throw e; } if (args[0] === "new-session") newSessionArgv = args; return ""; }); pty.spawnRun({ laneId: 4, cwd: "/tmp/repo" }); assert.ok(newSessionArgv.includes("--allow-dangerously-skip-permissions")); // ...as an OPTION only: the starting mode stays the requested one, and // `--dangerously-skip-permissions` (which would enable it) is never passed. assert.ok(!newSessionArgv.includes("--dangerously-skip-permissions")); const modeAt = newSessionArgv.indexOf("--permission-mode"); // `auto` is the default when the caller named no mode: the CLI's own // classifier asks for what it reads as risky and stays out of the way // otherwise. assert.equal(newSessionArgv[modeAt + 1], "auto"); }); it("spawnRun appends a positional initial prompt after argv flags", () => { let newSessionArgv = null; tmux.__setExecImpl((args) => { if (args[0] === "has-session") { const e = new Error("gone"); e.status = 1; throw e; } if (args[0] === "new-session") newSessionArgv = args; return ""; }); pty.spawnRun({ laneId: 3, cwd: "/tmp/repo", initialPrompt: "fix the bug" }); assert.equal(newSessionArgv[newSessionArgv.length - 1], "fix the bug"); }); it("killRun calls tmux kill-session with the run id", () => { const calls = []; tmux.__setExecImpl((args) => { calls.push(args); return ""; }); assert.equal(pty.killRun("agenthub-lane-5"), true); assert.ok(calls.some((c) => c[0] === "kill-session" && c[2] === "=agenthub-lane-5")); }); it("never touches a tmux session that is not a lane pane", () => { const calls = []; tmux.__setExecImpl((args) => { calls.push(args); return ""; }); assert.equal(pty.killRun("main"), false); assert.equal(pty.getRun("main"), null); assert.deepEqual(calls, []); }); it("listRuns reflects live tmux-session state, not cached memory", () => { tmux.__setExecImpl((args) => { if (args[0] === "list-sessions") return "agenthub-lane-1\nagenthub-lane-2\n"; return ""; }); const first = pty.listRuns(); assert.deepEqual(first.map((r) => r.id).sort(), ["agenthub-lane-1", "agenthub-lane-2"]); // Session killed out-of-band (not through killRun) — next list() call // must self-correct, proving state is computed, not stored. tmux.__setExecImpl((args) => { if (args[0] === "list-sessions") return "agenthub-lane-1\n"; return ""; }); const second = pty.listRuns(); assert.deepEqual( second.map((r) => r.id), ["agenthub-lane-1"] ); }); it("laneIdFromRunId parses the numeric lane id back out", () => { assert.equal(pty.laneIdFromRunId("agenthub-lane-42"), 42); assert.equal(pty.laneIdFromRunId("not-a-run-id"), null); }); it("getRun returns the recorded prompt for a live run", () => { let sessionExists = false; tmux.__setExecImpl((args) => { if (args[0] === "has-session") { if (sessionExists) { return ""; // session exists } // Session doesn't exist yet const e = new Error("no such session"); e.status = 1; throw e; } if (args[0] === "new-session") { sessionExists = true; // Mark session as created } return ""; }); const handle = pty.spawnRun({ laneId: 99, cwd: "/tmp/test", initialPrompt: "the live prompt", }); const retrieved = pty.getRun(handle.id); assert.equal(retrieved.id, "agenthub-lane-99"); assert.equal(retrieved.promptPreview, "the live prompt"); assert.equal(retrieved.status, "running"); }); function captureNewSession(fn) { const calls = []; tmux.__setExecImpl((args) => { calls.push(args); if (args[0] === "has-session") { const e = new Error("no such session"); e.status = 1; throw e; } return ""; }); fn(); return calls.find((c) => c[0] === "new-session"); } /** The pane's own command line — everything tmux passes after the `--`. */ function paneArgv(newSessionCall) { return newSessionCall.slice(newSessionCall.indexOf("--") + 1); } it("spawnRun builds a codex argv for a codex lane", () => { const call = captureNewSession(() => pty.spawnRun({ laneId: 7, cwd: "/tmp/repo", tool: "codex", model: "gpt-5.1-codex", sandbox: "workspace-write", approval: "on-request", effort: "high", initialPrompt: "hello", }) ); const argv = paneArgv(call); assert.deepEqual(argv, [ "codex", "-m", "gpt-5.1-codex", "-s", "workspace-write", "-a", "on-request", "-c", "model_reasoning_effort=high", "hello", ]); }); it("spawnRun puts codex resume first, as a subcommand", () => { const withId = captureNewSession(() => pty.spawnRun({ laneId: 8, cwd: "/tmp/repo", tool: "codex", resumeSessionId: "0199aaaa-bbbb-cccc-dddd-eeeeffff0000", }) ); assert.deepEqual(paneArgv(withId).slice(0, 3), [ "codex", "resume", "0199aaaa-bbbb-cccc-dddd-eeeeffff0000", ]); tmux.__reset(); const last = captureNewSession(() => pty.spawnRun({ laneId: 9, cwd: "/tmp/repo", tool: "codex", resumeLast: true }) ); assert.deepEqual(paneArgv(last).slice(0, 3), ["codex", "resume", "--last"]); }); it("spawnRun rejects a codex sandbox/approval outside the allowlist", () => { tmux.__setExecImpl(() => ""); assert.throws( () => pty.spawnRun({ laneId: 10, cwd: "/tmp/repo", tool: "codex", sandbox: "rm -rf" }), (err) => err.code === "EBADSANDBOX" ); assert.throws( () => pty.spawnRun({ laneId: 10, cwd: "/tmp/repo", tool: "codex", approval: "always" }), (err) => err.code === "EBADAPPROVAL" ); }); it("spawnRun never emits codex bypass flags", () => { const call = captureNewSession(() => pty.spawnRun({ laneId: 11, cwd: "/tmp/repo", tool: "codex", sandbox: "danger-full-access" }) ); assert.ok(!call.some((a) => String(a).startsWith("--dangerously-bypass"))); }); it("hands a codex pane the dashboard's own CODEX_HOME, and a claude pane none", (t) => { const saved = process.env.CODEX_HOME; process.env.CODEX_HOME = "/tmp/custom-codex-home"; t.after(() => { if (saved === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = saved; }); const envFlags = (call) => call.filter((a, n) => call[n - 1] === "-e"); const codex = captureNewSession(() => pty.spawnRun({ laneId: 40, cwd: "/tmp/repo", tool: "codex" }) ); assert.ok(envFlags(codex).includes("CODEX_HOME=/tmp/custom-codex-home")); tmux.__reset(); const claude = captureNewSession(() => pty.spawnRun({ laneId: 41, cwd: "/tmp/repo" })); assert.ok(!envFlags(claude).some((a) => a.startsWith("CODEX_HOME="))); }); it("keeps a claude pane off the alternate screen, so tmux scrolls (and draws a scrollbar for) it", () => { const envFlags = (call) => call.filter((a, n) => call[n - 1] === "-e"); const claude = captureNewSession(() => pty.spawnRun({ laneId: 42, cwd: "/tmp/repo" })); assert.ok(envFlags(claude).includes("CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1")); tmux.__reset(); const codex = captureNewSession(() => pty.spawnRun({ laneId: 43, cwd: "/tmp/repo", tool: "codex" }) ); assert.ok(!envFlags(codex).some((a) => a.startsWith("CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN="))); }); describe("catalog tools", () => { const aiTools = require("../lib/ai-tools"); const BIN = path.join(TMP, "bin"); let savedPath; before(() => { fs.mkdirSync(BIN, { recursive: true }); for (const name of ["agy", "gemini", "grok", "goose", "command-code"]) { fs.writeFileSync(path.join(BIN, name), "#!/bin/sh\n"); fs.chmodSync(path.join(BIN, name), 0o755); } savedPath = process.env.PATH; process.env.PATH = `${BIN}:/usr/bin:/bin`; // The built-in catalog plus three test-only tools for the prompt modes // and bypassEnv no built-in tool uses any more (custom files may). aiTools.__setCatalogDirs([ path.join(__dirname, "..", "data", "ai-tools"), path.join(__dirname, "fixtures", "ai-tools"), ]); }); after(() => { process.env.PATH = savedPath; aiTools.__setCatalogDirs(null); }); const at = (name) => path.join(BIN, name); const envOf = (call, key) => { const i = call.findIndex((a, n) => call[n - 1] === "-e" && a.startsWith(`${key}=`)); return i < 0 ? undefined : call[i].slice(key.length + 1); }; it("starts antigravity by absolute path, with its model flag and none it has no use for", () => { const call = captureNewSession(() => pty.spawnRun({ laneId: 13, cwd: "/tmp/repo", tool: "antigravity", model: "opus", effort: "high", permissionMode: "bypassPermissions", initialPrompt: "hello", }) ); assert.deepEqual(paneArgv(call), [at("agy"), "--model", "opus", "-i", "hello"]); assert.equal(envOf(call, "PATH"), process.env.PATH); tmux.__reset(); const last = captureNewSession(() => pty.spawnRun({ laneId: 14, cwd: "/tmp/repo", tool: "antigravity", resumeLast: true, bypass: true, }) ); assert.deepEqual(paneArgv(last), [at("agy"), "--continue", "--dangerously-skip-permissions"]); }); // A catalog tool has no "resume THIS session" argv; starting it fresh under // a resume request would read as a resume that silently lost the history. it("refuses resumeSessionId for a catalog tool instead of starting fresh", () => { assert.throws( () => pty.spawnRun({ laneId: 15, cwd: "/tmp/repo", tool: "antigravity", resumeSessionId: "abc12345", }), { code: "EUNSUPPORTED" } ); }); it("refuses a flag-shaped model id for any tool, before tmux is asked", () => { for (const tool of ["claude", "codex", "antigravity"]) { assert.throws( () => pty.spawnRun({ laneId: 16, cwd: "/tmp/repo", tool, model: "--yolo" }), { code: "EBADMODEL" }, tool ); } }); it("builds each prompt mode, with fixed args first, a dash-led prompt never a flag", () => { const argvFor = (tool, extra = {}) => paneArgv( captureNewSession(() => pty.spawnRun({ laneId: 15, cwd: "/tmp/repo", tool, initialPrompt: "-x y", ...extra }) ) ); assert.deepEqual(argvFor("gemini"), [at("gemini"), "--prompt-interactive", " -x y"]); // Behind a `--` separator the prompt is already a positional, kept as typed. assert.deepEqual(argvFor("grok"), [at("grok"), "--", "-x y"]); assert.deepEqual(argvFor("command-code"), [at("command-code"), "--trust", " -x y"]); assert.deepEqual(argvFor("gemini", { bypass: false }), [ at("gemini"), "--prompt-interactive", " -x y", ]); }); it("pastes an after-start prompt later and adds bypassEnv only on bypass", (t) => { t.mock.timers.enable({ apis: ["setTimeout"] }); const calls = []; let live = false; tmux.__setExecImpl((args) => { calls.push(args); if (args[0] === "has-session" && !live) { const e = new Error("no such session"); e.status = 1; throw e; } return ""; }); pty.spawnRun({ laneId: 16, cwd: "/tmp/repo", tool: "goose", initialPrompt: "fix it", bypass: true, }); const ns = calls.find((c) => c[0] === "new-session"); assert.deepEqual(paneArgv(ns), [at("goose")]); assert.equal(envOf(ns, "GOOSE_MODE"), "auto"); assert.ok(!calls.some((c) => c[0] === "set-buffer")); live = true; t.mock.timers.tick(3000); assert.deepEqual( calls.find((c) => c[0] === "set-buffer"), ["set-buffer", "-b", "agenthub-paste-agenthub-lane-16", "--", "fix it"] ); assert.ok(calls.some((c) => c[0] === "paste-buffer")); calls.length = 0; live = false; pty.spawnRun({ laneId: 17, cwd: "/tmp/repo", tool: "goose" }); assert.equal( envOf( calls.find((c) => c[0] === "new-session"), "GOOSE_MODE" ), undefined ); }); it("skips the paste when the pane is gone", (t) => { t.mock.timers.enable({ apis: ["setTimeout"] }); const calls = []; tmux.__setExecImpl((args) => { calls.push(args); if (args[0] === "has-session") { const e = new Error("no such session"); e.status = 1; throw e; } return ""; }); pty.spawnRun({ laneId: 18, cwd: "/tmp/repo", tool: "goose", initialPrompt: "hi" }); t.mock.timers.tick(3000); assert.ok(!calls.some((c) => c[0] === "set-buffer")); }); it("codex bypass replaces sandbox/approval; claude ignores bypass", () => { const codex = captureNewSession(() => pty.spawnRun({ laneId: 19, cwd: "/tmp/repo", tool: "codex", sandbox: "read-only", approval: "never", bypass: true, }) ); assert.ok(paneArgv(codex).includes("--dangerously-bypass-approvals-and-sandbox")); assert.ok(!paneArgv(codex).includes("-s")); tmux.__reset(); const claude = captureNewSession(() => pty.spawnRun({ laneId: 20, cwd: "/tmp/repo", bypass: true }) ); assert.ok(!paneArgv(claude).includes("--dangerously-skip-permissions")); }); it("refuses a tool the catalog does not know", () => { tmux.__setExecImpl(() => ""); assert.throws(() => pty.spawnRun({ laneId: 21, cwd: "/tmp/repo", tool: "nope" }), { code: "ENOTOOL", }); }); }); // `dashboard_runs.mode` is NOT NULL and recordRun swallows insert errors, so a // null there persisted nothing at all while every call still looked fine: the // runs modal rendered a row with no cwd, model, prompt or start time, and a // resumed session kept its separate `external` row because no run row ever // carried its session id. These assert the row actually lands. it("refuses to swallow a resume or a prompt when the pane already runs an agent", () => { // Adopting silently here answered 200 while spawning no `--resume` at all: // the dashboard reported a resumed session that never happened. tmux.__setExecImpl((args) => { if (args[0] === "display-message") return "claude\n"; return ""; // has-session succeeds → pane is busy with a live agent }); assert.throws( () => pty.spawnRun({ laneId: 51, cwd: "/tmp/repo", resumeSessionId: "abc12345" }), (err) => err.code === "ERUNLIVE" ); assert.throws( () => pty.spawnRun({ laneId: 51, cwd: "/tmp/repo", initialPrompt: "do the thing" }), (err) => err.code === "ERUNLIVE" ); }); it("still adopts a busy pane when the request asked for nothing but a pane", () => { const calls = []; tmux.__setExecImpl((args) => { calls.push(args); if (args[0] === "display-message") return "claude\n"; return ""; }); const handle = pty.spawnRun({ laneId: 52, cwd: "/tmp/repo" }); assert.equal(handle.id, "agenthub-lane-52"); assert.ok(!calls.some((c) => c[0] === "new-session")); assert.ok(!calls.some((c) => c[0] === "send-keys")); }); describe("run persistence", () => { const dashboardRuns = require("../lib/dashboard-runs"); it("spawnRun persists the run so the runs list has cwd, model and prompt", () => { tmux.__setExecImpl((args) => { if (args[0] === "has-session") { const e = new Error("gone"); e.status = 1; throw e; } return ""; }); pty.spawnRun({ laneId: 71, cwd: "/tmp/persisted", model: "claude-opus-5", initialPrompt: "ship it", }); const row = dashboardRuns.getRun("agenthub-lane-71"); assert.ok(row, "expected a dashboard_runs row for the spawned run"); assert.equal(row.cwd, "/tmp/persisted"); assert.equal(row.model, "claude-opus-5"); assert.equal(row.prompt_preview, "ship it"); assert.equal(row.lane_id, 71); assert.ok(row.started_at, "started_at must be set, or the UI dates the row to the epoch"); assert.ok(row.mode, "mode is NOT NULL — a null here silently drops the whole insert"); }); it("a resumed run carries the session id, which is what dedupes the external row", () => { tmux.__setExecImpl((args) => { if (args[0] === "has-session") { const e = new Error("gone"); e.status = 1; throw e; } return ""; }); const sessionId = "c2055094-09cf-4735-9712-09e5cdf7023f"; const handle = pty.spawnRun({ laneId: 72, cwd: "/tmp/resumed", resumeSessionId: sessionId, }); // The UI reads sessionId off the run list; an external session whose id is // already on a run row is not repeated as its own `external` row. assert.equal(handle.sessionId, sessionId); assert.equal(dashboardRuns.getRun("agenthub-lane-72").resume_session_id, sessionId); }); }); });