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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"id": "aider",
|
||||
"label": "Aider",
|
||||
"detect": [
|
||||
"aider"
|
||||
],
|
||||
"prompt": {
|
||||
"mode": "after-start",
|
||||
"delayMs": 3000
|
||||
},
|
||||
"bypass": [
|
||||
"--yes-always"
|
||||
],
|
||||
"homepageUrl": "https://aider.chat/docs/"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"id": "kilo",
|
||||
"label": "Kilocode",
|
||||
"detect": [
|
||||
"kilo"
|
||||
],
|
||||
"prompt": {
|
||||
"mode": "after-start",
|
||||
"delayMs": 3000
|
||||
},
|
||||
"homepageUrl": "https://kilo.ai/docs/cli"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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/"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"id": "openclaude",
|
||||
"label": "OpenClaude",
|
||||
"detect": [
|
||||
"openclaude"
|
||||
],
|
||||
"prompt": {
|
||||
"mode": "argv"
|
||||
},
|
||||
"bypass": [
|
||||
"--dangerously-skip-permissions"
|
||||
],
|
||||
"homepageUrl": "https://openclaude.gitlawb.com/"
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"id": "opencode2",
|
||||
"label": "OpenCode 2",
|
||||
"detect": [
|
||||
"opencode2"
|
||||
],
|
||||
"args": [
|
||||
"--standalone"
|
||||
],
|
||||
"prompt": {
|
||||
"mode": "flag",
|
||||
"flag": "--prompt"
|
||||
},
|
||||
"homepageUrl": "https://opencode.ai/v2/docs/"
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"id": "pi",
|
||||
"label": "Pi",
|
||||
"detect": [
|
||||
"pi"
|
||||
],
|
||||
"prompt": {
|
||||
"mode": "argv"
|
||||
},
|
||||
"homepageUrl": "https://pi.dev"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user