diff --git a/client/src/components/UpdateNotifier.tsx b/client/src/components/UpdateNotifier.tsx index 3bc03998..5a6de01c 100644 --- a/client/src/components/UpdateNotifier.tsx +++ b/client/src/components/UpdateNotifier.tsx @@ -463,7 +463,10 @@ export function UpdateNotifier() { ) : null} {error ? ( -

+

{error}

) : null} diff --git a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap index a0177a9a..b2ac085c 100644 --- a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap +++ b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap @@ -7174,7 +7174,7 @@ exports[`screen snapshots > Settings — about 1`] = ` - UI build v2.6.1 (rebuild client to match) + UI build v2.6.2 (rebuild client to match)

diff --git a/package-lock.json b/package-lock.json index e6911166..a2e0dfff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index fa502e6d..ec1eda30 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/cc-update-all-helper.js b/scripts/cc-update-all-helper.js index 03b66644..d4f91364 100644 --- a/scripts/cc-update-all-helper.js +++ b/scripts/cc-update-all-helper.js @@ -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 diff --git a/server/__tests__/tmux.test.js b/server/__tests__/tmux.test.js index aba46bb0..4034f188 100644 --- a/server/__tests__/tmux.test.js +++ b/server/__tests__/tmux.test.js @@ -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 }); diff --git a/server/__tests__/update-check.test.js b/server/__tests__/update-check.test.js index 7b8cd63d..a41f96bd 100644 --- a/server/__tests__/update-check.test.js +++ b/server/__tests__/update-check.test.js @@ -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; + }); + }); +}); diff --git a/server/lib/tmux.js b/server/lib/tmux.js index 61922c31..81cfd449 100644 --- a/server/lib/tmux.js +++ b/server/lib/tmux.js @@ -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); diff --git a/server/lib/update-check.js b/server/lib/update-check.js index d3e736a3..a710c9c6 100644 --- a/server/lib/update-check.js +++ b/server/lib/update-check.js @@ -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, };