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
+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 () => {