fcf8d8010a
- Windows panes: a WinGet tmux runs native programs and there is no `sh`, so
every pane's `sh -c` wrapper died at once ("Command failed: tmux set-option
… mouse on"). paneArgv runs the CLI itself on win32, and a Terminal gets
tmux's own default shell (newSession drops the `--` for an empty argv).
- Plugin UI build: client deps install with --ignore-scripts and tsc/vite run
by the running node instead of `npm run build`. On Windows esbuild's
postinstall went through cmd.exe with no `node` on the server's PATH, both
installs failed, and Update now kept the previous UI. A test keeps the two
build steps equal to client/package.json's build script.
- Update all: Atomics.wait instead of a `sleep` binary Windows lacks.
912 lines
33 KiB
JavaScript
912 lines
33 KiB
JavaScript
/**
|
|
* @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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
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-<id> 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-<id>-<slot>, 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 Proxy'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 proxy: 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 proxy 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-proxies").createProxy({
|
|
label: "Proxy",
|
|
baseUrl: "http://localhost:20128/v1",
|
|
apiKey: "sk-secret-value",
|
|
model: "M",
|
|
});
|
|
pty.spawnRun({ laneId: 51, cwd: "/tmp/repo", proxy: name, initialPrompt: "hi" });
|
|
const flag = newSessionArgv.find((a) => a.startsWith("--settings="));
|
|
assert.equal(flag, `--settings=${path.join(TMP, "data", "proxy-settings", `${name}.json`)}`);
|
|
assert.ok(!newSessionArgv.join(" ").includes("sk-secret-value"));
|
|
assert.equal(require("../lib/dashboard-runs").getRun("agenthub-lane-51").proxy, name);
|
|
|
|
pty.spawnRun({ laneId: 52, cwd: "/tmp/repo", tool: "codex", proxy: 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_PROXY_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").proxy, null);
|
|
assert.throws(
|
|
() => pty.spawnRun({ laneId: 54, cwd: "/tmp/repo", proxy: "nope" }),
|
|
(e) => e.code === "EBADPROXY"
|
|
);
|
|
} 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 CLI's own command line: what tmux passes after the `--`, less the
|
|
* `sh -c '<wait for a client>; exec "$@"' sh` wrapper spawnRun puts first. */
|
|
function paneArgv(newSessionCall) {
|
|
const argv = newSessionCall.slice(newSessionCall.indexOf("--") + 1);
|
|
assert.deepEqual([argv[0], argv[1], argv[3]], ["sh", "-c", "sh"]);
|
|
assert.match(argv[2], /session_attached.*exec "\$@"$/);
|
|
return argv.slice(4);
|
|
}
|
|
|
|
it("runs a Windows pane's command itself — no sh there — and a Terminal as tmux's default shell", () => {
|
|
const argv = ["C:\\Users\\u\\.local\\bin\\claude.exe", "--model", "opus"];
|
|
assert.deepEqual(pty.paneArgv(argv, "win32"), argv);
|
|
assert.deepEqual(pty.paneArgv(null, "win32"), []);
|
|
assert.equal(pty.paneArgv(argv, "linux")[0], "sh");
|
|
assert.deepEqual(pty.paneArgv(argv, "linux").slice(-3), argv);
|
|
assert.match(pty.paneArgv(null, "linux")[2], /exec "\$SHELL" -l$/);
|
|
});
|
|
|
|
it("spawnRun starts the CLI only once the pane has its real size, argv untouched", () => {
|
|
// A claude drawn at tmux's birth size duplicated its banner on the first
|
|
// resize; the argv rides as positional parameters, never script text.
|
|
const call = captureNewSession(() =>
|
|
pty.spawnRun({ laneId: 14, cwd: "/tmp/repo", initialPrompt: "a'; rm -rf / #" })
|
|
);
|
|
const argv = paneArgv(call);
|
|
assert.ok(argv.includes("a'; rm -rf / #"));
|
|
const { execFileSync } = require("node:child_process");
|
|
const out = execFileSync(
|
|
"sh",
|
|
[
|
|
...call.slice(call.indexOf("--") + 2, call.indexOf("--") + 4),
|
|
"sh",
|
|
"printf",
|
|
"%s|",
|
|
"x y",
|
|
"$HOME",
|
|
],
|
|
{
|
|
env: { ...process.env, PATH: "/nonexistent-tmux-dir:" + process.env.PATH, TMUX_PANE: "" },
|
|
encoding: "utf8",
|
|
timeout: 5000,
|
|
}
|
|
);
|
|
assert.equal(out, "x y|$HOME|");
|
|
});
|
|
|
|
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("leaves a claude pane's screen mode to the user's own tui setting", () => {
|
|
// Forcing it inline duplicated the transcript on every pane narrowing.
|
|
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).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;
|
|
// delayMs (3000) counts from when the CLI starts: after up to 2 s of
|
|
// waiting for a client to attach.
|
|
t.mock.timers.tick(4999);
|
|
assert.ok(!calls.some((c) => c[0] === "set-buffer"));
|
|
t.mock.timers.tick(1);
|
|
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);
|
|
});
|
|
});
|
|
});
|