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
+2
View File
@@ -23,6 +23,7 @@
*/
const fs = require("fs");
const { rotateIfLarge } = require("./log-rotate");
const path = require("path");
const { execFileSync, spawnSync } = require("child_process");
@@ -42,6 +43,7 @@ const state = { status: "running", started_at: new Date().toISOString(), steps:
function log(line) {
try {
rotateIfLarge(LOG_FILE);
fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${line}\n`);
} catch {
/* best effort */
+17 -5
View File
@@ -1029,16 +1029,27 @@ function importSubagentFromJsonl(dbModule, sessionId, mainAgentId, subData) {
// Per-tool-call events under the subagent's own agent_id so the UI can attribute
// them to the subagent. Idempotent by (agent_id, event_type, tool_use_id).
if (Array.isArray(subData.toolEvents) && subData.toolEvents.length > 0) {
const eventExists = db.prepare(
"SELECT 1 FROM events WHERE agent_id = ? AND event_type = ? AND data LIKE ? LIMIT 1"
// Every tool_use_id this agent already has, read ONCE: a `data LIKE` per
// tool event re-scanned the agent's payloads for each one, and this runs
// on every SubagentStop — quadratic in a long subagent's tool calls.
const seen = new Set(
db
.prepare(
`SELECT event_type, CASE WHEN json_valid(data) THEN json_extract(data, '$.tool_use_id') END AS id
FROM events WHERE agent_id = ? AND event_type IN ('PreToolUse', 'PostToolUse')`
)
.all(targetAgentId)
.filter((r) => r.id)
.map((r) => `${r.event_type}\u0001${r.id}`)
);
const has = (type, id) => seen.has(`${type}\u0001${id}`);
for (const tev of subData.toolEvents) {
if (!tev.tool_use_id) continue;
const useIdMarker = `%"tool_use_id":${JSON.stringify(tev.tool_use_id)}%`;
const ts = tev.pre_timestamp || subData.startedAt;
const truncatedInput = truncateForEvent(tev.tool_input);
if (!eventExists.get(targetAgentId, "PreToolUse", useIdMarker)) {
if (!has("PreToolUse", tev.tool_use_id)) {
seen.add(`PreToolUse\u0001${tev.tool_use_id}`);
insertEvent.run(
sessionId,
targetAgentId,
@@ -1057,7 +1068,8 @@ function importSubagentFromJsonl(dbModule, sessionId, mainAgentId, subData) {
created++;
}
if (tev.post_timestamp && !eventExists.get(targetAgentId, "PostToolUse", useIdMarker)) {
if (tev.post_timestamp && !has("PostToolUse", tev.tool_use_id)) {
seen.add(`PostToolUse\u0001${tev.tool_use_id}`);
insertEvent.run(
sessionId,
targetAgentId,
+41
View File
@@ -0,0 +1,41 @@
/**
* @file One-step rotation for AgentHub's append-only runtime logs (server.log,
* bootstrap.log, client-build.log, plugin-update.log, cc-update-all.log): a
* file past `max` bytes becomes `<file>.1`, replacing the previous one, so
* each log costs at most twice `max` however long the install lives. Pure fs,
* never throws — a log must never stop what it records.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
"use strict";
const fs = require("fs");
const DEFAULT_MAX = 5 * 1024 * 1024;
function rotateIfLarge(file, max = DEFAULT_MAX) {
try {
if (fs.statSync(file).size > max) fs.renameSync(file, `${file}.1`);
} catch {
/* missing file, or not ours to move: keep appending */
}
}
module.exports = { rotateIfLarge, DEFAULT_MAX };
if (require.main === module) {
// Self-check: node scripts/log-rotate.js
const os = require("os");
const path = require("path");
const assert = require("assert");
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "log-rotate-"));
const f = path.join(dir, "x.log");
fs.writeFileSync(f, "a".repeat(20));
rotateIfLarge(f, 10);
assert.ok(!fs.existsSync(f) && fs.statSync(`${f}.1`).size === 20);
fs.writeFileSync(f, "b".repeat(5));
rotateIfLarge(f, 10);
assert.equal(fs.readFileSync(f, "utf8"), "bbbbb");
rotateIfLarge(path.join(dir, "missing.log"), 10);
fs.rmSync(dir, { recursive: true, force: true });
console.log("log-rotate ok");
}
+3
View File
@@ -27,6 +27,7 @@
*/
const fs = require("fs");
const { rotateIfLarge } = require("./log-rotate");
const os = require("os");
const path = require("path");
const crypto = require("crypto");
@@ -469,6 +470,7 @@ function waitForPortsFree(timeoutMs = 10000) {
}
function startDashboard(rt = runtimeDir(), root = PLUGIN_ROOT) {
rotateIfLarge(path.join(rt, "server.log"));
const logFile = fs.openSync(path.join(rt, "server.log"), "a");
const child = spawn(process.execPath, [path.join(root, "server", "index.js")], {
cwd: root,
@@ -490,6 +492,7 @@ function startDashboard(rt = runtimeDir(), root = PLUGIN_ROOT) {
function log(rt, line) {
const stamped = `[${new Date().toISOString()}] ${line}\n`;
try {
rotateIfLarge(path.join(rt, "bootstrap.log"));
fs.appendFileSync(path.join(rt, "bootstrap.log"), stamped);
} catch {
/* best effort */
+2
View File
@@ -19,6 +19,7 @@
*/
const fs = require("fs");
const { rotateIfLarge } = require("./log-rotate");
const path = require("path");
const { spawnSync } = require("child_process");
@@ -55,6 +56,7 @@ function buildClient({
const dist = path.join(rt, "client-dist");
if (!force && fs.existsSync(path.join(dist, "index.html"))) return "ready";
if (logPath) rotateIfLarge(logPath);
const logFd = logPath ? fs.openSync(logPath, "a") : null;
const stdio = logFd === null ? "inherit" : ["ignore", logFd, logFd];
try {
+2
View File
@@ -29,6 +29,7 @@
*/
const fs = require("fs");
const { rotateIfLarge } = require("./log-rotate");
const path = require("path");
const { execFileSync, spawnSync } = require("child_process");
@@ -45,6 +46,7 @@ const SAFE_NAME = /^[A-Za-z0-9][\w.-]*$/;
function log(line) {
try {
rotateIfLarge(LOG_FILE);
fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${line}\n`);
} catch {
/* best effort */