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
+84 -43
View File
@@ -1291,7 +1291,7 @@ async function cmdDoctor() {
console.log(
available
? `${c.green("✔")} tmux available to the server`
: `${c.yellow("!")} tmux not on the server's PATH — Start Run and \`agenthub lanes shell\` will fail. ` +
: `${c.yellow("!")} tmux not on the server's PATH — Start Run and \`agenthub workspace shell\` will fail. ` +
`Install it (sudo apt install tmux / brew install tmux), then restart the server`
);
} catch {
@@ -1616,7 +1616,7 @@ async function cmdLanesAdd(args) {
if (repo) {
if (cwd || pipeline) {
console.error(
"usage: agenthub lanes add --repo <path> [--title <text>] [--base <branch>] [--slug <slug>] [--tool <id>]"
"usage: agenthub workspace add --repo <path> [--title <text>] [--base <branch>] [--slug <slug>] [--tool <id>]"
);
process.exitCode = 1;
return;
@@ -1653,7 +1653,7 @@ async function cmdLanesAdd(args) {
}
if (!cwd || !title) {
console.error(
"usage: agenthub lanes add --cwd <path> --title <text> [--pipeline <id>] [--tool <id>]"
"usage: agenthub workspace add --cwd <path> --title <text> [--pipeline <id>] [--tool <id>]"
);
process.exitCode = 1;
return;
@@ -1704,7 +1704,7 @@ function cmdLanesProfileInit(args) {
const repo = args.find((arg) => !arg.startsWith("--"));
const force = args.includes("--force");
if (!repo) {
console.error("usage: agenthub lanes profile init <repo> [--force]");
console.error("usage: agenthub workspace profile init <repo> [--force]");
process.exitCode = 1;
return;
}
@@ -1746,7 +1746,7 @@ function cmdLanesProfileInit(args) {
console.log(`\n${c.yellow(`⚠ ${result.todos.length} item(s) need manual attention:`)}`);
for (const todo of result.todos) console.log(` ${todo}`);
}
console.log(`\nNext: agenthub lanes profile check ${repo}`);
console.log(`\nNext: agenthub workspace profile check ${repo}`);
}
/**
@@ -1795,7 +1795,7 @@ async function cmdLanesLifecycle(action, args) {
const fields = LANE_PREFLIGHT_FIELDS[action];
if (!id) {
const extra = action === "reset" ? " [--keep-db]" : "";
console.error(`usage: agenthub lanes ${action} <id> [--force]${extra} --yes`);
console.error(`usage: agenthub workspace ${action} <id> [--force]${extra} --yes`);
process.exitCode = 1;
return;
}
@@ -1834,7 +1834,7 @@ async function cmdLanesLifecycle(action, args) {
preflight.blocked.includes("adopted")
) {
console.error(
`Workspace #${id} points at a directory you own. It cannot be reset; use \`agenthub lanes remove ${id} --yes\` to drop only the dashboard's record of it.`
`Workspace #${id} points at a directory you own. It cannot be reset; use \`agenthub workspace remove ${id} --yes\` to drop only the dashboard's record of it.`
);
process.exitCode = 1;
return;
@@ -1902,7 +1902,7 @@ async function cmdLanes() {
const { lanes, counts } = await get("/api/lanes");
if (!lanes.length) {
console.log(
"no workspaces yet — create one with: agenthub lanes add --cwd <path> --title <text>"
"no workspaces yet — create one with: agenthub workspace add --cwd <path> --title <text>"
);
return;
}
@@ -1957,7 +1957,7 @@ async function resolveLaneArg(args) {
return i > -1 ? args[i + 1] : undefined;
};
const explicit = flagValue("lane");
const explicit = flagValue("lane") || flagValue("workspace");
if (explicit) return { laneId: explicit, rest: args };
if (args.length && /^\d+$/.test(args[0])) return { laneId: args[0], rest: args.slice(1) };
@@ -1981,7 +1981,9 @@ async function resolveLaneArg(args) {
.filter((l) => cwd === l.cwd || cwd.startsWith(`${l.cwd}/`))
.sort((a, b) => b.cwd.length - a.cwd.length);
if (!owning.length) {
console.error(`no workspace owns ${cwd} — create one with: agenthub lanes add --cwd ${cwd}`);
console.error(
`no workspace owns ${cwd} — create one with: agenthub workspace add --cwd ${cwd}`
);
process.exitCode = 1;
return null;
}
@@ -2002,7 +2004,7 @@ async function resolveLaneArg(args) {
console.error(
`${candidates.length} workspaces own ${owning[0].cwd} — pass --tool <${candidates
.map((l) => l.tool || "claude")
.join("|")}> or --lane <id>: ${candidates
.join("|")}> or --workspace <id>: ${candidates
.map((l) => `#${l.id} (${l.tool || "claude"})`)
.join(", ")}`
);
@@ -2020,7 +2022,7 @@ function printRuntime(runtime) {
return;
}
if (!runtime.provisioned) {
console.log("profile found, runtime not provisioned yet — run: agenthub lanes up");
console.log("profile found, runtime not provisioned yet — run: agenthub workspace up");
return;
}
console.log(`slot ${runtime.slot} · profile ${runtime.profileDir}`);
@@ -2121,7 +2123,7 @@ async function cmdLanesRuntime(sub, args) {
if (sub === "logs") {
const svc = laneArgs.find((arg) => !arg.startsWith("--"));
if (!svc) {
console.error("usage: agenthub lanes logs [<id>] <service> [--tail bytes]");
console.error("usage: agenthub workspace logs [<id>] <service> [--tail bytes]");
process.exitCode = 1;
return;
}
@@ -2140,7 +2142,7 @@ async function cmdLanesRuntime(sub, args) {
if (sub === "hook") {
const name = laneArgs[0];
if (!name || name.startsWith("--")) {
console.error("usage: agenthub lanes hook [<id>] <name> [args…]");
console.error("usage: agenthub workspace hook [<id>] <name> [args…]");
process.exitCode = 1;
return;
}
@@ -2155,7 +2157,7 @@ async function cmdLanesRuntime(sub, args) {
return;
}
console.log(
`workspace #${laneId} running hook ${name} — follow it with: agenthub lanes logs ${laneId} ${name}`
`workspace #${laneId} running hook ${name} — follow it with: agenthub workspace logs ${laneId} ${name}`
);
}
@@ -2191,7 +2193,7 @@ async function cmdLanesRuntime(sub, args) {
console.error(` conflicted: ${result.conflictedFiles.join(", ")}`);
console.error(" resolve, then: git add <resolved files> && git commit --no-edit");
console.error(
` then: agenthub lanes sync-base --continue ${branch ? branch + " " : ""}${laneId}`
` then: agenthub workspace sync-base --continue ${branch ? branch + " " : ""}${laneId}`
);
process.exitCode = 4;
return;
@@ -2437,7 +2439,7 @@ async function cmdLanesMcpSync(args) {
async function cmdLanesIntegration(args) {
const name = args.find((arg) => !arg.startsWith("--"));
if (!name) {
console.error("usage: agenthub lanes integration <name> [<id>]");
console.error("usage: agenthub workspace integration <name> [<id>]");
process.exitCode = 1;
return;
}
@@ -2456,13 +2458,13 @@ async function cmdLanesIntegration(args) {
* mirrors the derived state: 0 success, 1 failure or any error, 3 pending. */
async function cmdLanesCi(args) {
if (args[0] !== "status") {
console.error("usage: agenthub lanes ci status <branch-or-sha> [<id>]");
console.error("usage: agenthub workspace ci status <branch-or-sha> [<id>]");
process.exitCode = 1;
return;
}
const ref = args[1];
if (!ref) {
console.error("usage: agenthub lanes ci status <branch-or-sha> [<id>]");
console.error("usage: agenthub workspace ci status <branch-or-sha> [<id>]");
process.exitCode = 1;
return;
}
@@ -2538,7 +2540,7 @@ async function cmdLanesNode(args) {
if (!key) {
listNodes();
console.error(`\nusage: agenthub lanes node <node-id> [extra args…] [<id>] [--print]`);
console.error(`\nusage: agenthub workspace node <node-id> [extra args…] [<id>] [--print]`);
process.exitCode = 1;
return;
}
@@ -2683,7 +2685,7 @@ async function cmdStage(args) {
const stage = args[0];
if (!stage || stage.startsWith("--")) {
console.error(
"usage: agenthub stage <stage> [--lane <id>] [--cwd <path>] [--tool <id>] [--status <s>] [--evidence <text>] [--note <text>] [--result pass|fail]"
"usage: agenthub stage <stage> [--workspace <id>] [--cwd <path>] [--tool <id>] [--status <s>] [--evidence <text>] [--note <text>] [--result pass|fail]"
);
process.exitCode = 1;
return;
@@ -2805,82 +2807,86 @@ const COMMAND_GROUPS = [
[
"Workspaces",
[
["lanes", "", "List workspaces with stage, liveness and progress"],
["workspaces", "", "List workspaces with stage, liveness and progress"],
[
"lanes add",
"workspace add",
"--cwd <path> --title <text> [--tool <id>]",
"Create a new workspace bound to a directory (--tool: an AI Tools id, default claude)",
],
[
"lanes add",
"workspace add",
"--repo <path> [--title <text>] [--base <branch>] [--slug <slug>] [--tool <id>]",
"Provision a managed worktree workspace",
],
[
"lanes profile init",
"workspace profile init",
"<repo> [--force]",
"Detect a Node.js project and scaffold .agenthub/profile/",
],
[
"lanes profile check",
"workspace profile check",
"[<path>]",
"Validate a profile (path defaults to cwd, not a workspace id)",
],
[
"lanes pipeline",
"workspace pipeline",
"[<template-id>] [<id>]",
"Show, or switch, which pipeline template a workspace renders against",
],
[
"lanes node",
"workspace node",
"[<node-id>] [extra…] [<id>] [--print]",
"Run ONE pipeline node's own command in the workspace's cwd (no id: list every node and its command)",
],
[
"lanes shell",
"workspace shell",
"[<id>]",
"Attach a real terminal to the exact tmux session the dashboard uses for a workspace's run",
],
[
"lanes reset|remove|purge",
"workspace reset|remove|purge",
"<id> [--force] [--keep-db] --yes",
"Show preflight facts, then perform a destructive workspace action (--keep-db: reset only; remove drops the dashboard's record only — worktree, branch and database are left alone)",
],
[
"lanes up|down",
"workspace up|down",
"[<id>] [--no-build] [--qc]",
"Boot or stop the workspace's own app stack (--no-build: up only, skip the build step; --qc: up only, inject QC_BOOT_ENV for a deterministic stack; id defaults to the workspace owning this directory)",
],
["lanes runtime", "[<id>]", "Slot, ports, service health and the last boot error"],
["lanes logs", "[<id>] <svc> [--tail N]", "Tail one of the workspace's hook or service logs"],
["lanes hook", "[<id>] <name> [args…]", "Run a profile hook (ci-gate, e2e, migrate, …)"],
["workspace runtime", "[<id>]", "Slot, ports, service health and the last boot error"],
[
"lanes sync-base",
"workspace logs",
"[<id>] <svc> [--tail N]",
"Tail one of the workspace's hook or service logs",
],
["workspace hook", "[<id>] <name> [args…]", "Run a profile hook (ci-gate, e2e, migrate, …)"],
[
"workspace sync-base",
"[<id>] [--check|--continue] [branch]",
"Fetch + migration-collision preflight, or merge origin/development into the feature branch (--check: read-only; --continue: finish after a resolved conflict; bare: merge, branch defaults to the workspace's current branch)",
],
[
"lanes proof-link",
"workspace proof-link",
"[<id>]",
"Converge clone-root proof/ onto .playwright-mcp/proof (idempotent, never automatic)",
],
[
"lanes mcp sync",
"workspace mcp sync",
"[<id>]",
"Relocate the source repo's MCP servers into <workspace>/.mcp.json and seed Chromium profiles",
],
[
"lanes integration",
"workspace integration",
"<name> [<id>]",
"Check whether a named integration (tracker, dev_qc, ci_wait) is on for this workspace — exit 0/1",
],
[
"lanes ci status",
"workspace ci status",
"<branch-or-sha> [<id>]",
"One-shot GitHub Actions status for a ref — exit 0 success / 1 failure / 3 pending",
],
[
"lanes gc",
"workspace gc",
"[--dry-run]",
"Reap orphaned Playwright MCP processes + cap oversized hook logs, across every workspace",
],
@@ -2944,6 +2950,9 @@ falling back to http://127.0.0.1:4820.
${c.bold("Output:")} colors auto-enable on a TTY and turn off when piped.
Disable with --no-color or NO_COLOR=1; force with FORCE_COLOR=1 / AGENTHUB_COLOR=1.
${c.bold("Old name:")} ${c.bold("agenthub lanes …")} and ${c.bold("--lane")} still work — the same
commands as ${c.bold("agenthub workspace …")} and ${c.bold("--workspace")}.
${c.bold("Note:")} agenthub talks to the local dashboard server — API-backed commands
require it to be running (${c.bold("agenthub start")} brings one up in the background).
Prefer an interactive session? Run ${c.bold("agenthub repl")} for a shell with completion,
@@ -3352,10 +3361,18 @@ function replHelp() {
/** Detailed help for one command (or command prefix): every catalog entry
* whose invocation starts with the token. Returns false if none matched. */
function commandHelp(token) {
// `lanes`, `workspace` and `workspaces` are one command (COMMAND_ALIASES),
// so `help lanes` finds the `workspace …` rows and vice versa.
const canon = (words) => {
const [head, ...tail] = words.split(" ");
return [COMMAND_ALIASES[head] || head, ...tail].join(" ");
};
const want = canon(token);
const matches = [];
for (const [group, items] of COMMAND_GROUPS) {
for (const [name, args, desc] of items) {
if (name === token || name.startsWith(token + " ")) matches.push([group, name, args, desc]);
const have = canon(name);
if (have === want || have.startsWith(want + " ")) matches.push([group, name, args, desc]);
}
}
if (!matches.length) return false;
@@ -3606,6 +3623,26 @@ async function cmdRepl() {
// prompt. Handlers that need the server throw ServerDownError; the caller
// decides whether to fall back to an offline reader or report the refusal.
/** Command names that are another command under the user-facing word.
* `workspace` is the name to use; `lanes` (the handler's own name) and
* `--lane` are the OLD names, still working but deprecated. Removing them is
* step 4 of docs/plans/workspace-rename/README.md — not before the third
* release after the first one that shipped these aliases. */
const COMMAND_ALIASES = { workspace: "lanes", workspaces: "lanes" };
const canonicalArgv = (argv) =>
COMMAND_ALIASES[argv[0]] ? [COMMAND_ALIASES[argv[0]], ...argv.slice(1)] : argv;
/** One stderr line when an old name is typed, so people and agents move over
* before step 4 removes it. stderr only: scripts reading stdout see nothing. */
function warnOldNames(argv) {
const old = [];
if (argv[0] === "lanes") old.push("`agenthub lanes` → `agenthub workspace`");
if (argv.includes("--lane")) old.push("`--lane` → `--workspace`");
if (old.length) {
console.error(c.dim(`note: old name, still works for now — use ${old.join(", ")}`));
}
}
/** All top-level command tokens, derived from COMMAND_GROUPS (first word of
* each invocation) plus the always-available `help`. Used by the REPL
* completer and unknown-command detection so they never drift from help. */
@@ -3615,6 +3652,8 @@ const COMMANDS = (() => {
for (const [name] of items) set.add(name.split(" ")[0]);
}
set.add("help");
// The old names stay typeable (and completable) for scripts written before.
for (const [alias, canonical] of Object.entries(COMMAND_ALIASES)) set.add(alias).add(canonical);
return [...set];
})();
@@ -3632,7 +3671,8 @@ const SUBCOMMANDS = {
* for usage/validation failures. `cmd === "repl"` is handled by the entry
* points, not here, to avoid recursive REPLs. */
async function runCommand(argv) {
const [cmd, ...rest] = argv;
warnOldNames(argv);
const [cmd, ...rest] = canonicalArgv(argv);
const { flags, positional } = parseArgs(rest);
switch (cmd) {
case "health":
@@ -3702,7 +3742,7 @@ async function runCommand(argv) {
if (rest[1] === "init") return cmdLanesProfileInit(rest.slice(2));
if (rest[1] === "check") return cmdLanesProfileCheck(rest.slice(2));
console.error(
"usage: agenthub lanes profile init <repo> [--force] | agenthub lanes profile check [<path>]"
"usage: agenthub workspace profile init <repo> [--force] | agenthub workspace profile check [<path>]"
);
process.exitCode = 1;
return;
@@ -3786,6 +3826,7 @@ async function runCommand(argv) {
* an embedding loop can stay alive.
*/
async function handleServerDown(argv) {
argv = canonicalArgv(argv);
const cmd = argv[0];
const { flags, positional } = parseArgs(argv.slice(1));
const handler = OFFLINE_HANDLERS[cmd];