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
+7
View File
@@ -139,6 +139,13 @@ describe("GET /api/pricing/cost daily split", () => {
"2026-10-05": 3,
});
assert.equal(ict.body.total_cost, 7);
// `date=` prices that one local day, breakdown included.
const day = await call("GET", "/api/pricing/cost?tz_offset=-420&date=2026-10-03");
assert.equal(day.body.total_cost, 2);
assert.equal(day.body.breakdown.length, 1);
assert.equal(day.body.breakdown[0].input_tokens, 2 * M);
assert.equal((await call("GET", "/api/pricing/cost?date=10/03")).status, 400);
});
it("scales hours above the total down to it, so the days still add up", async () => {
+11
View File
@@ -41,6 +41,17 @@ describe("dashboard-runs", () => {
assert.ok(old.ended_at);
});
it("narrows a lane's history to one tool", () => {
start("agenthub-lane-8", { laneId: 8, tool: "claude" });
start("agenthub-lane-8-1", { laneId: 8, tool: "shell" });
const shells = runs.listRuns({ laneId: 8, tool: "shell" });
assert.deepEqual(
shells.map((r) => r.id),
["agenthub-lane-8-1"]
);
assert.equal(runs.listRuns({ laneId: 8 }).length, 2);
});
it("flags as abandoned only the running rows whose pane is gone", () => {
start("agenthub-lane-5", { laneId: 5 });
start("agenthub-lane-6", { laneId: 6 });
@@ -15,7 +15,7 @@ const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { collectChanges, parseNumstat, readFileAt } = require("../lib/git-diff");
const { collectChanges, parseNumstat, readFileAt, readImageAt } = require("../lib/git-diff");
const { runGitAction } = require("../lib/git-ops");
const { gitFacts } = require("../lib/worktree");
@@ -179,6 +179,73 @@ describe("discard of a staged rename", () => {
});
});
describe("readImageAt: an image's bytes for the viewer", () => {
// Every byte value, so a utf8 round-trip anywhere would show.
const bytes = Buffer.from(Array.from({ length: 256 }, (_, i) => i));
it("reads the working file and a committed one byte for byte", async () => {
write(LANE, "img/logo.PNG", bytes);
g(LANE, "add", "-A");
g(LANE, "commit", "-qm", "img");
const work = await readImageAt(LANE, "img/logo.PNG", "work");
assert.equal(work.type, "image/png");
assert.deepEqual(work.buf, bytes);
const head = await readImageAt(LANE, "img/logo.PNG", "HEAD");
assert.deepEqual(head.buf, bytes);
});
it("refuses a non-image, a missing file and a symlink out of the lane", async () => {
await assert.rejects(readImageAt(LANE, "a.txt", "work"), { code: "ENOTIMAGE" });
await assert.rejects(readImageAt(LANE, "none.png", "work"), { code: "ENOFILE" });
fs.writeFileSync(path.join(ROOT, "outside.png"), bytes);
fs.symlinkSync(path.join(ROOT, "outside.png"), path.join(LANE, "leak.png"));
await assert.rejects(readImageAt(LANE, "leak.png", "work"));
await assert.rejects(readImageAt(LANE, "../outside.png", "work"));
});
});
describe("GET /api/lanes/:id/git/raw", () => {
it("serves an image with its type, and an SVG sandboxed", async () => {
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "raw.db");
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const server = await startServer(createApp(), 0);
const base = `http://127.0.0.1:${server.address().port}`;
try {
write(LANE, "pic.png", Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 255]));
write(
LANE,
"x.svg",
'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'
);
const created = await fetch(`${base}/api/lanes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cwd: LANE, title: "raw" }),
}).then((r) => r.json());
const id = created.lane.id;
const png = await fetch(`${base}/api/lanes/${id}/git/raw?path=pic.png`);
assert.equal(png.status, 200);
assert.equal(png.headers.get("content-type"), "image/png");
assert.deepEqual(
Buffer.from(await png.arrayBuffer()),
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 255])
);
const svg = await fetch(`${base}/api/lanes/${id}/git/raw?path=x.svg`);
assert.equal(svg.headers.get("content-type"), "image/svg+xml");
assert.match(svg.headers.get("content-security-policy"), /sandbox/);
assert.equal(svg.headers.get("x-content-type-options"), "nosniff");
const txt = await fetch(`${base}/api/lanes/${id}/git/raw?path=a.txt`);
assert.equal(txt.status, 400);
assert.equal((await txt.json()).error.code, "ENOTIMAGE");
await fetch(`${base}/api/lanes/${id}`, { method: "DELETE" });
} finally {
server.close();
}
});
});
describe("GET /api/lanes/:id/git/file maps a secret to 403 ESECRET", () => {
it("answers 403 for the router's settings file", async () => {
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "api.db");
+67 -6
View File
@@ -129,6 +129,23 @@ describe("/api/lanes", () => {
assert.equal(typeof r.body.counts.needs_you, "number");
});
it("serves the same routes under /api/workspaces, the UI's word", async () => {
const lanes = await request("GET", "/api/lanes");
const list = await request("GET", "/api/workspaces");
assert.equal(list.status, 200);
assert.deepEqual(
list.body.lanes.map((l) => l.id),
lanes.body.lanes.map((l) => l.id)
);
const one = await request("GET", `/api/workspaces/${laneId}`);
assert.equal(one.status, 200);
assert.equal(one.body.lane.id, laneId);
// Mounted through the sources router, which sits before the lanes router.
const sources = await request("GET", `/api/workspaces/${laneId}/sources`);
assert.equal(sources.status, 200);
assert.deepEqual(sources.body, (await request("GET", `/api/lanes/${laneId}/sources`)).body);
});
it("exposes pipeline templates", async () => {
const r = await request("GET", "/api/lanes/pipelines");
assert.equal(r.status, 200);
@@ -220,6 +237,42 @@ describe("/api/lanes", () => {
assert.equal((await request("DELETE", `/api/lanes/${id}`)).status, 200);
});
it("opens the native picker only for a local, unproxied, same-origin browser", async () => {
const nd = require("../lib/native-dialog");
const desktop = require("../lib/desktop-env");
const calls = [];
nd.__setExecImpl(async (cmd) => {
calls.push(cmd);
return { code: 0, stdout: "/tmp/picked\n", stderr: "" };
});
desktop.__setExecImpl(() => "DISPLAY=:0\n");
try {
for (const headers of [
{ Origin: "https://attacker.example" },
{ "X-Forwarded-For": "203.0.113.9" },
{ Forwarded: "for=203.0.113.9" },
]) {
const r = await request("POST", "/api/lanes/pick-path", { kind: "dir" }, headers);
assert.equal(r.status, 403, JSON.stringify(headers));
}
assert.deepEqual(calls, []);
if (process.platform === "linux") {
const r = await request("POST", "/api/lanes/pick-path", { kind: "dir" });
assert.equal(r.status, 200);
assert.equal(r.body.path, "/tmp/picked");
nd.__setExecImpl(async () => ({ missing: true }));
const none = await request("POST", "/api/lanes/pick-path", { kind: "file" });
assert.equal(none.status, 501);
assert.equal(none.body.error.code, "ENODIALOG");
}
} finally {
nd.__setExecImpl(null);
desktop.__setExecImpl(() => {
throw new Error("no systemd user manager in this test");
});
}
});
it("broadcasts lane_update on create", async () => {
const wsUrl = `ws://127.0.0.1:${server.address().port}/ws`;
const ws = new WebSocket(wsUrl);
@@ -425,8 +478,13 @@ describe("lane start input", () => {
assert.equal(r.body.run_id, `agenthub-lane-${id}-1`);
assert.equal(created[created.indexOf("-s") + 1], `agenthub-lane-${id}-1`);
assert.equal(created[created.indexOf("-c") + 1], cwd);
// Nothing after `--`: tmux runs the user's own default shell.
assert.equal(created[created.length - 1], "--");
// After `--` only the fixed size-wait wrapper that execs the user's own
// default shell (issue #55) — nothing from the request.
const argv = created.slice(created.indexOf("--") + 1);
assert.deepEqual(argv.slice(0, 2), ["sh", "-c"]);
assert.equal(argv.length, 3);
assert.match(argv[2], /exec "\$SHELL" -l$/);
assert.ok(!created.includes("ignored"));
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(lane.run_id, null);
assert.notEqual(lane.status, "running");
@@ -1958,13 +2016,16 @@ describe("start: slot auto and a session already live", () => {
assert.equal(r.body.run_id, `agenthub-lane-${id}-2`);
});
it("answers ERUNLIVE when every slot is busy", async () => {
live.add(`agenthub-lane-${id}-3`);
it("answers ERUNLIVE when all ten slots are busy", async () => {
for (let s = 3; s <= 8; s++) live.add(`agenthub-lane-${id}-${s}`);
// Slot 9 is the tenth and last.
const ninth = await request("POST", `/api/lanes/${id}/start`, { slot: "auto", shell: true });
assert.equal(ninth.body.run_id, `agenthub-lane-${id}-9`);
live.add(`agenthub-lane-${id}-9`);
const r = await request("POST", `/api/lanes/${id}/start`, { slot: "auto" });
assert.equal(r.status, 409);
assert.equal(r.body.error.code, "ERUNLIVE");
live.delete(`agenthub-lane-${id}-3`);
live.delete(`agenthub-lane-${id}-2`);
for (let s = 2; s <= 9; s++) live.delete(`agenthub-lane-${id}-${s}`);
});
it("refuses to resume a session a live pane already holds, naming that pane", async () => {
+30 -1
View File
@@ -193,6 +193,24 @@ after(() => {
}
});
describe("agenthub workspaces", () => {
it("is the lanes command under the UI's word", async () => {
const [lanes, workspaces, workspace] = await Promise.all([
cli(["lanes"], LANE_DIR),
cli(["workspaces"], LANE_DIR),
cli(["workspace"], LANE_DIR),
]);
assert.equal(workspaces.status, 0, workspaces.stderr);
assert.match(workspaces.stdout, /CLI lane/);
assert.equal(workspaces.stdout, lanes.stdout);
assert.equal(workspace.stdout, lanes.stdout);
// The old name still works but says so — on stderr, never in stdout.
assert.match(lanes.stderr, /old name.*agenthub workspace/);
assert.doesNotMatch(workspaces.stderr, /old name/);
assert.doesNotMatch(lanes.stdout, /old name/);
});
});
describe("agenthub stage", () => {
it("reports a stage for the lane owning the current directory", async () => {
const r = await cli(["stage", "review", "--evidence", "3 findings"], LANE_DIR);
@@ -280,6 +298,17 @@ describe("agenthub stage in a directory shared by two lanes", () => {
assert.notEqual(await stageOf(codexLane.id), "implement");
});
it("--workspace names the lane like --lane", async () => {
const r = await cli(
["stage", "review", "--workspace", String(codexLane.id)],
DUAL_DIR,
NO_HARNESS
);
assert.equal(r.status, 0, r.stderr);
assert.equal(await stageOf(codexLane.id), "review");
assert.notEqual(await stageOf(claudeLane.id), "review");
});
it("fails loudly, naming both candidates, when nothing identifies the tool", async () => {
const before = [await stageOf(claudeLane.id), await stageOf(codexLane.id)];
const r = await cli(["stage", "done"], DUAL_DIR, NO_HARNESS);
@@ -686,7 +715,7 @@ describe("agenthub lanes node", () => {
assert.notEqual(r.status, 0);
assert.match(r.stdout, /\bintake\b/);
assert.match(r.stdout, /\bdone\b/);
assert.match(r.stderr, /usage: agenthub lanes node/);
assert.match(r.stderr, /usage: agenthub workspace node/);
});
it("--print shows a slash-command node as a claude prompt, extra args appended", async () => {
+105
View File
@@ -0,0 +1,105 @@
/**
* @file native-dialog.test.js
* @description Unit tests for the server-side native folder/file picker: the
* argv each platform gets, the fallback from zenity to kdialog, Cancel versus a
* chosen path, and the "no dialog program" answer the client falls back on.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const os = require("node:os");
const nd = require("../lib/native-dialog");
const desktop = require("../lib/desktop-env");
const linux = process.platform === "linux";
describe("native-dialog", () => {
afterEach(() => nd.__setExecImpl(null));
it("starts in the given folder, a file's folder, or home", () => {
assert.equal(nd.startDir(os.tmpdir()), os.tmpdir());
assert.equal(nd.startDir("/definitely/not/here/x"), os.homedir());
assert.equal(nd.startDir("~"), os.homedir());
assert.equal(nd.startDir(undefined), os.homedir());
});
it("builds zenity then kdialog on Linux, the request's folder one argv element", () => {
const [[z, zargs], [k, kargs]] = nd.dialogCommands("dir", "/tmp/a b", "linux");
assert.equal(z, "zenity");
assert.deepEqual(zargs, [
"--file-selection",
"--title=Select a folder",
"--filename=/tmp/a b/",
"--directory",
]);
assert.equal(k, "kdialog");
assert.deepEqual(kargs.slice(0, 2), ["--getexistingdirectory", "/tmp/a b"]);
assert.ok(!nd.dialogCommands("file", "/tmp", "linux")[0][1].includes("--directory"));
});
it("passes the folder to osascript as an argument, never inside the script", () => {
const [[cmd, args]] = nd.dialogCommands("file", '/x"; do shell script "id', "darwin");
assert.equal(cmd, "osascript");
assert.equal(args.at(-1), '/x"; do shell script "id');
assert.ok(!args.slice(0, -2).join(" ").includes("shell script"));
});
it(
"answers the chosen path, null on Cancel, and falls through a missing program",
{
skip: !linux,
},
async () => {
desktop.__setExecImpl(() => "DISPLAY=:0\n");
const tried = [];
nd.__setExecImpl(async (cmd) => {
tried.push(cmd);
return cmd === "zenity" ? { missing: true } : { code: 0, stdout: "/home/u/proj\n" };
});
assert.equal(await nd.pickPath("dir"), "/home/u/proj");
assert.deepEqual(tried, ["zenity", "kdialog"]);
nd.__setExecImpl(async () => ({ code: 1, stdout: "" }));
assert.equal(await nd.pickPath("file"), null);
nd.__setExecImpl(async () => ({ missing: true }));
await assert.rejects(nd.pickPath("dir"), { code: "ENODIALOG" });
}
);
it(
"answers ENODIALOG, not a Cancel, when the program could not reach the desktop",
{ skip: !linux },
async () => {
desktop.__setExecImpl(() => "DISPLAY=:0\n");
nd.__setExecImpl(async () => ({
code: 1,
stdout: "",
stderr: "(zenity:1): Gtk-WARNING **: cannot open display: :0\n",
}));
await assert.rejects(nd.pickPath("dir"), { code: "ENODIALOG" });
}
);
it("passes the abort signal through to the dialog program", { skip: !linux }, async () => {
desktop.__setExecImpl(() => "DISPLAY=:0\n");
const ac = new AbortController();
let seen;
nd.__setExecImpl(async (cmd, args, env, signal) => {
seen = signal;
return { code: 1, stdout: "", stderr: "" };
});
assert.equal(await nd.pickPath("dir", undefined, ac.signal), null);
assert.equal(seen, ac.signal);
});
it("tells a Cancel from a failure per program", () => {
assert.equal(nd.isCancel("zenity", ""), 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("osascript", "execution error: User canceled. (-128)"), true);
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", "Add-Type : Cannot add type"), false);
});
});
+1 -1
View File
@@ -47,7 +47,7 @@ describe("plugin-doctor: tmux row", () => {
const row = rowWithTmux(null);
assert.equal(row.level, "warn", "a missing tmux must not fail the doctor run");
assert.match(row.detail, /lanes shell/, "the reader needs to know WHAT breaks");
assert.match(row.detail, /workspace shell/, "the reader needs to know WHAT breaks");
assert.match(row.detail, /install/i, "and how to fix it");
});
+3 -1
View File
@@ -56,7 +56,9 @@ describe("pty-attach", () => {
it("accepts a well-formed runId", () => {
assert.doesNotThrow(() => ptyAttach.validateRunId("agenthub-lane-42"));
assert.doesNotThrow(() => ptyAttach.validateRunId("agenthub-lane-42-3"));
assert.throws(() => ptyAttach.validateRunId("agenthub-lane-42-4"), /EBADRUNID/);
assert.doesNotThrow(() => ptyAttach.validateRunId("agenthub-lane-42-9"));
assert.throws(() => ptyAttach.validateRunId("agenthub-lane-42-10"), /EBADRUNID/);
assert.throws(() => ptyAttach.validateRunId("agenthub-lane-42-0"), /EBADRUNID/);
});
it("turns tmux mouse mode on for the session it attaches to", () => {
+46 -2
View File
@@ -58,7 +58,49 @@ describe("pty-run", () => {
assert.ok(newSessionCall.includes("opus"));
});
it("spawnRun names a split pane's session agenthub-lane-<id>-<slot>, and refuses a slot past 3", () => {
it("spawnShell starts the login shell only once the pane has its real size (issue #55)", async () => {
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
return "";
});
pty.spawnShell({ laneId: 43, cwd: "/tmp/repo" });
const call = calls.find((c) => c[0] === "new-session");
const argv = call.slice(call.indexOf("--") + 1);
assert.equal(argv[0], "sh");
assert.equal(argv[1], "-c");
// Run the wrapper with a stand-in shell and a fake `tmux` that reports no
// client until a flag file appears: the shell must wait for the attach,
// then start at once rather than at the 2 s cap.
const { spawn } = require("node:child_process");
const bin = fs.mkdtempSync(path.join(os.tmpdir(), "fake-tmux-"));
const flag = path.join(bin, "attached");
fs.writeFileSync(
path.join(bin, "tmux"),
`#!/bin/sh\n[ -e '${flag}' ] && echo 1 || echo 0\n`,
{ mode: 0o755 }
);
try {
const t0 = Date.now();
const child = spawn("sh", ["-c", argv[2]], {
env: { ...process.env, SHELL: "/bin/echo", PATH: `${bin}:${process.env.PATH}` },
});
let out = "";
let startedAt = 0;
child.stdout.on("data", (d) => {
out += d;
startedAt ||= Date.now() - t0;
});
setTimeout(() => fs.writeFileSync(flag, ""), 400);
await new Promise((r) => child.on("exit", r));
assert.equal(out.trim(), "-l");
assert.ok(startedAt >= 400 && startedAt < 1500, `shell started after ${startedAt} ms`);
} finally {
fs.rmSync(bin, { recursive: true, force: true });
}
});
it("spawnRun names a split pane's session agenthub-lane-<id>-<slot>, and refuses a slot past 9 (ten per workspace)", () => {
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
const e = new Error("no such session");
@@ -72,8 +114,10 @@ describe("pty-run", () => {
assert.equal(handle.laneId, 42);
assert.equal(handle.slot, 2);
assert.equal(pty.laneIdFromRunId("agenthub-lane-42-2"), 42);
assert.equal(pty.spawnRun({ laneId: 42, slot: 9, cwd: "/tmp/repo" }).id, "agenthub-lane-42-9");
assert.equal(pty.laneIdFromRunId("agenthub-lane-42-9"), 42);
assert.throws(
() => pty.spawnRun({ laneId: 42, slot: 4, cwd: "/tmp/repo" }),
() => pty.spawnRun({ laneId: 42, slot: 10, cwd: "/tmp/repo" }),
(err) => err.code === "EBADSLOT"
);
});