feat: workspaces own their sessions; desktop parity; sidebar and settings cleanup

Workspace sessions (docs/plans/desktop-parity, phases 1-5 and 7):
- sessions.lane_id: a workspace lists only the sessions its panes held,
  stamped by bindRunSession (first writer wins, nested `claude -p`
  excluded); GET /api/sessions?lane=&archived=
- session row state dot, double-click rename, archive (sessions.archived_at,
  hides only); the activity bar's duplicate Sessions tab removed
- Fork beside Resume (--fork-session, Claude only), Ctrl/Cmd+click a live
  session opens it in a second pane, a diff line's comment is pasted into
  the workspace's last-focused pane (lib/panePaste.ts)

dashboard_runs stability:
- a pane's previous run is archived as <pane>@<started_at> instead of being
  overwritten by INSERT OR REPLACE
- a run is flagged abandoned only once its tmux session is gone, at boot and
  on the run-heal beat (panes outlive a server restart)

UI:
- sidebar: no footer, language or theme block; small collapse button in the
  brand corner; version + update check in the connection details modal, a
  status-bar dot while a release is out; language in Settings -> General
- one Switch component for every switch, the themed Checkbox for every
  checkbox (no native checkboxes)

Also: AI Tools catalog trimmed to 10 built-ins (custom-file prompt modes
tested through fixtures), and the workspace plans under docs/plans/ (the
multi-user server plan lives in the AgentHub-Server repo).
This commit is contained in:
2026-10-02 12:05:00 +07:00
parent 5b269698f6
commit a6bd33bcff
121 changed files with 2699 additions and 1552 deletions
+12 -12
View File
@@ -135,21 +135,21 @@ describe("lanes on catalog tools", () => {
let r = await request("POST", "/api/lanes", { cwd: dir("a"), title: "a", tool: "gemini" });
assert.equal(r.status, 201);
assert.equal(r.body.lane.tool, "gemini");
r = await request("POST", "/api/lanes", { cwd: dir("b"), title: "b", tool: "aider" });
r = await request("POST", "/api/lanes", { cwd: dir("b"), title: "b", tool: "omp" });
assert.equal(r.status, 201, "a tool that is not installed may still get a lane");
r = await request("POST", "/api/lanes", { cwd: dir("c"), title: "c", tool: "nope" });
assert.equal(r.body.error.code, "EBADTOOL");
await request("PUT", "/api/ai-tools/aider", { enabled: false });
r = await request("POST", "/api/lanes", { cwd: dir("d"), title: "d", tool: "aider" });
await request("PUT", "/api/ai-tools/omp", { enabled: false });
r = await request("POST", "/api/lanes", { cwd: dir("d"), title: "d", tool: "omp" });
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EDISABLED");
r = await request("POST", "/api/lanes/ensure", { cwd: dir("e"), tool: "aider" });
r = await request("POST", "/api/lanes/ensure", { cwd: dir("e"), tool: "omp" });
assert.equal(r.body.error.code, "EDISABLED");
r = await request("POST", "/api/lanes/ensure", { cwd: dir("b"), tool: "aider" });
r = await request("POST", "/api/lanes/ensure", { cwd: dir("b"), tool: "omp" });
assert.equal(r.status, 200, "an existing lane on a disabled tool is still found");
r = await request("POST", "/api/lanes/worktree", { sourceRepo: dir("f"), tool: "aider" });
r = await request("POST", "/api/lanes/worktree", { sourceRepo: dir("f"), tool: "omp" });
assert.equal(r.body.error.code, "EDISABLED");
await request("PUT", "/api/ai-tools/aider", { enabled: true });
await request("PUT", "/api/ai-tools/omp", { enabled: true });
});
});
@@ -190,23 +190,23 @@ describe("install / update / icons", () => {
it("starts an install job and reports it", async () => {
jobs.__reset();
assert.equal((await request("GET", "/api/ai-tools/job")).body, null);
const r = await request("POST", "/api/ai-tools/qwen-code/install");
const r = await request("POST", "/api/ai-tools/copilot/install");
assert.equal(r.status, 202);
assert.equal(r.body.job.tool, "qwen-code");
assert.equal(r.body.job.tool, "copilot");
const done = await settle();
assert.equal(done.status, "ok");
assert.deepEqual(spawned.at(-1), [
path.join(BIN, "npm"),
"install",
"-g",
"@qwen-code/qwen-code@latest",
"@github/copilot@latest",
]);
});
it("maps refusals to statuses", async () => {
jobs.__reset();
assert.equal((await request("POST", "/api/ai-tools/nope/install")).status, 404);
const noInstall = await request("POST", "/api/ai-tools/aider/install");
const noInstall = await request("POST", "/api/ai-tools/antigravity/install");
assert.equal(noInstall.status, 400);
assert.equal(noInstall.body.error.code, "ENOINSTALL");
const notInstalled = await request("POST", "/api/ai-tools/codex/update");
@@ -216,7 +216,7 @@ describe("install / update / icons", () => {
const notNpm = await request("POST", "/api/ai-tools/gemini/update");
assert.equal(notNpm.status, 409);
assert.equal(notNpm.body.error.code, "ENOTNPM");
const cross = await request("POST", "/api/ai-tools/qwen-code/install", undefined, {
const cross = await request("POST", "/api/ai-tools/copilot/install", undefined, {
Origin: "https://evil.test",
});
assert.equal(cross.status, 403);
+41
View File
@@ -292,6 +292,47 @@ describe("Sessions API", () => {
assert.ok(claude.body.sessions.every((s) => s.tool === "claude"));
});
it("lists only the sessions a workspace owns, not the rest of its folder", async () => {
const laneId = db
.prepare("INSERT INTO lanes (cwd, tool) VALUES ('/tmp/own-ws', 'claude')")
.run().lastInsertRowid;
const add = (id, cwd, lane) =>
db
.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id, started_at, updated_at) VALUES (?, ?, 'active', ?, ?, '2026-09-29T00:00:00Z', '2026-09-29T00:00:00Z')"
)
.run(id, id, cwd, lane);
add("own-1", "/tmp/own-ws", laneId);
add("own-sub", "/tmp/own-ws/pkg", laneId); // cd'd into a subfolder — still its own
add("stray-1", "/tmp/own-ws", null); // a terminal session in the same folder
const own = await fetch(`/api/sessions?lane=${laneId}`);
assert.deepEqual(own.body.sessions.map((s) => s.id).sort(), ["own-1", "own-sub"]);
assert.equal((await fetch("/api/sessions?lane=abc")).status, 400);
});
it("archives a session out of a workspace's list and back, deleting nothing", async () => {
const laneId = stmts.getSession.get("own-1").lane_id;
let res = await patch("/api/sessions/own-1", { archived: true });
assert.equal(res.status, 200);
assert.ok(res.body.session.archived_at);
const live = await fetch(`/api/sessions?lane=${laneId}&archived=0`);
assert.deepEqual(
live.body.sessions.map((s) => s.id),
["own-sub"]
);
const gone = await fetch(`/api/sessions?lane=${laneId}&archived=1`);
assert.deepEqual(
gone.body.sessions.map((s) => s.id),
["own-1"]
);
// Without the parameter every session is listed, as before.
assert.equal((await fetch(`/api/sessions?lane=${laneId}`)).body.sessions.length, 2);
res = await patch("/api/sessions/own-1", { archived: "yes" });
assert.equal(res.status, 400);
res = await patch("/api/sessions/own-1", { archived: false });
assert.equal(res.body.session.archived_at, null);
});
it("should paginate sessions", async () => {
const res = await fetch("/api/sessions?limit=1&offset=0");
assert.equal(res.body.sessions.length, 1);
+52
View File
@@ -0,0 +1,52 @@
/**
* @file Tests for the persisted run history (`server/lib/dashboard-runs.js`).
* A run's id is its tmux pane name, which every Start in that pane reuses, so
* a new run must ARCHIVE the previous row under its own id instead of
* overwriting it; and a row is flagged dead only when its tmux session is
* really gone — panes outlive a server restart.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "dashboard-runs-"));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
let runs;
before(() => {
runs = require("../lib/dashboard-runs");
});
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
const start = (id, extra = {}) =>
runs.recordRun({ id, mode: "interactive", cwd: "/r", status: "running", laneId: 4, ...extra });
describe("dashboard-runs", () => {
it("keeps the previous run of a pane as history when the pane starts again", () => {
start("agenthub-lane-4", { startedAt: Date.parse("2026-10-01T10:00:00Z"), sessionId: "s-old" });
start("agenthub-lane-4", { startedAt: Date.parse("2026-10-02T10:00:00Z"), sessionId: "s-new" });
const current = runs.getRun("agenthub-lane-4");
assert.equal(current.session_id, "s-new");
const history = runs.listRuns({ laneId: 4 });
assert.equal(history.length, 2);
const old = history.find((r) => r.session_id === "s-old");
assert.notEqual(old.id, "agenthub-lane-4");
assert.ok(old.id.startsWith("agenthub-lane-4@"), old.id);
// A pane that is being replaced is not running any more.
assert.equal(old.status, "abandoned");
assert.ok(old.ended_at);
});
it("flags as abandoned only the running rows whose pane is gone", () => {
start("agenthub-lane-5", { laneId: 5 });
start("agenthub-lane-6", { laneId: 6 });
const n = runs.reconcileOrphans((id) => id !== "agenthub-lane-6");
assert.equal(n, 1);
assert.equal(runs.getRun("agenthub-lane-5").status, "running");
assert.equal(runs.getRun("agenthub-lane-6").status, "abandoned");
});
});
@@ -0,0 +1,10 @@
{
"id": "command-code",
"label": "Command Code",
"detect": ["command-code"],
"args": ["--trust"],
"prompt": {
"mode": "argv"
},
"bypass": ["--yolo"]
}
@@ -1,16 +1,12 @@
{
"id": "goose",
"label": "Goose",
"detect": [
"goose"
],
"detect": ["goose"],
"prompt": {
"mode": "after-start",
"delayMs": 3000
},
"bypassEnv": {
"GOOSE_MODE": "auto"
},
"homepageUrl": "https://block.github.io/goose/docs/quickstart/",
"faviconDomain": "goose-docs.ai"
}
}
@@ -0,0 +1,10 @@
{
"id": "grok",
"label": "Grok",
"detect": ["grok"],
"prompt": {
"mode": "argv",
"separator": "--"
},
"bypass": ["--permission-mode", "bypassPermissions"]
}
+19
View File
@@ -426,6 +426,25 @@ describe("lane start input", () => {
fs.rmSync(cwd, { recursive: true, force: true });
}
});
it("answers 400 for a fork with nothing to fork, or on a tool that cannot fork", async () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-bad-fork-"));
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
const codexId = (await request("POST", "/api/lanes", { cwd, tool: "codex" })).body.lane.id;
try {
let r = await request("POST", `/api/lanes/${id}/start`, { forkSession: true });
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EBADSESSION");
r = await request("POST", `/api/lanes/${codexId}/start`, {
forkSession: true,
resumeSessionId: "abc12345",
});
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EUNSUPPORTED");
} finally {
fs.rmSync(cwd, { recursive: true, force: true });
}
});
});
describe("hook → lane binding", () => {
+41 -2
View File
@@ -196,6 +196,40 @@ describe("pty-run", () => {
assert.ok(newSessionArgv.includes("abc12345"));
});
it("spawnRun forks a resumed Claude session into a new id, and refuses a fork elsewhere", () => {
let newSessionArgv = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
const e = new Error("gone");
e.status = 1;
throw e;
}
if (args[0] === "new-session") newSessionArgv = args;
return "";
});
pty.spawnRun({ laneId: 31, cwd: "/tmp/repo", resumeSessionId: "abc12345", forkSession: true });
const at = newSessionArgv.indexOf("--resume");
assert.deepEqual(newSessionArgv.slice(at, at + 3), ["--resume", "abc12345", "--fork-session"]);
// The pane holds the FORK, whose id only its first hook reports.
const dashboardRuns = require("../lib/dashboard-runs");
assert.equal(dashboardRuns.getRun("agenthub-lane-31").session_id, null);
assert.throws(
() => pty.spawnRun({ laneId: 32, cwd: "/tmp/repo", forkSession: true }),
(e) => e.code === "EBADSESSION"
);
assert.throws(
() =>
pty.spawnRun({
laneId: 33,
cwd: "/tmp/repo",
tool: "codex",
resumeSessionId: "abc12345",
forkSession: true,
}),
(e) => e.code === "EUNSUPPORTED"
);
});
it("spawnRun offers bypass in the pane's shift+tab cycle without starting in it", () => {
let newSessionArgv = null;
tmux.__setExecImpl((args) => {
@@ -427,11 +461,16 @@ describe("pty-run", () => {
}
savedPath = process.env.PATH;
process.env.PATH = `${BIN}:/usr/bin:/bin`;
aiTools.refresh();
// The built-in catalog plus three test-only tools for the prompt modes
// and bypassEnv no built-in tool uses any more (custom files may).
aiTools.__setCatalogDirs([
path.join(__dirname, "..", "data", "ai-tools"),
path.join(__dirname, "fixtures", "ai-tools"),
]);
});
after(() => {
process.env.PATH = savedPath;
aiTools.refresh();
aiTools.__setCatalogDirs(null);
});
const at = (name) => path.join(BIN, name);
const envOf = (call, key) => {
+59 -1
View File
@@ -3,7 +3,9 @@
* is started with `AGENTHUB_RUN_ID` in its tmux env, and a hook carrying that
* `run_id` binds the hook's session to the run (`dashboard_runs.session_id`),
* following `/clear` to a new session; a `run_id` that is not a dashboard pane
* name, or names no run, changes nothing.
* name, or names no run, changes nothing. A session a lane's pane holds is
* stamped with that lane (`sessions.lane_id`) — the workspace owns it — and a
* nested CLI's session, or one another lane already owns, is not.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -22,6 +24,7 @@ process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp } = require("../index");
const { db } = require("../db");
const dashboardRuns = require("../lib/dashboard-runs");
const tmux = require("../lib/tmux");
const pty = require("../lib/pty-run");
@@ -140,4 +143,59 @@ describe("run ↔ session binding", () => {
assert.equal(dashboardRuns.getRun("agenthub-lane-999"), null);
assert.equal(dashboardRuns.getRun("agenthub-lane-5-1").session_id, "sess-bbbb-2222");
});
it("stamps the session a lane's pane holds with that lane, once", async () => {
dashboardRuns.recordRun({
id: "agenthub-lane-8",
laneId: 8,
mode: "interactive",
cwd: ROOT,
status: "running",
startedAt: Date.now(),
});
const laneOf = (id) => db.prepare("SELECT lane_id FROM sessions WHERE id = ?").get(id)?.lane_id;
await postHook({
hook_type: "SessionStart",
data: { session_id: "own-aaaa-0001", cwd: ROOT, source: "startup" },
run_id: "agenthub-lane-8",
});
assert.equal(laneOf("own-aaaa-0001"), 8);
// A nested `claude -p` in that pane is not the pane's session.
await postHook({
hook_type: "SessionStart",
data: { session_id: "own-nest-0002", cwd: ROOT, source: "startup" },
run_id: "agenthub-lane-8",
});
assert.equal(laneOf("own-nest-0002"), null);
// A session another lane owns keeps its owner when resumed elsewhere.
db.prepare("UPDATE sessions SET lane_id = 3 WHERE id = ?").run("own-nest-0002");
await postHook({
hook_type: "SessionStart",
data: { session_id: "own-nest-0002", cwd: ROOT, source: "resume" },
run_id: "agenthub-lane-8",
});
assert.equal(dashboardRuns.getRun("agenthub-lane-8").session_id, "own-nest-0002");
assert.equal(laneOf("own-nest-0002"), 3);
});
it("stamps a resumed session the run was started with", async () => {
dashboardRuns.recordRun({
id: "agenthub-lane-9",
laneId: 9,
sessionId: "own-resu-0003",
mode: "interactive",
cwd: ROOT,
status: "running",
startedAt: Date.now(),
});
await postHook({
hook_type: "SessionStart",
data: { session_id: "own-resu-0003", cwd: ROOT, source: "resume" },
run_id: "agenthub-lane-9",
});
assert.equal(
db.prepare("SELECT lane_id FROM sessions WHERE id = ?").get("own-resu-0003").lane_id,
9
);
});
});
-15
View File
@@ -1,15 +0,0 @@
{
"id": "aider",
"label": "Aider",
"detect": [
"aider"
],
"prompt": {
"mode": "after-start",
"delayMs": 3000
},
"bypass": [
"--yes-always"
],
"homepageUrl": "https://aider.chat/docs/"
}
-23
View File
@@ -1,23 +0,0 @@
{
"id": "amp",
"label": "Amp",
"detect": [
"amp"
],
"prompt": {
"mode": "after-start",
"delayMs": 3000
},
"bypass": [
"--dangerously-allow-all"
],
"homepageUrl": "https://ampcode.com/manual#install",
"faviconDomain": "ampcode.com",
"install": {
"kind": "npm",
"package": "@sourcegraph/amp"
},
"update": {
"kind": "npm"
}
}
-16
View File
@@ -1,16 +0,0 @@
{
"id": "ante",
"label": "Ante",
"detect": [
"ante"
],
"prompt": {
"mode": "after-start",
"delayMs": 3000
},
"bypass": [
"--yolo"
],
"homepageUrl": "https://github.com/AntigmaLabs/ante-preview",
"faviconDomain": "antigma.ai"
}
-13
View File
@@ -1,13 +0,0 @@
{
"id": "aug",
"label": "Auggie",
"detect": [
"auggie"
],
"prompt": {
"mode": "after-start",
"delayMs": 3000
},
"homepageUrl": "https://docs.augmentcode.com/cli/overview",
"faviconDomain": "augmentcode.com"
}
-16
View File
@@ -1,16 +0,0 @@
{
"id": "autohand",
"label": "Autohand Code",
"detect": [
"autohand"
],
"prompt": {
"mode": "after-start",
"delayMs": 3000
},
"bypass": [
"--unrestricted"
],
"homepageUrl": "https://github.com/autohandai/code-cli",
"faviconDomain": "autohand.ai"
}
-17
View File
@@ -1,17 +0,0 @@
{
"id": "cline",
"label": "Cline",
"detect": [
"cline"
],
"prompt": {
"mode": "after-start",
"delayMs": 3000
},
"bypass": [
"--auto-approve",
"true"
],
"homepageUrl": "https://docs.cline.bot/cline-cli/overview",
"faviconDomain": "cline.bot"
}
-13
View File
@@ -1,13 +0,0 @@
{
"id": "codebuff",
"label": "Codebuff",
"detect": [
"codebuff"
],
"prompt": {
"mode": "after-start",
"delayMs": 3000
},
"homepageUrl": "https://www.codebuff.com/docs/help/quick-start",
"faviconDomain": "codebuff.com"
}
-17
View File
@@ -1,17 +0,0 @@
{
"id": "command-code",
"label": "Command Code",
"detect": [
"command-code"
],
"args": [
"--trust"
],
"prompt": {
"mode": "argv"
},
"bypass": [
"--yolo"
],
"homepageUrl": "https://commandcode.ai/docs/quickstart"
}
-17
View File
@@ -1,17 +0,0 @@
{
"id": "continue",
"label": "Continue",
"detect": [
"cn"
],
"prompt": {
"mode": "after-start",
"delayMs": 3000
},
"bypass": [
"--allow",
"*"
],
"homepageUrl": "https://docs.continue.dev/guides/cli",
"faviconDomain": "continue.dev"
}
-16
View File
@@ -1,16 +0,0 @@
{
"id": "crush",
"label": "Charm Crush",
"detect": [
"crush"
],
"prompt": {
"mode": "after-start",
"delayMs": 3000
},
"bypass": [
"--yolo"
],
"homepageUrl": "https://github.com/charmbracelet/crush",
"faviconDomain": "charm.sh"
}
+8 -2
View File
@@ -1,6 +1,6 @@
{
"id": "cursor",
"label": "Cursor",
"label": "Cursor CLI",
"detect": [
"cursor-agent"
],
@@ -10,6 +10,9 @@
"model": {
"flag": "--model"
},
"resume": [
"--continue"
],
"bypass": [
"--yolo"
],
@@ -20,7 +23,10 @@
"url": "https://cursor.com/install"
},
"update": {
"kind": "script"
"kind": "self",
"args": [
"update"
]
},
"config": [
{
-19
View File
@@ -1,19 +0,0 @@
{
"id": "devin",
"label": "Devin",
"detect": [
"devin"
],
"prompt": {
"mode": "after-start",
"delayMs": 3000
},
"bypass": [
"--permission-mode",
"bypass",
"--respect-workspace-trust",
"false"
],
"homepageUrl": "https://devin.ai/cli",
"faviconDomain": "devin.ai"
}
-15
View File
@@ -1,15 +0,0 @@
{
"id": "droid",
"label": "Droid",
"detect": [
"droid"
],
"prompt": {
"mode": "argv"
},
"bypass": [
"--auto",
"high"
],
"homepageUrl": "https://docs.factory.ai/cli/getting-started/quickstart"
}
-17
View File
@@ -1,17 +0,0 @@
{
"id": "grok",
"label": "Grok",
"detect": [
"grok"
],
"prompt": {
"mode": "argv",
"separator": "--"
},
"bypass": [
"--permission-mode",
"bypassPermissions"
],
"homepageUrl": "https://x.ai/cli",
"faviconDomain": "x.ai"
}
-12
View File
@@ -1,12 +0,0 @@
{
"id": "kilo",
"label": "Kilocode",
"detect": [
"kilo"
],
"prompt": {
"mode": "after-start",
"delayMs": 3000
},
"homepageUrl": "https://kilo.ai/docs/cli"
}
-17
View File
@@ -1,17 +0,0 @@
{
"id": "kimi",
"label": "Kimi",
"detect": [
"kimi",
"kimi-code"
],
"prompt": {
"mode": "after-start",
"delayMs": 3000
},
"bypass": [
"--yolo"
],
"homepageUrl": "https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html",
"faviconDomain": "moonshot.cn"
}
-19
View File
@@ -1,19 +0,0 @@
{
"id": "kiro",
"label": "Kiro",
"detect": [
"kiro-cli"
],
"args": [
"chat",
"--tui"
],
"prompt": {
"mode": "after-start",
"delayMs": 3000
},
"bypass": [
"--trust-all-tools"
],
"homepageUrl": "https://kiro.dev/docs/cli/"
}
-13
View File
@@ -1,13 +0,0 @@
{
"id": "mimo-code",
"label": "MiMo Code",
"detect": [
"mimo"
],
"prompt": {
"mode": "flag",
"flag": "--prompt"
},
"homepageUrl": "https://mimo.xiaomi.com/coder",
"faviconDomain": "mimo.xiaomi.com"
}
-18
View File
@@ -1,18 +0,0 @@
{
"id": "mistral-vibe",
"label": "Mistral Vibe",
"detect": [
"vibe",
"mistral-vibe"
],
"prompt": {
"mode": "after-start",
"delayMs": 3000
},
"bypass": [
"--agent",
"auto-approve"
],
"homepageUrl": "https://github.com/mistralai/mistral-vibe",
"faviconDomain": "mistral.ai"
}
-19
View File
@@ -1,19 +0,0 @@
{
"id": "muse",
"label": "Muse",
"detect": [
"muse"
],
"args": [
"--trust-workspace"
],
"prompt": {
"mode": "after-start",
"delayMs": 3000
},
"bypass": [
"--yolo"
],
"homepageUrl": "https://dev.meta.ai/docs/muse-code",
"faviconDomain": "dev.meta.ai"
}
+8 -1
View File
@@ -7,5 +7,12 @@
"prompt": {
"mode": "argv"
},
"homepageUrl": "https://omp.sh"
"homepageUrl": "https://omp.sh",
"install": {
"kind": "script",
"url": "https://omp.sh/install"
},
"update": {
"kind": "script"
}
}
-14
View File
@@ -1,14 +0,0 @@
{
"id": "openclaude",
"label": "OpenClaude",
"detect": [
"openclaude"
],
"prompt": {
"mode": "argv"
},
"bypass": [
"--dangerously-skip-permissions"
],
"homepageUrl": "https://openclaude.gitlawb.com/"
}
-15
View File
@@ -1,15 +0,0 @@
{
"id": "opencode2",
"label": "OpenCode 2",
"detect": [
"opencode2"
],
"args": [
"--standalone"
],
"prompt": {
"mode": "flag",
"flag": "--prompt"
},
"homepageUrl": "https://opencode.ai/v2/docs/"
}
-11
View File
@@ -1,11 +0,0 @@
{
"id": "pi",
"label": "Pi",
"detect": [
"pi"
],
"prompt": {
"mode": "argv"
},
"homepageUrl": "https://pi.dev"
}
-13
View File
@@ -1,13 +0,0 @@
{
"id": "prime-agent",
"label": "Prime Agent",
"detect": [
"prime-agent"
],
"prompt": {
"mode": "argv",
"separator": "--"
},
"homepageUrl": "https://github.com/PrimeIntellect-ai/prime-agent",
"faviconDomain": "primeintellect.ai"
}
-36
View File
@@ -1,36 +0,0 @@
{
"id": "qwen-code",
"label": "Qwen Code",
"detect": [
"qwen"
],
"prompt": {
"mode": "after-start",
"delayMs": 3000
},
"model": {
"flag": "--model"
},
"bypass": [
"--approval-mode",
"yolo"
],
"homepageUrl": "https://github.com/QwenLM/qwen-code",
"faviconDomain": "qwenlm.github.io",
"install": {
"kind": "npm",
"package": "@qwen-code/qwen-code"
},
"update": {
"kind": "npm"
},
"config": [
{
"path": "~/.qwen/settings.json",
"format": "jsonc"
},
{
"path": "~/.qwen/QWEN.md"
}
]
}
-16
View File
@@ -1,16 +0,0 @@
{
"id": "rovo",
"label": "Rovo Dev",
"detect": [
"rovo"
],
"prompt": {
"mode": "after-start",
"delayMs": 3000
},
"bypass": [
"--yolo"
],
"homepageUrl": "https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/",
"faviconDomain": "atlassian.com"
}
-16
View File
@@ -1,16 +0,0 @@
{
"id": "trae",
"label": "Trae",
"detect": [
"traecli"
],
"prompt": {
"mode": "argv",
"separator": "--"
},
"bypass": [
"--yolo"
],
"homepageUrl": "https://docs.trae.cn/cli_get-started-with-trae-cli",
"faviconDomain": "www.trae.cn"
}
-17
View File
@@ -1,17 +0,0 @@
{
"id": "zcode",
"label": "ZCode",
"detect": [
"zcode"
],
"prompt": {
"mode": "after-start",
"delayMs": 3000
},
"bypass": [
"--mode",
"yolo"
],
"homepageUrl": "https://zcode.z.ai/en/docs",
"faviconDomain": "zcode.z.ai"
}
+31
View File
@@ -1142,6 +1142,35 @@ try {
db.prepare("ALTER TABLE sessions ADD COLUMN tool TEXT NOT NULL DEFAULT 'claude'").run();
}
// Migrate: add `lane_id` to sessions — the workspace that OWNS a session: the
// lane whose dashboard pane held it (stamped by bindRunSession in
// routes/hooks.js from the hook's run_id) or one the user added it to. Null =
// a session no workspace started (a terminal, an import). No FK: removing a
// lane leaves its sessions, which are the user's history. The backfill takes
// only what dashboard_runs still records — older history is not guessed.
try {
db.prepare("SELECT lane_id FROM sessions LIMIT 1").get();
} catch {
db.prepare("ALTER TABLE sessions ADD COLUMN lane_id INTEGER").run();
db.prepare(
`UPDATE sessions SET lane_id = (
SELECT r.lane_id FROM dashboard_runs r
WHERE r.session_id = sessions.id AND r.lane_id IS NOT NULL
ORDER BY r.started_at DESC LIMIT 1)
WHERE lane_id IS NULL`
).run();
}
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_lane ON sessions(lane_id)`);
// Migrate: add `archived_at` to sessions — a session the user archived out of
// its workspace's lists (Claude Desktop's archive). Hides only; nothing about
// the session, its transcript or its tokens is deleted.
try {
db.prepare("SELECT archived_at FROM sessions LIMIT 1").get();
} catch {
db.prepare("ALTER TABLE sessions ADD COLUMN archived_at TEXT").run();
}
// How far each Codex rollout journal has been read (server/lib/codex-usage.js).
// In the DB, not a side file, so a session the user deleted stays deleted
// until Codex writes to its journal again, while a wiped DB re-imports all.
@@ -1444,6 +1473,8 @@ const stmts = {
updateSession: db.prepare(
"UPDATE sessions SET name = COALESCE(?, name), status = COALESCE(?, status), ended_at = COALESCE(?, ended_at), metadata = COALESCE(?, metadata), updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?"
),
// First writer wins: a session a workspace already owns keeps its owner.
claimSessionLane: db.prepare("UPDATE sessions SET lane_id = ? WHERE id = ? AND lane_id IS NULL"),
reactivateSession: db.prepare(
"UPDATE sessions SET status = 'active', ended_at = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?"
),
+12 -7
View File
@@ -539,14 +539,18 @@ function startBackgroundServices() {
}
}
// A codex/antigravity pane that exits fires no hook, so release its lane on
// a beat instead (one `tmux has-session` per lane with a run). Off with
// DASHBOARD_RUN_HEAL_MS=0.
// a beat instead (one `tmux has-session` per lane with a run), and end the
// run record of any pane that is gone. Off with DASHBOARD_RUN_HEAL_MS=0.
const HEAL_MS = Number(process.env.DASHBOARD_RUN_HEAL_MS ?? 10_000);
if (Number.isFinite(HEAL_MS) && HEAL_MS > 0) {
const { healAllRunIds } = require("./routes/lanes");
const { reconcileOrphans } = require("./lib/dashboard-runs");
const { hasSession } = require("./lib/tmux");
const healTimer = setInterval(() => {
try {
healAllRunIds();
// A pane that quit (Ctrl+C, `exit`) ends its run record too.
reconcileOrphans(hasSession);
} catch (err) {
console.warn("run heal failed:", err.message);
}
@@ -587,13 +591,14 @@ function startBackgroundServices() {
} catch (err) {
console.warn("remote source sync failed to start:", err.message);
}
// Flip any dashboard_runs rows the previous process left flagged
// running/spawning — those handles died with the previous server, so
// there's no way to attach to them anymore. Marking them abandoned
// keeps the Run history honest and unblocks Resume on conversation rows.
// Flip the dashboard_runs rows still flagged running/spawning whose tmux
// pane is gone (a reboot, a pane that quit while no server was up). A pane
// that is still there survived the restart and stays running — tmux, not
// this process, owns it.
try {
const { reconcileOrphans } = require("./lib/dashboard-runs");
const reconciled = reconcileOrphans();
const { hasSession } = require("./lib/tmux");
const reconciled = reconcileOrphans(hasSession);
if (reconciled > 0) {
console.log(`[runs] reconciled ${reconciled} orphan run(s) → abandoned`);
}
+42 -12
View File
@@ -14,6 +14,13 @@
* Workspace page can list one lane's own run history; runs spawned straight
* from POST /api/run leave it null.
*
* A run's id is its tmux pane name (`agenthub-lane-<id>[-slot]`), which every
* Start in that pane reuses. So a new run first ARCHIVES the row it replaces
* under `<id>@<its started_at>` (flagged `abandoned` if it never said it
* ended) — history is kept, and `getRun(<pane name>)` is always the pane's
* current run. A row is flagged dead only once its tmux session is gone
* (`reconcileOrphans(isAlive)`): panes outlive a server restart.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -66,11 +73,28 @@ const getStmt = db.prepare(`
FROM dashboard_runs WHERE id = @id
`);
// The row a new run in the same pane replaces, kept under its own id.
const archiveStmt = db.prepare(`
UPDATE dashboard_runs
SET id = id || '@' || started_at,
status = CASE WHEN status IN ('running', 'spawning') THEN 'abandoned' ELSE status END,
ended_at = COALESCE(ended_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
WHERE id = ?
`);
/**
* Insert a new run record at spawn time. Idempotent on `id`.
* Insert a new run record at spawn time; the pane's previous run, if any, is
* archived first rather than overwritten.
*/
function recordRun(handle) {
try {
try {
archiveStmt.run(handle.id);
} catch (err) {
// An archive id already taken (an imported bundle) — fall back to the
// replace below rather than lose the new run.
console.warn(`[dashboard-runs] could not archive run ${handle?.id}: ${err.message}`);
}
const startedAt = new Date(handle.startedAt || Date.now()).toISOString();
const endedAt = handle.endedAt ? new Date(handle.endedAt).toISOString() : null;
const prompt = typeof handle.prompt === "string" ? handle.prompt : "";
@@ -137,26 +161,32 @@ function getRun(id) {
}
}
const reconcileStmt = db.prepare(`
const runningStmt = db.prepare(
"SELECT id FROM dashboard_runs WHERE status IN ('running', 'spawning')"
);
const abandonStmt = db.prepare(`
UPDATE dashboard_runs
SET status = 'abandoned',
ended_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE status IN ('running', 'spawning')
ended_at = COALESCE(ended_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
WHERE id = ? AND status IN ('running', 'spawning')
`);
/**
* On server boot, any rows still flagged `running` or `spawning` are
* orphans — the spawner only persists those statuses for handles it knows
* about, and the in-memory map was just wiped by the restart. Mark them as
* `abandoned` so the UI doesn't display them as live and the user can
* resume them like any other completed past run.
* Flags `abandoned` every row still marked running/spawning whose pane is
* gone — the pane's process quit (Ctrl+C, `exit`), or the machine rebooted.
* A pane is a tmux session, which outlives a server restart, so liveness is
* asked of `isAlive(id)` (tmux), never assumed from the restart. Run at boot
* and on the server's run-heal beat.
*
* Returns the number of rows updated.
*/
function reconcileOrphans() {
function reconcileOrphans(isAlive = () => false) {
try {
const info = reconcileStmt.run();
return info.changes || 0;
let changes = 0;
for (const { id } of runningStmt.all()) {
if (!isAlive(id)) changes += abandonStmt.run(id).changes;
}
return changes;
} catch {
return 0;
}
+16 -1
View File
@@ -106,6 +106,7 @@ function buildClaudeArgv({
permissionMode,
effort,
resumeSessionId,
forkSession = false,
initialPrompt,
addDirs = [],
systemPromptFile = null,
@@ -129,6 +130,9 @@ function buildClaudeArgv({
if (model) argv.push("--model", model);
if (effort && EFFORT_LEVELS.has(effort)) argv.push("--effort", effort);
if (resumeSessionId) argv.push("--resume", resumeSessionId);
// A fork resumes the history into a NEW session id; the original is left as
// it was.
if (resumeSessionId && forkSession) argv.push("--fork-session");
if (initialPrompt) argv.push(promptArg(initialPrompt));
return argv;
}
@@ -222,6 +226,8 @@ function buildArgv(args, entry) {
* @param {string} [args.sandbox]
* @param {string} [args.approval]
* @param {string} [args.resumeSessionId]
* @param {boolean} [args.forkSession] resume `resumeSessionId` into a new
* session id (`claude --fork-session`); Claude only.
* @param {boolean} [args.resumeLast]
* @param {boolean} [args.bypass] the user ticked "bypass permissions" for THIS
* run; appends the catalog's bypass args/env. Claude has no catalog bypass.
@@ -241,6 +247,7 @@ function spawnRun(args) {
sandbox,
approval,
resumeSessionId,
forkSession,
resumeLast,
bypass,
initialPrompt,
@@ -293,6 +300,12 @@ function spawnRun(args) {
) {
throw makeErr("EBADSESSION", "resumeSessionId is not a valid session id");
}
if (forkSession === true) {
if (!resumeSessionId) throw makeErr("EBADSESSION", "a fork needs resumeSessionId");
if (entry.launcher !== "claude") {
throw makeErr("EUNSUPPORTED", `${tool} cannot fork a session`);
}
}
// A run IS a tmux session, so an absent binary is a precondition failure, not
// a spawn failure. Without this the caller gets `spawnSync tmux ENOENT` from
@@ -330,6 +343,7 @@ function spawnRun(args) {
sandbox,
approval,
resumeSessionId,
forkSession: forkSession === true,
resumeLast,
bypass: bypass === true,
initialPrompt,
@@ -343,7 +357,8 @@ function spawnRun(args) {
if (!dashboardRuns) return;
dashboardRuns.recordRun({
id,
sessionId: resumeSessionId || null,
// A fork's own id arrives with its first hook (bindRunSession).
sessionId: (forkSession !== true && resumeSessionId) || null,
// `dashboard_runs.mode` is NOT NULL. Passing null made every insert throw
// inside recordRun's best-effort catch, so no tmux-backed run was ever
// persisted: publicRun then read its cwd/model/prompt/startedAt off a row
+11 -4
View File
@@ -1306,15 +1306,22 @@ const HOOK_TOOLS = { codex: codexHookInput, antigravity: antigravityHookInput };
* AGENTHUB_RUN_ID too, and its hooks must not take the pane over. That rule
* is Claude's only: for Codex and agy it is not verified that a new session in
* the pane and a nested one can be told apart, so their panes follow every one.
*
* The session the pane holds is also stamped with the pane's lane
* (`sessions.lane_id`, first writer wins) — that is what makes it the
* workspace's own session rather than one that merely ran in the same folder.
*/
function bindRunSession(runId, sessionId, hookType, source, tool) {
if (typeof runId !== "string" || !/^agenthub-lane-\d+(-[1-3])?$/.test(runId)) return;
if (typeof sessionId !== "string" || !sessionId) return;
const row = dashboardRuns.getRun(runId);
if (!row || row.session_id === sessionId) return;
const reopened = hookType === "SessionStart" && source !== "startup";
if (tool === "claude" && row.session_id && !reopened) return;
dashboardRuns.patchRun({ id: runId, sessionId });
if (!row) return;
if (row.session_id !== sessionId) {
const reopened = hookType === "SessionStart" && source !== "startup";
if (tool === "claude" && row.session_id && !reopened) return;
dashboardRuns.patchRun({ id: runId, sessionId });
}
if (row.lane_id != null) stmts.claimSessionLane.run(row.lane_id, sessionId);
}
router.post("/event", (req, res) => {
+4 -2
View File
@@ -1790,6 +1790,7 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
resumeLast: body.resumeLast === true,
bypass: body.bypass === true,
resumeSessionId: body.resumeSessionId || (body.resume ? current.session_id : undefined),
forkSession: body.forkSession === true,
});
startedRunId = handle.id;
// An extra pane's session is not the lane's run: `run_id` stays the
@@ -1843,8 +1844,9 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
if (err.code === "ETMUXMISSING") {
return res.status(503).json({ error: { code: err.code, message: err.message } });
}
// A refused field (EBADMODEL, EBADEFFORT, …) is the request's fault.
const status = /^EBAD/.test(err.code || "") ? 400 : 500;
// A refused field (EBADMODEL, EBADEFFORT, …) or a thing the lane's tool
// cannot do (EUNSUPPORTED: a fork on a non-Claude lane) is the request's fault.
const status = /^(EBAD|EUNSUPPORTED$)/.test(err.code || "") ? 400 : 500;
return res.status(status).json({ error: { code: err.code, message: err.message } });
}
+24
View File
@@ -130,6 +130,13 @@ router.get("/", (req, res) => {
// Which CLI's sessions (claude / codex / antigravity). Applied here, before
// the limit, so a busy Codex lane cannot push Claude rows out of a page.
const tool = typeof req.query.tool === "string" ? req.query.tool : "";
// `lane` = the sessions a workspace owns (`sessions.lane_id`).
const lane = req.query.lane;
if (lane !== undefined && !/^\d+$/.test(String(lane))) {
return res
.status(400)
.json({ error: { code: "INVALID_INPUT", message: "lane must be a lane id" } });
}
const sortBy = req.query.sort_by || "time"; // "time", "duration", "price"
const sortDesc = req.query.sort_desc !== "false";
@@ -153,6 +160,13 @@ router.get("/", (req, res) => {
where.push("s.tool = ?");
params.push(tool);
}
if (lane !== undefined) {
where.push("s.lane_id = ?");
params.push(Number(lane));
}
// `archived=0|1` = only live / only archived; absent = both, as before.
if (req.query.archived === "0") where.push("s.archived_at IS NULL");
if (req.query.archived === "1") where.push("s.archived_at IS NOT NULL");
// Data-scope filter: restrict to sessions collected from a chosen set of
// machines (local + any configured remote sources). Absent = all sources.
const sourceFilter = sourceColumnClause(parseSources(req));
@@ -397,6 +411,16 @@ router.patch("/:id", (req, res) => {
if (!existing) {
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Session not found" } });
}
if (Object.hasOwn(req.body, "archived")) {
if (typeof req.body.archived !== "boolean") {
return res
.status(400)
.json({ error: { code: "INVALID_INPUT", message: "archived must be a boolean" } });
}
db.prepare(
"UPDATE sessions SET archived_at = CASE WHEN ? THEN COALESCE(archived_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) END WHERE id = ?"
).run(req.body.archived ? 1 : 0, req.params.id);
}
stmts.updateSession.run(
name || null,