feat: workspace aliases, tab groups of split panes, native path picker (v2.1.0)

- /api/workspaces and `agenthub workspace(s)` / `--workspace` as aliases of
  /api/lanes and `agenthub lanes` / `--lane`; old names print one stderr note.
  REPL `help lanes|workspace|workspaces` resolves through the aliases.
- A workspace holds up to ten sessions (slots 0-9); every console tab is a
  group of 1-4 split panes (PaneGroups, agenthub.workspace.paneLayout);
  parseGroups keeps the stored active tab when an earlier one is dropped.
- Browse button beside path fields: POST /api/lanes/pick-path opens the OS
  picker on the server's desktop — loopback only and no forwarding header
  (EREMOTE), a display failure is ENODIALOG not a Cancel, the dialog is
  killed when the request closes, a folder field falls back to the in-app
  browser on any failure.
- Changes rail counts chip, image viewer (/git/raw), status bar session
  counts and per-day cost (`date=` on /api/pricing/cost).
- /agenthub-open and /agenthub-doctor run from the INSTALLED build
  (supersededBy), doctor offers fixes behind two confirmations; plugin-open
  treats a locked bootstrap as non-fatal.
- New-terminal shell waits for the first attach (issue #55).
- repo-onboarding / debug-live-issue skills removed.
- OpenAPI: pick-path, lane/date on /pricing/cost, tool on /run/history,
  /api/workspaces mount.
This commit is contained in:
2026-10-06 11:31:26 +07:00
parent c067abc68f
commit 6c605849d8
104 changed files with 3478 additions and 1396 deletions
+12 -2
View File
@@ -68,6 +68,14 @@ const listByLaneStmt = db.prepare(`
LIMIT @limit
`);
const listByLaneToolStmt = db.prepare(`
SELECT ${RUN_COLUMNS}
FROM dashboard_runs
WHERE lane_id = @laneId AND tool = @tool
ORDER BY started_at DESC
LIMIT @limit
`);
const getStmt = db.prepare(`
SELECT ${RUN_COLUMNS}
FROM dashboard_runs WHERE id = @id
@@ -144,10 +152,12 @@ function patchRun({ id, sessionId, status, exitCode, endedAt }) {
}
}
/** @param {{limit?: number, laneId?: number|null}} [opts] laneId narrows to one lane's runs. */
function listRuns({ limit = 50, laneId = null } = {}) {
/** @param {{limit?: number, laneId?: number|null, tool?: string|null}} [opts]
* laneId narrows to one lane's runs, and tool (with a laneId) to one tool's. */
function listRuns({ limit = 50, laneId = null, tool = null } = {}) {
try {
const safeLimit = Math.max(1, Math.min(500, Math.floor(Number(limit) || 50)));
if (laneId != null && tool) return listByLaneToolStmt.all({ limit: safeLimit, laneId, tool });
if (laneId != null) return listByLaneStmt.all({ limit: safeLimit, laneId });
return listStmt.all({ limit: safeLimit });
} catch {
+55
View File
@@ -723,6 +723,60 @@ async function readFileAt(dir, requested, rev = "work") {
return { path: file, rev, binary, truncated, lines, ...(sha ? { sha } : {}) };
}
/** The image types the viewer shows, by extension. Raster and SVG only. */
const IMAGE_TYPES = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
avif: "image/avif",
bmp: "image/bmp",
ico: "image/x-icon",
svg: "image/svg+xml",
};
const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
/**
* One image's bytes at a revision, for the viewer's `<img>`: `readFileAt`'s
* guards (lexical path, safe rev, secret, realpath-in-lane for `work`), plus
* an extension allowlist that also picks the Content-Type and a size cap.
*/
async function readImageAt(dir, requested, rev = "work") {
const file = lanePath(requested);
const type = IMAGE_TYPES[path.extname(file).slice(1).toLowerCase()];
if (!type) throw badRequest("ENOTIMAGE", "not an image file");
if (rev !== "work" && !SAFE_REV.test(rev)) {
throw badRequest("EBADREV", "rev is not a valid revision");
}
if (await isSecretOnDisk(dir, file)) {
throw badRequest("ESECRET", "that file holds secrets and is not readable here");
}
const tooBig = () => badRequest("ETOOBIG", "image is larger than 20 MB");
if (rev === "work") {
const abs = await resolveInLane(dir, file);
let stat;
try {
stat = await fs.promises.stat(abs);
} catch {
throw badRequest("ENOFILE", "no such file in the working tree");
}
if (!stat.isFile()) throw badRequest("ENOFILE", "no such file in the working tree");
if (stat.size > MAX_IMAGE_BYTES) throw tooBig();
return { type, buf: await fs.promises.readFile(abs) };
}
try {
const { stdout } = await git(dir, ["show", `${rev}:./${file}`], {
encoding: "buffer",
maxBuffer: MAX_IMAGE_BYTES,
});
return { type, buf: stdout };
} catch (err) {
if (err.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") throw tooBig();
throw badRequest("ENOFILE", "no such file at that revision");
}
}
/** The version stamp of a file's bytes, for a stale-save check. */
function contentSha(buf) {
return crypto.createHash("sha1").update(buf).digest("hex");
@@ -983,6 +1037,7 @@ module.exports = {
parsePorcelainZ,
parseUnifiedDiff,
readFileAt,
readImageAt,
resolveBaseRef,
DEFAULT_CONTEXT,
MAX_CONTEXT,
+180
View File
@@ -0,0 +1,180 @@
/**
* @file native-dialog.js
* @description Opens the operating system's own folder/file picker ON THE
* SERVER'S DESKTOP and returns the absolute path the user chose. A browser's
* native picker never hands a page an absolute path, and the dashboard is
* local-first, so the server asks the desktop itself: zenity or kdialog on
* Linux (with the graphical session's variables from `desktop-env.js`, which a
* systemd user service lacks), `osascript` on macOS, PowerShell's WinForms
* dialogs on Windows. Runs with `execFile` (never a shell, never sync — a
* dialog waits on a human for minutes), one dialog at a time. Nothing from the
* request reaches argv except the starting folder, resolved to an absolute
* path first. macOS and Windows are code-only, untested on those systems.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { execFile } = require("node:child_process");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { desktopEnv } = require("./desktop-env");
const TITLES = { dir: "Select a folder", file: "Select a file" };
const TIMEOUT_MS = 10 * 60 * 1000;
const defaultExec = (cmd, args, env, signal) =>
new Promise((resolve) => {
execFile(
cmd,
args,
{ env, timeout: TIMEOUT_MS, windowsHide: true, encoding: "utf8", signal },
(err, stdout, stderr) =>
resolve({
missing: err?.code === "ENOENT",
code: err ? (typeof err.code === "number" ? err.code : -1) : 0,
stdout: String(stdout || ""),
stderr: String(stderr || ""),
})
);
});
let execImpl = defaultExec;
/** Test seam — same pattern as desktop-env.js; `null` restores the real one. */
function __setExecImpl(fn) {
execImpl = fn || defaultExec;
}
/** An existing folder to open the dialog in: `start` (or its parent when it is a file), else home. */
function startDir(start) {
if (typeof start === "string" && start.trim()) {
const raw = start.trim().replace(/^~(?=$|[\\/])/, os.homedir());
const abs = path.resolve(raw);
for (const p of [abs, path.dirname(abs)]) {
try {
if (fs.statSync(p).isDirectory()) return p;
} catch {
// missing — try the parent, then home
}
}
}
return os.homedir();
}
/** The candidate dialog commands for a platform, in order of preference. */
function dialogCommands(kind, dir, platform = process.platform) {
const title = TITLES[kind];
if (platform === "darwin") {
const what = kind === "dir" ? "folder" : "file";
return [
[
"osascript",
[
"-e",
"on run argv",
"-e",
`POSIX path of (choose ${what} with prompt (item 1 of argv) default location (POSIX file (item 2 of argv)))`,
"-e",
"end run",
title,
dir,
],
],
];
}
if (platform === "win32") {
const dialog =
kind === "dir"
? "$d = New-Object System.Windows.Forms.FolderBrowserDialog; $d.SelectedPath = $env:AGENTHUB_PICK_START; $d.Description = $env:AGENTHUB_PICK_TITLE"
: "$d = New-Object System.Windows.Forms.OpenFileDialog; $d.InitialDirectory = $env:AGENTHUB_PICK_START; $d.Title = $env:AGENTHUB_PICK_TITLE";
const result = kind === "dir" ? "$d.SelectedPath" : "$d.FileName";
return [
[
"powershell.exe",
[
"-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 }`,
],
],
];
}
const sep = dir.endsWith("/") ? "" : "/";
return [
[
"zenity",
[
"--file-selection",
`--title=${title}`,
`--filename=${dir}${sep}`,
...(kind === "dir" ? ["--directory"] : []),
],
],
[
"kdialog",
[kind === "dir" ? "--getexistingdirectory" : "--getopenfilename", dir, "--title", title],
],
];
}
// A dialog that could not reach the desktop exits 1 too — a stale DISPLAY
// handed over by `systemctl --user show-environment` after a logout, say.
const NO_DISPLAY_RE =
/cannot open display|could not connect to display|failed to open display|cannot connect to x server|unable to init server/i;
/**
* Whether an exit status of 1 means the user pressed Cancel. zenity/kdialog
* may print GTK/Qt warnings on a normal Cancel, so only a display failure
* counts against it; osascript says Cancel as error -128 and nothing else.
*/
function isCancel(cmd, stderr = "") {
if (cmd === "osascript") return /-128/.test(stderr);
if (cmd === "powershell.exe") return !stderr.trim();
return !NO_DISPLAY_RE.test(stderr);
}
function fail(code, message) {
return Object.assign(new Error(message), { code });
}
let busy = false;
/**
* Show the native picker and wait for the user.
* @param {"dir"|"file"} kind
* @param {string} [start] Folder (or file) to open the dialog at.
* @param {AbortSignal} [signal] Closes the dialog — the browser went away.
* @returns {Promise<string|null>} The chosen absolute path, or null when cancelled.
* @throws `ENODIALOG` (no desktop or no dialog program), `EBUSY` (one is already open), `EDIALOG`.
*/
async function pickPath(kind, start, signal) {
if (busy) throw fail("EBUSY", "a picker is already open on the desktop");
const env = { ...process.env, ...desktopEnv() };
if (process.platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY) {
throw fail("ENODIALOG", "no graphical desktop session to show a picker on");
}
const dir = startDir(start);
env.AGENTHUB_PICK_START = dir;
env.AGENTHUB_PICK_TITLE = TITLES[kind];
busy = true;
try {
for (const [cmd, args] of dialogCommands(kind, dir)) {
const r = await execImpl(cmd, args, env, signal);
if (r.missing) continue;
if (r.code === 1) {
if (isCancel(cmd, r.stderr)) return null; // every one of these exits 1 on Cancel
throw fail("ENODIALOG", `${cmd} could not open a picker on the desktop`);
}
const out = r.stdout.replace(/[\r\n]+$/, "");
if (r.code !== 0 || !out) throw fail("EDIALOG", `${cmd} exited with ${r.code}`);
// osascript ends a folder with "/"; "/" itself stays.
return out.replace(/\/+$/, "") || "/";
}
throw fail("ENODIALOG", "no native picker found (install zenity or kdialog)");
} finally {
busy = false;
}
}
module.exports = { pickPath, dialogCommands, startDir, isCancel, __setExecImpl };
-1
View File
@@ -32,7 +32,6 @@ const FEATURED_HOST = "https://codex-pets.net";
/** Offered in the pet menu, installed only on a click. `id` is codex-pets.net's. */
const FEATURED = [
{ id: "goku", displayName: "Goku", by: "hackminit" },
{ id: "bac-ho", displayName: "Bac Ho", by: "k0nkupa" },
{ id: "border-collie", displayName: "Border Collie", by: "dev-minsoo" },
{ id: "11", displayName: "11", by: "ct" },
];
+2 -2
View File
@@ -14,10 +14,10 @@
const fs = require("fs");
const tmux = require("./tmux");
const RUN_ID_RE = /^agenthub-lane-\d+(?:-[1-3])?$/;
const RUN_ID_RE = /^agenthub-lane-\d+(?:-[1-9])?$/;
/**
* Reject anything that isn't exactly `agenthub-lane-<digits>[-<1-3>]` before it can ever
* Reject anything that isn't exactly `agenthub-lane-<digits>[-<1-9>]` before it can ever
* reach a tmux/PTY command — the trust boundary for this WS path, since a
* validated runId is the only thing standing between an authenticated WS
* client and naming an arbitrary session on the host.
+18 -6
View File
@@ -22,8 +22,8 @@
* Spawns `claude`, `codex` or `agy` (Antigravity) in the tmux pane based on
* the tool parameter.
*
* A lane can hold up to four sessions, one per Workspace tab: slot 0 is
* `agenthub-lane-<laneId>` (the lane's own `run_id`), slots 1–3 are
* A lane can hold up to ten sessions (`MAX_PANE_SLOT`): slot 0 is
* `agenthub-lane-<laneId>` (the lane's own `run_id`), slots 1–9 are
* `agenthub-lane-<laneId>-<slot>`. A slot holds an AI tool (`spawnRun`) or a
* plain shell (`spawnShell`, a Terminal tab).
*
@@ -43,9 +43,10 @@ try {
/* db-less environment, skip persistence */
}
const RUN_ID_RE = /^agenthub-lane-(\d+)(?:-([1-3]))?$/;
/** Highest pane slot a lane may use (the Workspace's 4-pane grid is 0–3). */
const MAX_PANE_SLOT = 3;
const RUN_ID_RE = /^agenthub-lane-(\d+)(?:-([1-9]))?$/;
/** Highest pane slot a lane may use: ten sessions per workspace, 0–9 (the
* Workspace groups them into tabs of up to four split panes each). */
const MAX_PANE_SLOT = 9;
const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]);
// `auto` is the CLI's own default-ish mode (`claude --permission-mode auto`):
// it asks only for what its classifier reads as risky. `default` is kept for
@@ -529,7 +530,18 @@ function spawnShell({ laneId, slot = 0, cwd }) {
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
...desktopEnv(),
};
tmux.newSession({ name: id, cwd, argv: [], env });
// The session is born 80×24 and only takes the browser's size when the pane
// attaches. A shell started at once printed its prompt at 80 columns (a long
// cwd wrapped), and readline's redraw after the resize left the cursor on top
// of the prompt (issue #55). So wait until a client is attached — 2 s at
// most — then exec the login shell tmux would have run (tmux sets SHELL in the
// pane). Not "the first SIGWINCH": the scrollbar `enableMouse` turns on
// resizes the pane before any client is there. A tmux that cannot answer
// ends the wait at once, i.e. the old behaviour.
const waitForSize =
"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; exec "$SHELL" -l';
tmux.newSession({ name: id, cwd, argv: ["sh", "-c", waitForSize], env });
dashboardRuns?.recordRun({
id,
sessionId: null,
+9 -1
View File
@@ -33,7 +33,14 @@ const STATUS_FILE_CAP = 20;
async function git(
cwd,
args,
{ timeout = 0, maxBuffer = 8 * 1024 * 1024, literal = false, env: extraEnv = {} } = {}
{
timeout = 0,
maxBuffer = 8 * 1024 * 1024,
literal = false,
env: extraEnv = {},
// "buffer" for a binary read (an image at a revision).
encoding = "utf8",
} = {}
) {
// Build a clean environment: copy process.env but scrub git hook variables
// that could point to the outer repo's git directory or index.
@@ -79,6 +86,7 @@ async function git(
// passes one so an unreachable remote fails instead of pinning a request
// open forever; GIT_TERMINAL_PROMPT=0 only covers the prompting case.
timeout,
encoding,
});
return { stdout: result.stdout, stderr: result.stderr };
} catch (err) {