feat: right-click menus everywhere, workspace/session cost, long-term DB safety

Right-click menus: one portal shell (components/ContextMenu.tsx) for every
item with actions - sidebar/Recents/archived session rows (SessionMenu),
console tabs, pane headers, the Changes panel's files/tree/commits, the
workspace home, sessions outside a workspace (SessionRowMenu: Sessions
table, Dashboard, Activity, Workflow runs), prompt images, every Settings
list and every Claude Code/Codex config list (FileItemMenu). Buttons stay;
a delete keeps its button's confirm; the shell stops portal clicks from
reaching the card that opened it.

Status bar: Workspace $ and Session $ on the Workspace page, from
?lane= on GET /api/pricing/cost and the focused/replayed session's cost.

Database, no data dropped: synchronous=NORMAL, journal_size_limit 64 MB,
hourly PASSIVE checkpoint + PRAGMA optimize, idx_events_session_created
(replacing the redundant idx_events_session), watchdog reads
sessions.transcript_path, transcript parsed before the hook's write
transaction, GET /api/sessions/:id?events=0 for the web page (reloads only
on its own session, debounced), streamed export, transactional clear-data
and chunked cleanup, subagent import reads tool_use_ids once, runtime logs
rotated past 5 MB.
This commit is contained in:
2026-10-05 09:37:48 +07:00
parent 65fe680d38
commit ad6e80192d
80 changed files with 2914 additions and 489 deletions
+25 -9
View File
@@ -1367,6 +1367,14 @@ router.post("/event", (req, res) => {
return res.json({ ok: true, duplicate: true });
}
// Parse the transcript BEFORE the write transaction: `processEvent` takes the
// write lock at its first statement and reads the transcript inside it, so a
// cold multi-MB parse held the lock and the other dashboard on this DB hit
// SQLITE_BUSY. Warmed here, the read inside is a stat-check cache hit.
if (tool === "claude" && typeof data.transcript_path === "string" && data.transcript_path) {
transcriptCache.extract(data.transcript_path);
}
const result = processEvent(hook_type, data, tool);
if (!result) {
return res.status(400).json({
@@ -1482,6 +1490,12 @@ router.post("/event", (req, res) => {
// sessions every 15s to detect errors that hooks missed.
const WATCHDOG_INTERVAL_MS = 15_000;
const STALE_THRESHOLD_MS = 10_000; // only check sessions idle for >10s
// The watchdog's fallback for a session with no transcript_path column value.
const lastTranscriptEvent = db.prepare(
`SELECT data FROM events WHERE session_id = ?
AND event_type IN ('SessionStart','UserPromptSubmit','PreToolUse','Stop','Notification')
ORDER BY created_at DESC LIMIT 1`
);
// Idle-working timeout for the no-marker interrupt case (Esc pressed before the
// model emits anything → Claude Code writes no interrupt entry and fires no
@@ -1541,11 +1555,11 @@ function watchdogCheck() {
// lifecycle is owned by server/lib/codex-usage.js.
const staleSessions = db
.prepare(
`SELECT s.id, s.status, s.cwd,
(SELECT MAX(e.created_at) FROM events e WHERE e.session_id = s.id) as last_event,
(SELECT e.data FROM events e WHERE e.session_id = s.id
AND e.event_type IN ('SessionStart','UserPromptSubmit','PreToolUse','Stop','Notification')
ORDER BY e.created_at DESC LIMIT 1) as last_data
// transcript_path is the session's own column (every hook keeps it);
// reading it out of the newest event's `data` walked a multi-KB payload
// per session, every 15 s, in every dashboard on this database.
`SELECT s.id, s.status, s.cwd, s.transcript_path,
(SELECT MAX(e.created_at) FROM events e WHERE e.session_id = s.id) as last_event
FROM sessions s
WHERE s.status IN ('active', 'error') AND s.updated_at < ?
AND (s.source = 'local' OR s.source IS NULL) AND s.tool = 'claude'`
@@ -1553,11 +1567,13 @@ function watchdogCheck() {
.all(cutoff);
for (const sess of staleSessions) {
// Try to get transcript_path from event data first
let tPath = null;
if (sess.last_data) {
// The session's recorded transcript first, else the newest hook payload
// that named one (a row from before the column was filled).
let tPath = sess.transcript_path || null;
if (!tPath) {
const lastData = lastTranscriptEvent.get(sess.id)?.data;
try {
tPath = JSON.parse(sess.last_data).transcript_path;
tPath = lastData ? JSON.parse(lastData).transcript_path : null;
} catch {}
}
// Fall back: derive from Claude's standard path layout
+19 -3
View File
@@ -463,15 +463,31 @@ router.delete("/:pattern", (req, res) => {
res.json({ ok: true });
});
// GET /api/pricing/cost - Get total cost across all sessions.
// GET /api/pricing/cost - Get total cost across all sessions (`?lane=<id>`: the
// sessions that workspace owns).
// Honors the global data-scope: `?sources=<csv>` narrows the aggregate to those
// origin machines (see server/lib/source-filter.js), matching the sessions /
// stats / analytics endpoints so the Dashboard "total cost" tracks the selector.
router.get("/cost", (req, res) => {
const rawOffset = parseInt(req.query.tz_offset, 10);
const tzModifier = Number.isFinite(rawOffset) ? `${-rawOffset} minutes` : "+0 minutes";
const { clause, params } = sourceColumnClause(parseSources(req), "s.source");
const whereClause = clause ? `WHERE ${clause}` : "";
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 scope = sourceColumnClause(parseSources(req), "s.source");
const params = [...scope.params];
const where = scope.clause ? [scope.clause] : [];
if (lane !== undefined) {
// The sessions the workspace owns — the same rule as GET /api/sessions?lane=.
where.push(
"(s.lane_id = ? OR (s.lane_id IS NULL AND s.id IN (SELECT session_id FROM dashboard_runs WHERE lane_id = ?)))"
);
params.push(Number(lane), Number(lane));
}
const whereClause = where.length ? `WHERE ${where.join(" AND ")}` : "";
const dailyTokens = dailyTokenRows(tzModifier, whereClause, params);
const rules = stmts.listPricing.all();
+3 -1
View File
@@ -300,7 +300,9 @@ router.get("/:id", (req, res) => {
// Each agent's OWN cost (from its metadata token buckets) so subagent cards on
// the session-detail tree show their real cost, not the session total.
const agents = attachAgentCosts(stmts.listAgentsBySession.all(req.params.id));
const events = stmts.listEventsBySession.all(req.params.id);
// `?events=0`: the web page pages its timeline separately, and a long
// session's full list is hundreds of MB of payload. The CLI still asks.
const events = req.query.events === "0" ? [] : stmts.listEventsBySession.all(req.params.id);
// Workflow-tool runs launched within this session (issue #167). Parse the
// JSON-blob columns so the client gets arrays, not strings.
const workflows = stmts.listWorkflowsBySession.all(req.params.id).map((w) => {
+93 -44
View File
@@ -141,7 +141,19 @@ router.get("/info", (req, res) => {
// POST /api/settings/clear-data — delete all sessions, agents, events, tokens
router.post("/clear-data", (_req, res) => {
const counts = getTableCounts();
// foreign_keys cannot change inside a transaction, so it is turned off
// around one: all of it lands, or none of it (a crash midway used to leave
// events without sessions).
db.pragma("foreign_keys = OFF");
try {
db.transaction(clearAll)();
} finally {
db.pragma("foreign_keys = ON");
}
res.json({ ok: true, cleared: counts });
});
function clearAll() {
db.prepare("DELETE FROM token_usage").run();
db.prepare("DELETE FROM token_usage_hourly").run();
db.prepare("DELETE FROM events").run();
@@ -157,9 +169,10 @@ router.post("/clear-data", (_req, res) => {
// Webhook delivery log is an audit trail of those fired alerts — wipe it too.
// Webhook *targets* survive, like alert rules and pricing.
db.prepare("DELETE FROM webhook_deliveries").run();
db.pragma("foreign_keys = ON");
res.json({ ok: true, cleared: counts });
});
// Workflow runs belong to the sessions; with foreign keys off they would
// not cascade and stay as orphans.
db.prepare("DELETE FROM workflows").run();
}
// POST /api/settings/reimport — re-import legacy sessions from ~/.claude/
router.post("/reimport", async (_req, res) => {
@@ -257,27 +270,53 @@ router.post("/reset-pricing", async (req, res) => {
res.json({ ok: true, pricing });
});
// GET /api/settings/export — export all data as JSON
router.get("/export", (_req, res) => {
const sessions = db.prepare("SELECT * FROM sessions ORDER BY started_at DESC").all();
const agents = db.prepare("SELECT * FROM agents ORDER BY started_at DESC").all();
const events = db.prepare("SELECT * FROM events ORDER BY created_at DESC").all();
const tokenUsage = db.prepare("SELECT * FROM token_usage").all();
const pricing = stmts.listPricing.all();
// GET /api/settings/export — export all data as JSON. Same document as ever,
// but written as it is read: `events` alone is GBs on a long-lived install,
// and one `.all()` + `res.json` held it in memory twice (past V8's string
// limit). Events go out in keyset pages, waiting for the socket to drain.
const EXPORT_PAGE = 500;
router.get("/export", async (req, res) => {
res.setHeader("Content-Type", "application/json");
res.setHeader(
"Content-Disposition",
`attachment; filename="agent-monitor-export-${new Date().toISOString().slice(0, 10)}.json"`
);
res.json({
exported_at: new Date().toISOString(),
sessions,
agents,
events,
token_usage: tokenUsage,
model_pricing: pricing,
});
let gone = false;
req.on("close", () => (gone = true));
const write = async (chunk) => {
if (!res.write(chunk)) await new Promise((r) => res.once("drain", r));
};
const field = (name, value) => `,${JSON.stringify(name)}:${JSON.stringify(value)}`;
try {
await write(`{"exported_at":${JSON.stringify(new Date().toISOString())}`);
await write(
field("sessions", db.prepare("SELECT * FROM sessions ORDER BY started_at DESC").all())
);
await write(field("agents", db.prepare("SELECT * FROM agents ORDER BY started_at DESC").all()));
await write(`,"events":[`);
const first = db.prepare(
`SELECT * FROM events ORDER BY created_at DESC, id DESC LIMIT ${EXPORT_PAGE}`
);
const next = db.prepare(
`SELECT * FROM events WHERE created_at < ? OR (created_at = ? AND id < ?)
ORDER BY created_at DESC, id DESC LIMIT ${EXPORT_PAGE}`
);
let page = first.all();
let sep = "";
while (page.length && !gone) {
await write(sep + page.map((e) => JSON.stringify(e)).join(","));
sep = ",";
const last = page[page.length - 1];
page = next.all(last.created_at, last.created_at, last.id);
}
await write("]");
await write(field("token_usage", db.prepare("SELECT * FROM token_usage").all()));
await write(field("model_pricing", stmts.listPricing.all()) + "}");
res.end();
} catch (err) {
console.warn("export failed:", err.message);
res.destroy(err);
}
});
// The two editable home directories share one shape: GET answers the value and
@@ -319,6 +358,7 @@ homeRoutes("/claude-home", "CLAUDE_HOME", "claude_home", getClaudeHome, setClaud
homeRoutes("/codex-home", "CODEX_HOME", "codex_home", getCodexHome, setCodexHome);
// POST /api/settings/cleanup — abandon stale sessions, purge old data
const PURGE_CHUNK = 500;
router.post("/cleanup", (req, res) => {
const { abandon_hours, purge_days } = req.body;
const result = { abandoned: 0, purged_sessions: 0, purged_events: 0, purged_agents: 0 };
@@ -337,15 +377,19 @@ router.post("/cleanup", (req, res) => {
)
.all(cutoff, cutoff);
for (const row of stale) {
db.prepare(
"UPDATE sessions SET status = 'abandoned', ended_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?"
).run(row.id);
// Also complete any lingering agents
db.prepare(
"UPDATE agents SET status = 'completed', ended_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE session_id = ? AND status IN ('waiting','working')"
).run(row.id);
}
const abandon = db.prepare(
"UPDATE sessions SET status = 'abandoned', ended_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?"
);
// Also complete any lingering agents
const complete = db.prepare(
"UPDATE agents SET status = 'completed', ended_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE session_id = ? AND status IN ('waiting','working')"
);
db.transaction(() => {
for (const row of stale) {
abandon.run(row.id);
complete.run(row.id);
}
})();
result.abandoned = stale.length;
}
@@ -358,23 +402,28 @@ router.post("/cleanup", (req, res) => {
)
.all(cutoff);
if (toDelete.length > 0) {
// One transaction, in chunks: a single IN (...) list of every id would
// pass SQLite's bound-variable limit on a large history.
db.transaction(() => {
const ids = toDelete.map((r) => r.id);
const placeholders = ids.map(() => "?").join(",");
// Cascading deletes handle agents/events, but token_usage FK might not cascade on all setups
result.purged_events = db
.prepare(`DELETE FROM events WHERE session_id IN (${placeholders})`)
.run(...ids).changes;
result.purged_agents = db
.prepare(`DELETE FROM agents WHERE session_id IN (${placeholders})`)
.run(...ids).changes;
db.prepare(`DELETE FROM token_usage WHERE session_id IN (${placeholders})`).run(...ids);
db.prepare(`DELETE FROM token_usage_hourly WHERE session_id IN (${placeholders})`).run(
...ids
);
db.prepare(`DELETE FROM sessions WHERE id IN (${placeholders})`).run(...ids);
result.purged_sessions = toDelete.length;
}
for (let i = 0; i < ids.length; i += PURGE_CHUNK) {
const chunk = ids.slice(i, i + PURGE_CHUNK);
const placeholders = chunk.map(() => "?").join(",");
// Cascading deletes handle agents/events, but token_usage FK might not cascade on all setups
result.purged_events += db
.prepare(`DELETE FROM events WHERE session_id IN (${placeholders})`)
.run(...chunk).changes;
result.purged_agents += db
.prepare(`DELETE FROM agents WHERE session_id IN (${placeholders})`)
.run(...chunk).changes;
db.prepare(`DELETE FROM token_usage WHERE session_id IN (${placeholders})`).run(...chunk);
db.prepare(`DELETE FROM token_usage_hourly WHERE session_id IN (${placeholders})`).run(
...chunk
);
db.prepare(`DELETE FROM sessions WHERE id IN (${placeholders})`).run(...chunk);
}
result.purged_sessions = ids.length;
})();
}
res.json({ ok: true, ...result });