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"
);
});
+7 -3
View File
@@ -130,9 +130,13 @@ function createApp() {
app.use("/api/git-credentials", gitCredentialsRouter);
app.use("/api/ai-tools", aiToolsRouter);
app.use("/api/run", runRouter);
// Before the lanes router, whose `/:id/:action` would otherwise see these.
app.use("/api/lanes", laneSourcesRouter);
app.use("/api/lanes", lanesRouter);
// `/api/workspaces` is the same router under the UI's word; `/api/lanes`
// stays for every client and script that already names it (whether it ever
// goes is step 4c of docs/plans/workspace-rename/README.md, default keep).
// Sources first: the lanes router's `/:id/:action` would otherwise see them.
const LANE_MOUNTS = ["/api/lanes", "/api/workspaces"];
app.use(LANE_MOUNTS, laneSourcesRouter);
app.use(LANE_MOUNTS, lanesRouter);
app.use("/api/projects", projectsRouter);
app.use("/api/locks", locksRouter);
app.use("/api/pets", petsRouter);
+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) {
+61 -1
View File
@@ -12,7 +12,8 @@
const tags = [
{
name: "Lanes",
description: "Durable parallel-work lanes and dashboard-managed git worktrees",
description:
"Durable parallel-work workspaces (called lanes in the API) and dashboard-managed git worktrees. Every /api/lanes route is also mounted at /api/workspaces — the same handlers, the user-facing name.",
},
];
@@ -111,6 +112,45 @@ const schemas = {
};
const paths = {
"/api/lanes/pick-path": {
post: {
tags: ["Lanes"],
summary: "Open the OS's own folder/file picker on the server's desktop",
description:
"Shows zenity/kdialog (Linux), osascript (macOS) or a WinForms dialog (Windows) on the SERVER's desktop and waits for the user (up to 10 minutes; closed when the request goes away). Only for a browser on the same machine: a non-loopback client, or a request carrying X-Forwarded-For/Forwarded, gets EREMOTE and should fall back to GET /api/lanes/browse. One dialog at a time. Nothing from the request reaches argv but the starting folder, resolved to an existing absolute directory first.",
operationId: "pickLanePath",
requestBody: {
content: {
"application/json": {
schema: {
type: "object",
properties: {
kind: { type: "string", enum: ["dir", "file"], default: "dir" },
start: { type: "string", description: "Folder (or file) to open the dialog at." },
},
},
},
},
},
responses: {
200: {
description: "`{path}` — the chosen absolute path — or `{cancelled: true}`.",
content: {
"application/json": {
schema: {
type: "object",
properties: { path: { type: "string" }, cancelled: { type: "boolean" } },
},
},
},
},
403: { description: "EBADORIGIN (not same-origin) or EREMOTE (not a local browser)." },
409: { description: "EBUSY — a picker is already open." },
500: { description: "EDIALOG — the dialog program failed." },
501: { description: "ENODIALOG — no desktop session or no dialog program." },
},
},
},
"/api/lanes/ensure": {
post: {
tags: ["Lanes"],
@@ -551,6 +591,26 @@ const paths = {
},
},
},
"/api/lanes/{id}/git/raw": {
get: {
tags: ["Lanes"],
summary: "One image file's bytes at a revision",
description:
"For the viewer's <img>. Same path, rev and secret guards as /git/file, plus an extension allowlist (png, jpg, jpeg, gif, webp, avif, bmp, ico, svg) that sets the Content-Type, and a 20 MB cap. Served with `Content-Security-Policy: …; sandbox` and `X-Content-Type-Options: nosniff`, so an SVG opened from this URL runs no script. Read-only, no same-origin guard.",
operationId: "readLaneImage",
parameters: [
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
{ name: "path", in: "query", required: true, schema: { type: "string" } },
{ name: "rev", in: "query", schema: { type: "string", default: "work" } },
],
responses: {
200: { description: "The image bytes, typed by extension." },
400: { description: "EBADPATH, EBADREV, ENOFILE, ENOTIMAGE or ETOOBIG." },
403: { description: "ESECRET." },
404: { description: "Lane not found." },
},
},
},
"/api/lanes/{id}/git/ls": {
get: {
tags: ["Lanes"],
@@ -335,6 +335,27 @@ module.exports = {
"for all sources. Narrows the aggregate cost to the given origins.",
example: "local",
},
{
name: "lane",
in: "query",
required: false,
schema: { type: "integer" },
description:
"Only the sessions this workspace owns — the same rule as " +
"GET /api/sessions?lane=. 400 INVALID_INPUT when not an id.",
example: 5,
},
{
name: "date",
in: "query",
required: false,
schema: { type: "string", format: "date" },
description:
"Only that local day (YYYY-MM-DD, in `tz_offset`'s timezone): the " +
"total and breakdown count the tokens spent that day. 400 " +
"INVALID_INPUT for any other shape.",
example: "2026-10-06",
},
],
responses: {
200: {
+9
View File
@@ -812,6 +812,15 @@ const paths = {
"Return only the runs started through this lane (POST /api/lanes/{id}/start). Omit for every run; runs spawned from POST /api/run carry no lane and are excluded whenever this is set.",
example: 5,
},
{
name: "tool",
in: "query",
required: false,
schema: { type: "string" },
description:
"Return only the runs of this AI Tools id — `shell` for a plain terminal tab (the sidebar's recently closed Terminal tabs).",
example: "shell",
},
],
responses: {
200: {
+1 -1
View File
@@ -1318,7 +1318,7 @@ const HOOK_TOOLS = { codex: codexHookInput, antigravity: antigravityHookInput };
*/
function bindRunSession(runId, sessionId, hookType, source, tool) {
if (typeof sessionId !== "string" || !sessionId) return;
if (typeof runId !== "string" || !/^agenthub-lane-\d+(-[1-3])?$/.test(runId)) {
if (typeof runId !== "string" || !/^agenthub-lane-\d+(-[1-9])?$/.test(runId)) {
// No pane named (a hook handler that predates AGENTHUB_RUN_ID): a run
// that was started holding this very session id still owns it.
const laneId = dashboardRuns.laneForSession(sessionId);
+70
View File
@@ -30,6 +30,7 @@ const runs = require("../lib/pty-run");
const { resumeRouter } = require("../lib/router-apply");
const { sameOriginGuard } = require("./run");
const { writeLaneFile } = require("../lib/lane-file-write");
const { pickPath } = require("../lib/native-dialog");
const { preflight } = require("../lib/lane-preflight");
const {
LANES_ROOT,
@@ -51,6 +52,7 @@ const {
listTree,
listTreeAll,
readFileAt,
readImageAt,
} = require("../lib/git-diff");
const { runGitAction } = require("../lib/git-ops");
const { withLaneLock } = require("../lib/lane-lock");
@@ -501,6 +503,40 @@ router.get("/browse", (req, res) => {
res.json({ path: resolved, parent, entries });
});
/**
* The OS's own folder/file picker, shown on the SERVER's desktop
* (`server/lib/native-dialog.js`), answering `{path}` or `{cancelled: true}`.
* Only a client on this machine may ask — anywhere else the dialog would pop
* up on a screen nobody is looking at (`EREMOTE`); that client, and a server
* with no desktop (`ENODIALOG`), falls back to `GET /browse` above. A request
* a reverse proxy forwarded arrives from loopback too, so a forwarding header
* counts as remote. An SSH tunnel carries no such header and cannot be told
* apart — the dialog is closed when the browser's request goes away.
*/
router.post("/pick-path", sameOriginGuard, async (req, res) => {
const addr = req.socket.remoteAddress || "";
const proxied = req.headers["x-forwarded-for"] || req.headers.forwarded;
if (proxied || !["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(addr)) {
return res.status(403).json({
error: { code: "EREMOTE", message: "the native picker opens only for a local browser" },
});
}
const kind = req.body?.kind === "file" ? "file" : "dir";
const ac = new AbortController();
res.on("close", () => {
if (!res.writableFinished) ac.abort();
});
try {
const picked = await pickPath(kind, req.body?.start, ac.signal);
if (ac.signal.aborted) return;
res.json(picked ? { path: picked } : { cancelled: true });
} catch (err) {
if (ac.signal.aborted) return;
const status = err.code === "EBUSY" ? 409 : err.code === "ENODIALOG" ? 501 : 500;
res.status(status).json({ error: { code: err.code || "EDIALOG", message: err.message } });
}
});
router.get("/:id", (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane)
@@ -901,6 +937,40 @@ router.get("/:id/git/file", async (req, res) => {
}
});
/**
* One image's bytes (`?path=&rev=work|HEAD|<sha>`) for the viewer's `<img>`:
* `readImageAt`'s guards (the file read's, plus an image-extension allowlist
* that picks the Content-Type). Served under a `sandbox` CSP with `nosniff`,
* so an SVG opened straight from this URL runs no script on the dashboard's
* origin.
*/
router.get("/:id/git/raw", async (req, res) => {
const t = await gitTargetOr404(req, res);
if (!t) return;
try {
const { type, buf } = await readImageAt(
t.cwd,
typeof req.query.path === "string" ? req.query.path : "",
typeof req.query.rev === "string" && req.query.rev ? req.query.rev : "work"
);
res.set({
"Content-Type": type,
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",
"X-Content-Type-Options": "nosniff",
"Cache-Control": "no-store",
});
res.send(buf);
} catch (err) {
if (err.code === "ESECRET") {
return res.status(403).json({ error: { code: err.code, message: err.message } });
}
if (["EBADPATH", "EBADREV", "ENOFILE", "ENOTIMAGE", "ETOOBIG"].includes(err.code)) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
res.status(500).json({ error: { code: "EREAD", message: "could not read the image" } });
}
});
/**
* Save one text file in the lane from the viewer's editor. Guards live in
* lane-file-write.js (the read path's own, plus existing-textual-file-only and
+9 -1
View File
@@ -477,6 +477,12 @@ router.get("/cost", (req, res) => {
.status(400)
.json({ error: { code: "INVALID_INPUT", message: "lane must be a lane id" } });
}
const date = req.query.date;
if (date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(String(date))) {
return res
.status(400)
.json({ error: { code: "INVALID_INPUT", message: "date must be YYYY-MM-DD" } });
}
const scope = sourceColumnClause(parseSources(req), "s.source");
const params = [...scope.params];
const where = scope.clause ? [scope.clause] : [];
@@ -489,7 +495,9 @@ router.get("/cost", (req, res) => {
}
const whereClause = where.length ? `WHERE ${where.join(" AND ")}` : "";
const dailyTokens = dailyTokenRows(tzModifier, whereClause, params);
let dailyTokens = dailyTokenRows(tzModifier, whereClause, params);
// `date=`: only that local day (the status bar's Today and its per-model list).
if (date !== undefined) dailyTokens = dailyTokens.filter((r) => r.date === date);
const rules = stmts.listPricing.all();
// Price the date-split rows so each day's usage bills at the rate effective on
// that date (e.g. Sonnet 5's intro discount before 2026-08-31, standard after).
+3 -1
View File
@@ -84,7 +84,8 @@ router.get("/", (_req, res) => {
*
* Optional ?limit=<n> caps results (default 50, max 500). The most recent
* runs are first. Optional ?laneId=<n> narrows to the runs started through one
* lane, which is what the Workspace page's per-lane history shows.
* lane, and ?tool=<id> (with a laneId) to one tool's — the sidebar reads a
* workspace's closed Terminal tabs with `tool=shell`.
*/
// ── Drag & drop into the terminal ─────────────────────────────────────────
// A local terminal answers a dropped image by typing its PATH, which is how
@@ -149,6 +150,7 @@ router.get("/history", (req, res) => {
const items = dr.listRuns({
limit: Number.isFinite(limit) ? limit : 50,
laneId: Number.isFinite(laneId) ? laneId : null,
tool: typeof req.query.tool === "string" ? req.query.tool : null,
});
const liveIds = new Set(runs.listRuns().map((h) => h.id));
res.json({
+1 -1
View File
@@ -80,7 +80,7 @@ function initWebSocket(server) {
}
let ptyWss = null;
const PTY_PATH_RE = /^\/ws-pty\/(agenthub-lane-\d+(?:-[1-3])?)$/;
const PTY_PATH_RE = /^\/ws-pty\/(agenthub-lane-\d+(?:-[1-9])?)$/;
/**
* The attaching terminal's own size, off the attach URL's query. tmux resizes