feat: model lists and prices that follow each AI tool, a Settings tab per tool, live lane status (#40)

Pricing: the LiteLLM sync now adds every current Claude / GPT (>= 5) /
Gemini (>= 3) chat model before it is used and keeps its own rows
(model_pricing.source = 'sync', the Auto badge) at the published rate. A
user's row (any PUT) and a deleted pattern (model_pricing_dismissed) are
never touched; a deleted built-in row is not re-seeded at the next start; a
reset re-applies the list at once; retired Claude models are priced only
once used.

Models: GET /api/ai-tools/:id/models reads each tool's own list (Codex's
models_cache.json, Claude's aliases named after the newest version plus
priced versions, a catalog tool's vendor rows plus the models its sessions
used). A catalog `model: {flag, vendor?}` field passes `<flag> <id>`
(validated: never a flag or shell text); a per-tool default model starts
each run, and a resume keeps the session's own model.

Settings: every installed, enabled catalog tool without its own tab gets
one (AiToolConfig.tsx): path, homepage, Update, sessions/cost/lanes,
default model, vendor pricing, and the config files its release catalog
lists, edited over the opened sha, syntax-checked, backed up, written
atomically; credential-looking names refused. Antigravity keeps its hooks
card there.

Lanes: a just-started run no longer reads "dead" (age counts the run's
start while the old session is still bound); a second dashboard on the
same database re-broadcasts the lanes the other changed (PRAGMA
data_version, every 2 s); a lane start with a bad model answers 400.

DB: every migration runs under a cross-process lock (<db>-migrate.lock), so
two dashboards started at once on a fresh database both come up.
This commit is contained in:
2026-10-01 13:39:17 +07:00
parent c5aba094bd
commit f887c16309
51 changed files with 2343 additions and 203 deletions
+84
View File
@@ -344,6 +344,90 @@ describe("/api/lanes", () => {
});
});
describe("liveness of a just-started run", () => {
// A new run keeps the lane's PREVIOUS session_id until the new session's
// first hook rebinds it, and that session may have been silent for days —
// judged by it alone, a run started a second ago read "dead" until a
// refresh (and stayed so for a resume, whose hooks change no lane field).
it("ages the lane from the newer of its last event and its run's start", async () => {
const tmux = require("../lib/tmux");
const { db } = require("../db");
const lanesLib = require("../lib/lanes");
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-live-start-"));
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
const runId = `agenthub-lane-${id}`;
const old = new Date(Date.now() - 3 * 86400e3).toISOString();
try {
tmux.__setExecImpl(() => "");
db.prepare(
"INSERT INTO sessions (id, status, started_at, cwd) VALUES ('sess-old-run', 'completed', ?, ?)"
).run(old, cwd);
db.prepare(
"INSERT INTO events (session_id, event_type, summary, created_at) VALUES ('sess-old-run', 'Stop', '', ?)"
).run(old);
lanesLib.updateLane(id, { session_id: "sess-old-run", run_id: runId, status: "running" });
const runAt = db.prepare(
"INSERT OR REPLACE INTO dashboard_runs (id, mode, cwd, status, started_at, lane_id) VALUES (?, 'interactive', ?, 'running', ?, ?)"
);
runAt.run(runId, cwd, new Date().toISOString(), id);
const fresh = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(fresh.liveness, "active");
runAt.run(runId, cwd, old, id);
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.liveness, "dead");
// A lane no hook ever binds a session to (a catalog tool, Codex without
// its hooks) has no signal at all, and stays "active" however long it runs.
lanesLib.updateLane(id, { session_id: null });
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.liveness, "active");
} finally {
tmux.__reset();
fs.rmSync(cwd, { recursive: true, force: true });
}
});
});
describe("lanes changed by another server on the same DB", () => {
// Hooks go to ONE server per database (the lowest port), so a browser on the
// other one never heard about a lane the hooks moved until it refreshed.
it("re-broadcasts a lane another connection changed, and a removed one", async () => {
const Database = require("better-sqlite3");
const { broadcastForeignLaneChanges } = require("../routes/lanes");
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-foreign-"));
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
const other = new Database(TEST_DB);
try {
broadcastForeignLaneChanges(); // prime what this server has seen
assert.deepEqual(broadcastForeignLaneChanges(), { changed: [], removed: [] });
other
.prepare("UPDATE lanes SET status = 'running', updated_at = ? WHERE id = ?")
.run(new Date(Date.now() + 5).toISOString(), id);
assert.deepEqual(broadcastForeignLaneChanges(), { changed: [id], removed: [] });
assert.deepEqual(broadcastForeignLaneChanges(), { changed: [], removed: [] });
other.prepare("DELETE FROM lanes WHERE id = ?").run(id);
assert.deepEqual(broadcastForeignLaneChanges(), { changed: [], removed: [id] });
} finally {
other.close();
fs.rmSync(cwd, { recursive: true, force: true });
}
});
});
describe("lane start input", () => {
it("answers 400 for a model that is not a plain model id", async () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-bad-model-"));
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
try {
const r = await request("POST", `/api/lanes/${id}/start`, { model: "--dangerous" });
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EBADMODEL");
} finally {
fs.rmSync(cwd, { recursive: true, force: true });
}
});
});
describe("hook → lane binding", () => {
it("binds a session to the lane owning its cwd and flags/clears needs_action", async () => {
const created = await request("POST", "/api/lanes", {