fix: v2.5.6 — Windows panes without sh, UI build without node on PATH
- 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.
This commit is contained in:
@@ -455,14 +455,18 @@ describe("buildClient dependency install", () => {
|
||||
return root;
|
||||
}
|
||||
|
||||
// Records every npm invocation; `fails` names the subcommands that exit non-zero.
|
||||
// A successful "run build" writes the dist/ the real Vite build would produce.
|
||||
// Records every invocation — npm's as its args, node's as `node <script> …`;
|
||||
// `fails` names the npm subcommands that exit non-zero. A successful vite
|
||||
// build writes the dist/ the real one would produce.
|
||||
function fakeExec(calls, fails = []) {
|
||||
return (_cmd, args, cwd) => {
|
||||
return (cmd, args, cwd) => {
|
||||
if (cmd === "node") {
|
||||
calls.push(`node ${path.basename(args[0])} ${args.slice(1).join(" ")}`);
|
||||
if (args[1] === "build") fs.mkdirSync(path.join(cwd, "dist"), { recursive: true });
|
||||
return true;
|
||||
}
|
||||
calls.push(args.join(" "));
|
||||
if (fails.includes(args[0])) return false;
|
||||
if (args[0] === "run") fs.mkdirSync(path.join(cwd, "dist"), { recursive: true });
|
||||
return true;
|
||||
return !fails.includes(args[0]);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -489,10 +493,12 @@ describe("buildClient dependency install", () => {
|
||||
const result = buildClient({ rt: RT, root: fakeRoot(), exec: fakeExec(calls, ["ci"]) });
|
||||
|
||||
assert.equal(result, "built");
|
||||
assert.deepEqual(
|
||||
calls.map((c) => c.split(" ")[0]),
|
||||
["ci", "install", "run"]
|
||||
);
|
||||
assert.deepEqual(calls, [
|
||||
"ci --no-audit --no-fund --include=dev --ignore-scripts",
|
||||
"install --no-audit --no-fund --include=dev --ignore-scripts",
|
||||
"node tsc -b",
|
||||
"node vite.js build",
|
||||
]);
|
||||
});
|
||||
|
||||
it("installs devDependencies explicitly, because NODE_ENV=production omits them", () => {
|
||||
@@ -521,11 +527,28 @@ describe("buildClient dependency install", () => {
|
||||
|
||||
assert.equal(result, "failed");
|
||||
assert.equal(
|
||||
calls.some((c) => c.startsWith("run")),
|
||||
calls.some((c) => c.startsWith("node")),
|
||||
false,
|
||||
"the build must not run on a broken dependency tree"
|
||||
);
|
||||
});
|
||||
|
||||
it("builds with the running node, no lifecycle script and no PATH lookup (Windows)", () => {
|
||||
// On Windows a script or a .bin shim runs through cmd.exe and needs `node`
|
||||
// on PATH; the server's PATH lacked it, so esbuild's postinstall failed and
|
||||
// every update kept the previous UI. The build here must equal the
|
||||
// client's own "build" script, run without npm.
|
||||
assert.equal(
|
||||
JSON.parse(fs.readFileSync(path.join(__dirname, "..", "..", "client", "package.json")))
|
||||
.scripts.build,
|
||||
"tsc -b && vite build"
|
||||
);
|
||||
resetRuntime();
|
||||
const calls = [];
|
||||
buildClient({ rt: RT, root: fakeRoot(), exec: fakeExec(calls) });
|
||||
assert.deepEqual(calls.slice(-2), ["node tsc -b", "node vite.js build"]);
|
||||
assert.ok(calls.every((c) => !c.startsWith("run")));
|
||||
});
|
||||
});
|
||||
|
||||
describe("supersededBy — an old cache directory must not act", () => {
|
||||
|
||||
@@ -499,6 +499,15 @@ describe("pty-run", () => {
|
||||
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.
|
||||
|
||||
@@ -41,6 +41,16 @@ describe("tmux wrapper", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("newSession with no argv leaves tmux its default shell (no trailing --)", () => {
|
||||
const calls = [];
|
||||
tmux.__setExecImpl((args) => {
|
||||
calls.push(args);
|
||||
return "";
|
||||
});
|
||||
tmux.newSession({ name: "agenthub-lane-1", cwd: "C:\\repo", argv: [] });
|
||||
assert.deepEqual(calls[0], ["new-session", "-d", "-s", "agenthub-lane-1", "-c", "C:\\repo"]);
|
||||
});
|
||||
|
||||
it("newSession builds the correct argv", () => {
|
||||
const calls = [];
|
||||
tmux.__setExecImpl((args) => {
|
||||
|
||||
+20
-13
@@ -77,6 +77,23 @@ const WAIT_FOR_CLIENT =
|
||||
'i=0; while [ "$(tmux display -p -t "$TMUX_PANE" \'#{session_attached}\' 2>/dev/null)" = 0 ]' +
|
||||
" && [ $i -lt 20 ]; do sleep 0.1; i=$((i+1)); done;";
|
||||
const CLIENT_WAIT_MS = 2000;
|
||||
|
||||
/**
|
||||
* A pane's tmux command: `argv` behind {@link WAIT_FOR_CLIENT}, or — with no
|
||||
* `argv` — the login shell behind it. Windows has no `sh` (a WinGet tmux runs
|
||||
* native programs), and an `sh -c` there died at once and took the session
|
||||
* with it, so it gets the command itself, or nothing for tmux's own default
|
||||
* shell.
|
||||
* ponytail: no wait for the attach on Windows, so a pane may first draw at
|
||||
* 80×24 (issue #55); port the wait to PowerShell if that shows.
|
||||
*/
|
||||
function paneArgv(argv, platform = process.platform) {
|
||||
if (platform === "win32") return argv || [];
|
||||
// argv rides as sh's positional parameters, never inside the script text.
|
||||
return argv
|
||||
? ["sh", "-c", `${WAIT_FOR_CLIENT} exec "$@"`, "sh", ...argv]
|
||||
: ["sh", "-c", `${WAIT_FOR_CLIENT} exec "$SHELL" -l`];
|
||||
}
|
||||
const CODEX_SANDBOXES = new Set(["read-only", "workspace-write", "danger-full-access"]);
|
||||
const CODEX_APPROVALS = new Set(["on-request", "never"]);
|
||||
// Codex has no `--effort` flag; reasoning effort is a config override.
|
||||
@@ -493,13 +510,7 @@ function spawnRun(args) {
|
||||
};
|
||||
|
||||
if (!tmux.hasSession(id)) {
|
||||
// argv rides as sh's positional parameters, never inside the script text.
|
||||
tmux.newSession({
|
||||
name: id,
|
||||
cwd,
|
||||
argv: ["sh", "-c", `${WAIT_FOR_CLIENT} exec "$@"`, "sh", ...argv],
|
||||
env,
|
||||
});
|
||||
tmux.newSession({ name: id, cwd, argv: paneArgv(argv), env });
|
||||
record();
|
||||
schedulePaste();
|
||||
} else if (SHELL_COMMANDS.has(tmux.paneCommand(id) || "")) {
|
||||
@@ -554,12 +565,7 @@ function spawnShell({ laneId, slot = 0, cwd }) {
|
||||
};
|
||||
// Started once the pane has its real size (WAIT_FOR_CLIENT, issue #55), as
|
||||
// the login shell tmux would have run (tmux sets SHELL in the pane).
|
||||
tmux.newSession({
|
||||
name: id,
|
||||
cwd,
|
||||
argv: ["sh", "-c", `${WAIT_FOR_CLIENT} exec "$SHELL" -l`],
|
||||
env,
|
||||
});
|
||||
tmux.newSession({ name: id, cwd, argv: paneArgv(null), env });
|
||||
dashboardRuns?.recordRun({
|
||||
id,
|
||||
sessionId: null,
|
||||
@@ -636,4 +642,5 @@ module.exports = {
|
||||
runIdForLane,
|
||||
MAX_PANE_SLOT,
|
||||
SHELL_COMMANDS,
|
||||
paneArgv,
|
||||
};
|
||||
|
||||
+6
-2
@@ -76,7 +76,9 @@ function envFlags(env) {
|
||||
* guessing from cwd — see resolveLaneArg in bin/agenthub.js.
|
||||
*/
|
||||
function newSession({ name, cwd, argv, env }) {
|
||||
execImpl(["new-session", "-d", "-s", name, "-c", cwd, ...envFlags(env), "--", ...argv]);
|
||||
// No argv = tmux's own default shell (a Windows Terminal pane).
|
||||
const cmd = argv.length ? ["--", ...argv] : [];
|
||||
execImpl(["new-session", "-d", "-s", name, "-c", cwd, ...envFlags(env), ...cmd]);
|
||||
try {
|
||||
enableMouse(name);
|
||||
} catch (err) {
|
||||
@@ -85,7 +87,9 @@ function newSession({ name, cwd, argv, env }) {
|
||||
// "Command failed: tmux set-option …" that hid the real problem.
|
||||
if (hasSession(name)) throw err;
|
||||
throw Object.assign(
|
||||
new Error(`the session's command exited as soon as it started: ${argv[0]}`),
|
||||
new Error(
|
||||
`the session's command exited as soon as it started: ${argv[0] || "the default shell"}`
|
||||
),
|
||||
{ code: "EPANEEXITED" }
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user