fix: v2.5.3 — Windows workspace folders, picker on top, no console flashes

A workspace could not be created on Windows: "cwd must be an absolute path"
for D:\…, because createLane/canonicalCwd tested for a leading "/".

- lanes.js: path.isAbsolute/path.resolve; canonicalCwd stops at any root (a
  missing drive looped forever); resolveLaneByCwd and /ensure accept "\" too
- native-dialog.js: the Windows picker gets an invisible TopMost owner form,
  shown first — an ownerless dialog from the background server opened behind
  the browser, or hidden
- windows-hide.js: every child process on Windows starts with windowsHide,
  required first by the server and the detached scripts (a console window
  flashed per git/npm call)
- Confirm/update/clear-data modals and the quota panel scroll instead of
  overflowing a short window
This commit is contained in:
2026-10-06 20:22:34 +07:00
parent f8acd5fc29
commit af014d9829
22 changed files with 151 additions and 27 deletions
+2 -1
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -85,14 +85,14 @@ export function ConfirmModal({
> >
<div <div
ref={panelRef} ref={panelRef}
className="relative w-full max-w-md rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40" className="relative flex max-h-full w-full max-w-md flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby={titleId} aria-labelledby={titleId}
aria-describedby={message ? messageId : undefined} aria-describedby={message ? messageId : undefined}
> >
<div className="flex items-start gap-3 p-5"> <div className="flex min-h-0 items-start gap-3 overflow-y-auto p-5">
{destructive && ( {destructive && (
<div className="w-9 h-9 rounded-lg bg-status-danger/10 border border-status-danger/20 flex items-center justify-center flex-shrink-0"> <div className="w-9 h-9 rounded-lg bg-status-danger/10 border border-status-danger/20 flex items-center justify-center flex-shrink-0">
<AlertTriangle className="w-4.5 h-4.5 text-status-danger" aria-hidden /> <AlertTriangle className="w-4.5 h-4.5 text-status-danger" aria-hidden />
+1 -1
View File
@@ -208,7 +208,7 @@ function QuotaPanel({
onMouseLeave={onLeave} onMouseLeave={onLeave}
onFocus={onEnter} onFocus={onEnter}
onBlur={onLeave} onBlur={onLeave}
className="fixed bottom-7 right-2 z-[9999] w-[19rem] max-w-[calc(100vw-1rem)] rounded-lg border border-border bg-surface-2 px-3 py-2.5 text-[11px] leading-snug text-fg-primary shadow-lg shadow-black/20" className="fixed bottom-7 right-2 z-[9999] max-h-[calc(100vh-2.5rem)] w-[19rem] max-w-[calc(100vw-1rem)] overflow-y-auto rounded-lg border border-border bg-surface-2 px-3 py-2.5 text-[11px] leading-snug text-fg-primary shadow-lg shadow-black/20"
> >
<div className="mb-2 font-semibold">{t("statusBar.quotaTitle")}</div> <div className="mb-2 font-semibold">{t("statusBar.quotaTitle")}</div>
{groups.map(([id, list]) => ( {groups.map(([id, list]) => (
+2 -2
View File
@@ -330,7 +330,7 @@ export function UpdateNotifier() {
longest label is a whole sentence ("restarting the dashboard…"). */} longest label is a whole sentence ("restarting the dashboard…"). */}
<div <div
ref={panelRef} ref={panelRef}
className="w-full max-w-2xl card shadow-2xl animate-slide-up overflow-hidden" className="w-full max-w-2xl max-h-full flex flex-col card shadow-2xl animate-slide-up overflow-hidden"
> >
{/* Header */} {/* Header */}
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-border"> <div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-border">
@@ -370,7 +370,7 @@ export function UpdateNotifier() {
</div> </div>
{/* Body */} {/* Body */}
<div className="px-5 py-4 space-y-3"> <div className="min-h-0 overflow-y-auto px-5 py-4 space-y-3">
<p className="text-sm text-fg-secondary leading-relaxed"> <p className="text-sm text-fg-secondary leading-relaxed">
{pluginManaged ? t("pluginAutoNote") : release ? t("releaseLead") : t("lead")} {pluginManaged ? t("pluginAutoNote") : release ? t("releaseLead") : t("lead")}
</p> </p>
@@ -67,7 +67,7 @@ export function ConfirmDeleteModal<
aria-modal="true" aria-modal="true"
aria-labelledby="cc-confirm-delete-title" aria-labelledby="cc-confirm-delete-title"
aria-describedby="cc-confirm-delete-body" aria-describedby="cc-confirm-delete-body"
className="relative w-full max-w-lg rounded-xl border border-status-danger/40 bg-surface-1" className="relative flex max-h-full w-full max-w-lg flex-col rounded-xl border border-status-danger/40 bg-surface-1"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2"> <div className="border-b border-border px-4 py-2.5 flex items-center gap-2">
@@ -76,7 +76,7 @@ export function ConfirmDeleteModal<
{t("edit.confirmDelete")} {t("edit.confirmDelete")}
</span> </span>
</div> </div>
<div className="p-4 space-y-3"> <div className="min-h-0 overflow-y-auto p-4 space-y-3">
<p id="cc-confirm-delete-body" className="text-xs text-fg-secondary leading-relaxed"> <p id="cc-confirm-delete-body" className="text-xs text-fg-secondary leading-relaxed">
{t("edit.confirmDeleteBody")} {t("edit.confirmDeleteBody")}
</p> </p>
+1 -1
View File
@@ -1229,7 +1229,7 @@ function ClearDataDialog({
aria-modal="true" aria-modal="true"
aria-labelledby="clear-data-title" aria-labelledby="clear-data-title"
aria-describedby="clear-data-body" aria-describedby="clear-data-body"
className="card w-full max-w-md space-y-4 border-status-danger/40 p-5" className="card max-h-full w-full max-w-md space-y-4 overflow-y-auto border-status-danger/40 p-5"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<h2 <h2
@@ -7233,7 +7233,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<p <p
class="text-[10px] text-status-warning/90 mt-1" class="text-[10px] text-status-warning/90 mt-1"
> >
UI build v2.5.2 (rebuild client to match) UI build v2.5.3 (rebuild client to match)
</p> </p>
</div> </div>
<div <div
+1 -1
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: Agent Dashboard for Claude Code API title: Agent Dashboard for Claude Code API
version: 2.5.2 version: 2.5.3
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence. description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact: contact:
name: Nguyễn Ngọc Trí Vĩ name: Nguyễn Ngọc Trí Vĩ
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "2.5.2", "version": "2.5.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "agenthub", "name": "agenthub",
"version": "2.5.2", "version": "2.5.3",
"hasInstallScript": true, "hasInstallScript": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "2.5.2", "version": "2.5.3",
"bin": { "bin": {
"agenthub": "bin/agenthub.js" "agenthub": "bin/agenthub.js"
}, },
+1
View File
@@ -22,6 +22,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
require("../server/lib/windows-hide").install(); // before child_process is read
const fs = require("fs"); const fs = require("fs");
const { rotateIfLarge } = require("./log-rotate"); const { rotateIfLarge } = require("./log-rotate");
const path = require("path"); const path = require("path");
+1
View File
@@ -26,6 +26,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
require("../server/lib/windows-hide").install(); // before child_process is read
const fs = require("fs"); const fs = require("fs");
const { rotateIfLarge } = require("./log-rotate"); const { rotateIfLarge } = require("./log-rotate");
const os = require("os"); const os = require("os");
+1
View File
@@ -31,6 +31,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
require("../server/lib/windows-hide").install(); // before child_process is read
const fs = require("fs"); const fs = require("fs");
const { rotateIfLarge } = require("./log-rotate"); const { rotateIfLarge } = require("./log-rotate");
const path = require("path"); const path = require("path");
+1
View File
@@ -7,6 +7,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
require("../server/lib/windows-hide").install(); // before child_process is read
const { spawn } = require("child_process"); const { spawn } = require("child_process");
const [, , pidArg, entry] = process.argv; const [, , pidArg, entry] = process.argv;
+18 -2
View File
@@ -44,6 +44,16 @@ describe("native-dialog", () => {
assert.ok(!args.slice(0, -2).join(" ").includes("shell script")); assert.ok(!args.slice(0, -2).join(" ").includes("shell script"));
}); });
it("gives the Windows dialog a TopMost owner, so it does not open behind the browser", () => {
for (const kind of ["dir", "file"]) {
const [[cmd, args]] = nd.dialogCommands(kind, "C:\\x", "win32");
assert.equal(cmd, "powershell.exe");
const script = args.at(-1);
assert.match(script, /\$o\.TopMost = \$true;.*\$o\.Show\(\);.*\$d\.ShowDialog\(\$o\)/);
assert.ok(!script.includes("C:\\x"), "the folder travels in env, never the script");
}
});
it( it(
"answers the chosen path, null on Cancel, and falls through a missing program", "answers the chosen path, null on Cancel, and falls through a missing program",
{ {
@@ -95,10 +105,16 @@ describe("native-dialog", () => {
it("tells a Cancel from a failure per program", () => { it("tells a Cancel from a failure per program", () => {
assert.equal(nd.isCancel("zenity", ""), true); assert.equal(nd.isCancel("zenity", ""), true);
assert.equal(nd.isCancel("zenity", "Gtk-Message: GtkDialog mapped without a transient parent"), true); assert.equal(
nd.isCancel("zenity", "Gtk-Message: GtkDialog mapped without a transient parent"),
true
);
assert.equal(nd.isCancel("kdialog", "qt.qpa.xcb: could not connect to display :1"), false); assert.equal(nd.isCancel("kdialog", "qt.qpa.xcb: could not connect to display :1"), false);
assert.equal(nd.isCancel("osascript", "execution error: User canceled. (-128)"), true); assert.equal(nd.isCancel("osascript", "execution error: User canceled. (-128)"), true);
assert.equal(nd.isCancel("osascript", "execution error: No user interaction allowed. (-1713)"), false); assert.equal(
nd.isCancel("osascript", "execution error: No user interaction allowed. (-1713)"),
false
);
assert.equal(nd.isCancel("powershell.exe", ""), true); assert.equal(nd.isCancel("powershell.exe", ""), true);
assert.equal(nd.isCancel("powershell.exe", "Add-Type : Cannot add type"), false); assert.equal(nd.isCancel("powershell.exe", "Add-Type : Cannot add type"), false);
}); });
+50
View File
@@ -0,0 +1,50 @@
/**
* @file Tests for server/lib/windows-hide.js: on win32 every child process,
* async or sync, is started with `windowsHide: true`; elsewhere nothing changes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { test } = require("node:test");
const assert = require("node:assert/strict");
const { install } = require("../lib/windows-hide");
function fakeCp() {
const seen = [];
class ChildProcess {
spawn(options) {
seen.push(options);
}
}
const rec = (...args) => seen.push(args);
return { seen, cp: { ChildProcess, spawnSync: rec, execFileSync: rec, execSync: rec } };
}
test("win32: async spawns and every sync form get windowsHide", () => {
const { seen, cp } = fakeCp();
install(cp, "win32");
install(cp, "win32"); // idempotent: no double wrap
new cp.ChildProcess().spawn({ file: "git", windowsHide: false });
cp.execFileSync("git", ["status"]);
cp.execFileSync("git", ["status"], { cwd: "x" });
cp.execFileSync("git", { cwd: "x" });
cp.execSync("dir");
cp.spawnSync("git", ["log"], undefined);
assert.deepEqual(seen, [
{ file: "git", windowsHide: true },
["git", ["status"], { windowsHide: true }],
["git", ["status"], { cwd: "x", windowsHide: true }],
["git", { cwd: "x", windowsHide: true }],
["dir", { windowsHide: true }],
["git", ["log"], { windowsHide: true }],
]);
});
test("other platforms are left alone", () => {
const { cp } = fakeCp();
const before = { ...cp, spawn: cp.ChildProcess.prototype.spawn };
install(cp, "linux");
assert.equal(cp.execSync, before.execSync);
assert.equal(cp.ChildProcess.prototype.spawn, before.spawn);
});
+3
View File
@@ -3,6 +3,9 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
// First, before any module destructures child_process: no console window per
// git/npm call on Windows (server/lib/windows-hide.js).
require("./lib/windows-hide").install();
if (!process.env.NODE_ENV) process.env.NODE_ENV = "production"; if (!process.env.NODE_ENV) process.env.NODE_ENV = "production";
// A server started from inside a Claude Code session (the bootstrap fallback, // A server started from inside a Claude Code session (the bootstrap fallback,
+12 -9
View File
@@ -9,6 +9,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
const path = require("node:path");
const { db } = require("../db"); const { db } = require("../db");
const aiTools = require("./ai-tools"); const aiTools = require("./ai-tools");
const { const {
@@ -182,17 +183,18 @@ function normalizeTitle(value) {
*/ */
function canonicalCwd(cwd) { function canonicalCwd(cwd) {
// A relative path must stay relative, so createLane still refuses it. // A relative path must stay relative, so createLane still refuses it.
if (!cwd.startsWith("/")) return cwd; // path.isAbsolute, not a leading "/": a Windows cwd is `D:\…`.
const trimmed = cwd.replace(/\/+$/, ""); if (!path.isAbsolute(cwd)) return cwd;
const trimmed = path.resolve(cwd);
// A path that does not exist yet resolves through its deepest existing // A path that does not exist yet resolves through its deepest existing
// ancestor, so /link/new and /real/new still name the same lane. // ancestor, so /link/new and /real/new still name the same lane.
const path = require("node:path");
const tail = []; const tail = [];
for (let dir = trimmed; ; dir = path.dirname(dir)) { for (let dir = trimmed; ; dir = path.dirname(dir)) {
try { try {
return path.join(require("node:fs").realpathSync.native(dir || "/"), ...tail); return path.join(require("node:fs").realpathSync.native(dir), ...tail);
} catch { } catch {
if (dir === "/" || !dir) return trimmed; // A root (`/`, `D:\` — a drive that does not exist) is its own dirname.
if (path.dirname(dir) === dir) return trimmed;
tail.unshift(path.basename(dir)); tail.unshift(path.basename(dir));
} }
} }
@@ -210,7 +212,7 @@ function createLane({
tool = "claude", tool = "claude",
router_profile = null, router_profile = null,
} = {}) { } = {}) {
if (!cwd || typeof cwd !== "string" || !cwd.startsWith("/")) { if (!cwd || typeof cwd !== "string" || !path.isAbsolute(cwd)) {
throw Object.assign(new Error("cwd must be an absolute path"), { code: "EBADCWD" }); throw Object.assign(new Error("cwd must be an absolute path"), { code: "EBADCWD" });
} }
validateKind(kind); validateKind(kind);
@@ -603,12 +605,13 @@ function recoverInterruptedProvisioning() {
*/ */
function resolveLaneByCwd(cwd, tool) { function resolveLaneByCwd(cwd, tool) {
if (!cwd || typeof cwd !== "string") return null; if (!cwd || typeof cwd !== "string") return null;
const target = cwd.replace(/\/+$/, ""); // A Windows cwd ends in and nests under `\`.
const target = cwd.replace(/[\\/]+$/, "");
let best = null; let best = null;
for (const lane of listLanes()) { for (const lane of listLanes()) {
if (tool && lane.tool !== tool) continue; if (tool && lane.tool !== tool) continue;
const base = lane.cwd.replace(/\/+$/, ""); const base = lane.cwd.replace(/[\\/]+$/, "");
if (target === base || target.startsWith(`${base}/`)) { if (target === base || target.startsWith(`${base}/`) || target.startsWith(base + path.sep)) {
if (!best || base.length > best.cwd.length) best = lane; if (!best || base.length > best.cwd.length) best = lane;
} }
} }
+6 -1
View File
@@ -95,7 +95,12 @@ function dialogCommands(kind, dir, platform = process.platform) {
"-NoProfile", "-NoProfile",
"-STA", "-STA",
"-Command", "-Command",
`[Console]::OutputEncoding = [Text.Encoding]::UTF8; Add-Type -AssemblyName System.Windows.Forms; ${dialog}; if ($d.ShowDialog() -eq 'OK') { [Console]::Out.Write(${result}) } else { exit 1 }`, // The server is a background process, and Windows will not let one
// bring a window to the front: an ownerless dialog opened BEHIND the
// browser (or hidden — the first ShowWindow of a process started
// with windowsHide). An invisible TopMost owner, shown first, takes
// that first ShowWindow and keeps the dialog on top.
`[Console]::OutputEncoding = [Text.Encoding]::UTF8; Add-Type -AssemblyName System.Windows.Forms; $o = New-Object System.Windows.Forms.Form; $o.TopMost = $true; $o.ShowInTaskbar = $false; $o.FormBorderStyle = 'None'; $o.Opacity = 0; $o.Width = 1; $o.Height = 1; $o.Show(); $o.Activate(); ${dialog}; if ($d.ShowDialog($o) -eq 'OK') { [Console]::Out.Write(${result}) } else { exit 1 }`,
], ],
], ],
]; ];
+41
View File
@@ -0,0 +1,41 @@
/**
* @file Makes every child process on Windows start with `windowsHide: true`.
* The dashboard runs detached, with no console of its own, so Windows gave
* each console program it ran (git, npm, where, …) a NEW console window — a
* flash that stole focus on every poll. Node has no global default, so this
* patches `ChildProcess.prototype.spawn` (the one path spawn/exec/execFile/fork
* and their promisified forms all take) and wraps the sync exports. Require it
* FIRST in an entry point: a `const { execSync } = require("child_process")`
* evaluated before it keeps the unwrapped function. A no-op off Windows.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const DONE = Symbol.for("agenthub.windowsHide");
function install(cp = require("child_process"), platform = process.platform) {
if (platform !== "win32" || cp[DONE]) return;
cp[DONE] = true;
const spawn = cp.ChildProcess.prototype.spawn;
cp.ChildProcess.prototype.spawn = function (options) {
options.windowsHide = true;
return spawn.call(this, options);
};
// spawnSync(cmd, args?, opts?), execFileSync(file, args?, opts?), execSync(cmd, opts?)
for (const name of ["spawnSync", "execFileSync", "execSync"]) {
const fn = cp[name];
cp[name] = function (...args) {
while (args.length > 1 && args[args.length - 1] == null) args.pop();
const last = args[args.length - 1];
if (args.length > 1 && typeof last === "object" && !Array.isArray(last)) {
args[args.length - 1] = { ...last, windowsHide: true };
} else {
args.push({ windowsHide: true });
}
return fn.apply(this, args);
};
}
}
module.exports = { install };
+1 -1
View File
@@ -406,7 +406,7 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
// it, a nested cwd still resolves to its owner, which is what the CLI and // it, a nested cwd still resolves to its owner, which is what the CLI and
// other callers adopting "the lane I am inside" rely on. // other callers adopting "the lane I am inside" rely on.
const found = lanesLib.resolveLaneByCwd(cwd, body.tool || "claude"); const found = lanesLib.resolveLaneByCwd(cwd, body.tool || "claude");
const strip = (p) => p.replace(/\/+$/, ""); const strip = (p) => p.replace(/[\\/]+$/, "");
const owner = found && (body.exact !== true || strip(found.cwd) === strip(cwd)) ? found : null; const owner = found && (body.exact !== true || strip(found.cwd) === strip(cwd)) ? found : null;
if (owner) { if (owner) {
// First-write wins on the title: an ensure-with-title on an existing lane // First-write wins on the title: an ensure-with-title on an existing lane
+1
View File
@@ -67,6 +67,7 @@ function gitBranch(cwd) {
cwd, cwd,
timeout: 500, timeout: 500,
stdio: ["ignore", "pipe", "ignore"], stdio: ["ignore", "pipe", "ignore"],
windowsHide: true,
}) })
.toString() .toString()
.trim(); .trim();