fix: v2.6.2 — Windows panes no longer read as dead (psmux ignores -F), Update now's setup works under cmd.exe

- psmux prints `name: 1 windows (created …)` whatever `list-sessions -F`
  asks for. Read as names, every live pane looked gone and the heal beat
  closed its run (and its tab) within seconds while the session kept
  running. tmux.js (and the Update all helper) now take the text before the
  first `:` — a session name never holds one.
- `npm run setup` used `(cd client && npm install)`; cmd.exe's ( ) is no
  subshell, so `npm run link-cli` ran inside client/ ("Missing script") and
  Update now failed on Windows. Now `npm --prefix client install`.
- Update now: an npm step names itself in its error and shows the END of
  npm's output (the error, not the warnings), says when it hit the time
  limit, and gets 20 min instead of 5.
This commit is contained in:
2026-10-10 12:01:01 +07:00
parent 96e4d28f7c
commit f0dc1445fc
9 changed files with 101 additions and 15 deletions
+4 -1
View File
@@ -463,7 +463,10 @@ export function UpdateNotifier() {
) : null}
{error ? (
<p className="text-xs text-status-danger" role="alert">
<p
className="text-xs text-status-danger whitespace-pre-wrap break-words max-h-48 overflow-auto"
role="alert"
>
{error}
</p>
) : null}
@@ -7174,7 +7174,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.6.1 (rebuild client to match)
UI build v2.6.2 (rebuild client to match)
</span>
</p>
</div>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.6.1",
"version": "2.6.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.6.1",
"version": "2.6.2",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.6.1",
"version": "2.6.2",
"bin": {
"agenthub": "bin/agenthub.js"
},
@@ -55,7 +55,7 @@
"dev:client": "cd client && npm run dev",
"build": "cd client && npm run build",
"start": "node server/index.js",
"setup": "npm install && (cd client && npm install) && npm run link-cli",
"setup": "npm install && npm --prefix client install && npm run link-cli",
"update:pull-setup": "git pull --ff-only && npm run setup",
"install-hooks": "node scripts/install-hooks.js",
"seed": "node scripts/seed.js",
+2 -1
View File
@@ -155,9 +155,10 @@ function reloadPanes() {
timeout: 15_000,
stdio: ["ignore", "pipe", "pipe"],
});
// psmux (Windows) ignores -F: `name: 1 windows (…)`; a name holds no `:`.
names = out
.split("\n")
.map((s) => s.trim())
.map((s) => s.split(":")[0].trim())
.filter((s) => s.startsWith("agenthub-lane-"));
} catch {
// No tmux server at all means no open sessions — nothing to reload, which
+13
View File
@@ -38,6 +38,19 @@ describe("tmux wrapper", () => {
assert.deepEqual(calls, ["list-sessions"]);
});
it("reads psmux's list-sessions, which ignores -F, as bare names (Windows)", () => {
// psmux prints its default line whatever `-F` asks for; read as names,
// every pane looked dead and the heal beat closed its run within seconds.
tmux.__setExecImpl((args) =>
args[0] === "list-sessions"
? "agenthub-lane-2-5: 1 windows (created Sat Oct 10 11:44:39 2026)\r\n" +
"agenthub-lane-2: 1 windows (created Sat Oct 10 10:43:16 2026)\r\n"
: ""
);
assert.deepEqual([...tmux.liveSessions()], ["agenthub-lane-2-5", "agenthub-lane-2"]);
assert.deepEqual(tmux.listSessions("agenthub-lane-"), ["agenthub-lane-2-5", "agenthub-lane-2"]);
});
it("liveSessions is empty when tmux answers that no server runs (or is not installed)", () => {
tmux.__setExecImpl(() => {
throw Object.assign(new Error("no server running"), { status: 1 });
+47 -1
View File
@@ -12,7 +12,7 @@ const os = require("os");
const path = require("path");
const { execFileSync } = require("child_process");
const { getUpdatesStatus, npmInvocation } = require("../lib/update-check");
const { getUpdatesStatus, npmInvocation, execNpm } = require("../lib/update-check");
// Strip GIT_* vars a parent git hook (e.g. the pre-commit hook running this
// very suite) sets in its own environment — those leak to every child
@@ -199,3 +199,49 @@ describe("npmInvocation (the checkout's Update now)", () => {
assert.deepEqual(npm.args.slice(-2), ["run", "build"]);
});
});
describe("npm run setup (the checkout's Update now)", () => {
it("never changes directory: cmd.exe's ( ) is no subshell, so a `cd` leaks into the next step", () => {
// On Windows `(cd client && npm install) && npm run link-cli` ran
// link-cli inside client/ — "Missing script" — and Update now failed.
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "..", "package.json"), "utf8"));
assert.doesNotMatch(pkg.scripts.setup, /\bcd\b/);
assert.match(pkg.scripts.setup, /npm --prefix client install/);
});
});
describe("execNpm (the checkout's Update now)", () => {
let dir;
before(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "exec-npm-"));
const noise = "for (let i = 0; i < 60; i++) console.error('npm warn noise ' + i);";
fs.writeFileSync(
path.join(dir, "package.json"),
JSON.stringify({
name: "fx",
version: "1.0.0",
scripts: {
fail: `node -e "${noise} console.error('ERR the real cause'); process.exit(1)"`,
slow: `node -e "setTimeout(() => {}, 60000)"`,
},
})
);
});
after(() => fs.rmSync(dir, { recursive: true, force: true }));
it("names the failed step and keeps the end of npm's output, where the error is", async () => {
await assert.rejects(execNpm(dir, ["run", "fail"]), (err) => {
assert.match(err.message, /^npm run fail failed/);
assert.match(err.message, /ERR the real cause/);
assert.doesNotMatch(err.message, /noise 0\b/);
return true;
});
});
it("says a step was stopped at the time limit instead of echoing its warnings", async () => {
await assert.rejects(execNpm(dir, ["run", "slow"], { timeout: 3000 }), (err) => {
assert.match(err.message, /npm run slow did not finish within/);
return true;
});
});
});
+12 -3
View File
@@ -196,6 +196,15 @@ function __reset() {
// too and is shared; any other failure is not.
const LIST_TTL_MS = 1000;
const LIST_ARGS = ["list-sessions", "-F", "#{session_name}"];
// psmux (Windows) ignores `-F` and prints `name: 1 windows (created …)`. A
// session name never holds a `:` (tmux rewrites it), so the text before the
// first one is the name in either form.
const sessionNames = (out) =>
String(out)
.split("\n")
.map((l) => l.split(":")[0].trim())
.filter(Boolean)
.join("\n");
let listed = new Map(); // member login ("" = ours) → { at, out } | { at, err }
let listGen = 0; // bumped by a change this process makes: drops lists in flight
function listOutput(acct = null) {
@@ -206,7 +215,7 @@ function listOutput(acct = null) {
return hit.out;
}
try {
const out = String(execImpl(LIST_ARGS));
const out = sessionNames(execImpl(LIST_ARGS));
listed.set("", { at: Date.now(), out });
return out;
} catch (err) {
@@ -252,7 +261,7 @@ function refreshList(acct) {
// about another pane) is not believed.
(out) =>
done({
out: String(out)
out: sessionNames(out)
.split("\n")
.filter((n) => ownedBy(n.trim(), acct))
.join("\n"),
@@ -308,7 +317,7 @@ function exact() {
if (exactTargets !== null) return exactTargets;
let first;
try {
first = String(execImpl(["list-sessions", "-F", "#{session_name}"]))
first = sessionNames(execImpl(LIST_ARGS))
.split("\n")
.map((x) => x.trim())
.find(Boolean);
+18 -4
View File
@@ -94,19 +94,32 @@ function npmInvocation(args, env = process.env) {
return require("../../scripts/plugin-bootstrap").npmCommand(args, { env: rest });
}
// `npm run setup` installs the client's dependencies more than once and
// `npm run build` type-checks before bundling; on Windows (Defender scanning
// every file) each step ran past the old 5-minute cap.
const NPM_TIMEOUT_MS = 20 * 60_000;
/** Same shape as {@link execGit}, for the `npm run setup`/`npm run build` steps
* {@link applyUpdate} runs after fast-forwarding. */
* {@link applyUpdate} runs after fast-forwarding. A failure names its step and
* carries the END of npm's output — the error, not the warnings before it. */
function execNpm(cwd, args, opts = {}) {
const timeout = opts.timeout ?? 300_000;
const timeout = opts.timeout ?? NPM_TIMEOUT_MS;
const npm = npmInvocation(args);
const step = `npm ${args.join(" ")}`;
return new Promise((resolve, reject) => {
execFile(
npm.file,
npm.args,
{ cwd, timeout, maxBuffer: 5_000_000, encoding: "utf8", env: npm.env, shell: npm.shell },
(err, stdout, stderr) => {
if (err) reject(new Error(stderr || err.message || String(err)));
else resolve(String(stdout).trim());
if (!err) return resolve(String(stdout).trim());
if (err.killed) {
return reject(
new Error(`${step} did not finish within ${Math.round(timeout / 60_000)} min and was stopped`)
);
}
const tail = String(stderr || err.message || err).trim().split(/\r?\n/).slice(-15).join("\n");
reject(new Error(`${step} failed:\n${tail}`));
}
);
});
@@ -880,4 +893,5 @@ module.exports = {
repoUrlFor,
isShallowClone,
npmInvocation,
execNpm,
};