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
+12 -9
View File
@@ -9,6 +9,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const path = require("node:path");
const { db } = require("../db");
const aiTools = require("./ai-tools");
const {
@@ -182,17 +183,18 @@ function normalizeTitle(value) {
*/
function canonicalCwd(cwd) {
// A relative path must stay relative, so createLane still refuses it.
if (!cwd.startsWith("/")) return cwd;
const trimmed = cwd.replace(/\/+$/, "");
// path.isAbsolute, not a leading "/": a Windows cwd is `D:\…`.
if (!path.isAbsolute(cwd)) return cwd;
const trimmed = path.resolve(cwd);
// A path that does not exist yet resolves through its deepest existing
// ancestor, so /link/new and /real/new still name the same lane.
const path = require("node:path");
const tail = [];
for (let dir = trimmed; ; dir = path.dirname(dir)) {
try {
return path.join(require("node:fs").realpathSync.native(dir || "/"), ...tail);
return path.join(require("node:fs").realpathSync.native(dir), ...tail);
} 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));
}
}
@@ -210,7 +212,7 @@ function createLane({
tool = "claude",
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" });
}
validateKind(kind);
@@ -603,12 +605,13 @@ function recoverInterruptedProvisioning() {
*/
function resolveLaneByCwd(cwd, tool) {
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;
for (const lane of listLanes()) {
if (tool && lane.tool !== tool) continue;
const base = lane.cwd.replace(/\/+$/, "");
if (target === base || target.startsWith(`${base}/`)) {
const base = lane.cwd.replace(/[\\/]+$/, "");
if (target === base || target.startsWith(`${base}/`) || target.startsWith(base + path.sep)) {
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",
"-STA",
"-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 };