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
@@ -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");