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
+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,