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
+62 -8
View File
@@ -76,15 +76,29 @@ const MAX_WORKTREE_DIRECTORY_ATTEMPTS = 50;
const LOG_TAIL_DEFAULT = 64 * 1024;
const LOG_TAIL_MAX = 1024 * 1024;
/** Seconds since this lane's session last emitted an event; null if never. */
/**
* Seconds since this lane last showed life: its session's newest event or the
* start of its current run, whichever is newer; null if neither exists. The
* run counts because a new run keeps the PREVIOUS session_id until the new
* session's first hook rebinds it — judged by that old session alone, a run
* started a second ago read "dead" and, with no lane field changing after
* (a resume, or a hook ingested by another server on the same DB), stayed so
* until a refresh. Only with a session bound: a lane no hook ever gives one
* (a catalog tool, Codex without its hooks) has no signal, so stays null.
*/
const lastEventStmt = db.prepare("SELECT MAX(created_at) AS last FROM events WHERE session_id = ?");
const runStartStmt = db.prepare("SELECT started_at FROM dashboard_runs WHERE id = ?");
function lastEventAge(lane) {
if (!lane.session_id) return null;
const row = db
.prepare("SELECT MAX(created_at) AS last FROM events WHERE session_id = ?")
.get(lane.session_id);
if (!row || !row.last) return null;
const t = Date.parse(row.last);
return Number.isNaN(t) ? null : Math.max(0, Math.round((Date.now() - t) / 1000));
const times = [];
const row = lastEventStmt.get(lane.session_id);
if (row?.last) times.push(Date.parse(row.last));
if (lane.run_id) {
const run = runStartStmt.get(lane.run_id);
if (run?.started_at) times.push(Date.parse(run.started_at));
}
const t = Math.max(...times.filter((x) => !Number.isNaN(x)));
return Number.isFinite(t) ? Math.max(0, Math.round((Date.now() - t) / 1000)) : null;
}
/**
@@ -116,6 +130,43 @@ function healAllRunIds() {
for (const lane of lanesLib.listLanes()) if (lane.run_id) healRunId(lane);
}
/**
* Re-broadcast the lanes ANOTHER process changed. Two dashboards may share one
* database, and hooks reach only one of them (the lowest port), so the other's
* browsers never heard about a lane the hooks moved until they refreshed.
* `PRAGMA data_version` moves only on another connection's commit, so the
* common pass is one pragma; after one, each lane whose `updated_at` differs
* from what this pass saw last is broadcast (a lane this server wrote itself
* may go out twice — the client drops an identical copy), and a vanished
* lane goes out as `removed`.
* ponytail: two writes in the same millisecond share an updated_at; a counter
* column would close that.
*/
let seenDataVersion = null;
const seenLanes = new Map();
const laneStampsStmt = db.prepare("SELECT id, updated_at FROM lanes");
function broadcastForeignLaneChanges() {
const out = { changed: [], removed: [] };
const version = db.pragma("data_version", { simple: true });
if (version === seenDataVersion) return out;
const primed = seenDataVersion !== null;
seenDataVersion = version;
const live = new Set();
for (const { id, updated_at } of laneStampsStmt.all()) {
live.add(id);
if (primed && seenLanes.get(id) !== updated_at) out.changed.push(id);
seenLanes.set(id, updated_at);
}
for (const id of [...seenLanes.keys()]) {
if (live.has(id)) continue;
seenLanes.delete(id);
if (primed) out.removed.push(id);
}
for (const id of out.changed) broadcastLane(id);
for (const id of out.removed) broadcast("lane_update", { removed: id });
return out;
}
function payload(lane) {
const healed = healRunId(lane);
return lanesLib.lanePayload(healed, lastEventAge(healed));
@@ -1774,7 +1825,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 } });
}
return res.status(500).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;
return res.status(status).json({ error: { code: err.code, message: err.message } });
}
broadcastLane(lane.id);
@@ -1794,3 +1847,4 @@ router.delete("/:id", sameOriginGuard, (req, res) => {
module.exports = router;
module.exports.broadcastLane = broadcastLane;
module.exports.healAllRunIds = healAllRunIds;
module.exports.broadcastForeignLaneChanges = broadcastForeignLaneChanges;