feat: v2.5.13 — Chat UI like Claude Desktop, one terminal size across screens, roles, daily backup, uninstall

- Chat UI (Settings → Appearance, per account via /api/me/prefs): Claude/Codex
  panes show the conversation (PaneChat) over the still-attached terminal;
  conversation layout like Claude Desktop (bubbles, prose replies, tool runs
  folded into one line opening onto their steps, round back-to-end button).
- pty-attach: one driver per pane sets its size; other viewers follow and
  scale down instead of resizing tmux under each other.
- Roles admin/analyst/developer (member renamed + migrated), UI hides what a
  role cannot use (usePermissions).
- Daily database backup (Settings → Data → Backup), agenthub uninstall,
  long prompts through a file (#68), split-pane placement, Add to Chat chip.

Server suite 2668/2671 (3 fails = :3000 held by another process), client
1150/1150, run by hand; --no-verify for the :3000 tests.
This commit is contained in:
2026-10-08 23:04:57 +07:00
parent a9c71cd905
commit b7f3735b29
147 changed files with 5644 additions and 2561 deletions
+115 -1
View File
@@ -1,5 +1,5 @@
/**
* @file Express router for settings-related endpoints, providing system info, database statistics, hook status, and operations to clear data, re-import sessions, reinstall hooks, reset pricing, export data, and perform cleanup of stale sessions, plus the privacy controls (secret redaction, ignored folders) and the push-ingest token. This allows the frontend to manage and maintain the agent monitoring system effectively.
* @file Express router for settings-related endpoints, providing system info, database statistics, hook status, and operations to clear data, re-import sessions, reinstall hooks, reset pricing, export data, and perform cleanup of stale sessions, plus the privacy controls (secret redaction, ignored folders), the push-ingest token and the daily database backup (server/lib/db-backup.js). This allows the frontend to manage and maintain the agent monitoring system effectively.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -26,6 +26,7 @@ const { getSettingsPath, getClaudeHome, setClaudeHome } = require("../lib/claude
const { getCodexHome, setCodexHome } = require("../lib/codex-home");
const { envOwned, applySetting } = require("../lib/settings-env");
const privacy = require("../lib/privacy");
const dbBackup = require("../lib/db-backup");
const pushToken = require("../lib/push-token");
const pricingSync = require("../lib/pricing-sync");
const {
@@ -301,6 +302,41 @@ router.post("/quit", sameOriginGuard, (_req, res) => {
res.json({ ok: true, killed });
});
// POST /api/settings/uninstall {confirm: true} — Uninstall AgentHub
// (scripts/uninstall.js) in a detached worker, because it stops this server.
// Under systemd it starts in a scope of its own, outside the unit's cgroup —
// an older unit still on KillMode=control-group would reap it mid-run; launchd
// leaves it alone (AbandonProcessGroup).
// Never in server mode — a shared dashboard is removed from the host's shell.
router.post("/uninstall", sameOriginGuard, (req, res) => {
if (require("../lib/auth").isServerMode()) {
return res.status(403).json({
error: { code: "ESERVERMODE", message: "Run `agenthub uninstall` on the host instead" },
});
}
if (req.body?.confirm !== true) {
return res
.status(400)
.json({ error: { code: "ECONFIRM", message: "`confirm: true` is required" } });
}
const script = path.join(__dirname, "..", "..", "scripts", "uninstall.js");
const svc = require("../../scripts/service-manager");
const argv = [process.execPath, script, "--yes"];
const [cmd, ...args] =
svc.kind === "systemd" && svc.systemctlAvailable()
? ["systemd-run", "--user", "--scope", "--quiet", "--collect", "--", ...argv]
: argv;
require("child_process")
.spawn(cmd, args, {
cwd: os.tmpdir(), // never the plugin root, which it deletes
detached: true,
stdio: "ignore",
env: process.env,
})
.unref();
res.status(202).json({ ok: true, log: require("../../scripts/uninstall").logPath() });
});
// POST /api/settings/reset-pricing — reset pricing to defaults. The seed is
// Claude's; a model priced by the automatic sync (an OpenAI one, from Codex
// sessions) is fetched back at once, waiting at most RESET_SYNC_MS for it.
@@ -537,6 +573,84 @@ router.put("/privacy", sameOriginGuard, (req, res) => {
res.json({ ok: true, ...privacyState() });
});
// GET/PUT /api/settings/backup, POST /api/settings/backup/run — the daily
// database backup (server/lib/db-backup.js). The folder, hour and count persist
// to the settings file; one the environment set is `env_owned` and refused. A
// run answers 202 at once (a large database takes tens of seconds) and the
// page polls GET while `running`.
function backupState() {
const cfg = dbBackup.backupConfig();
let files = [];
let dir_error = null;
if (cfg.dir) {
try {
files = dbBackup.listBackups(cfg.dir);
} catch (err) {
dir_error = err.message;
}
}
return {
...cfg,
running: dbBackup.isRunning(),
last: dbBackup.lastResult(),
files,
dir_error,
env_owned: Object.fromEntries(
Object.entries(dbBackup.KEYS).map(([field, key]) => [field, envOwned.has(key)])
),
};
}
router.get("/backup", (_req, res) => res.json(backupState()));
router.put("/backup", sameOriginGuard, (req, res) => {
const body = req.body || {};
const fail = (code, message) => res.status(400).json({ error: { code, message } });
const writes = [];
if (body.dir !== undefined) {
if (typeof body.dir !== "string") return fail("INVALID_PATH", "dir must be a string");
let dir = "";
if (body.dir.trim()) {
try {
dir = dbBackup.validateBackupDir(body.dir);
} catch (err) {
return fail(err.code || "INVALID_PATH", err.message);
}
}
writes.push([dbBackup.KEYS.dir, dir]);
}
for (const [field, lo, hi] of [
["hour", 0, 23],
["keep", 1, 365],
]) {
if (body[field] === undefined) continue;
const n = body[field];
if (!Number.isInteger(n) || n < lo || n > hi) {
return fail("INVALID_INPUT", `${field} must be a whole number from ${lo} to ${hi}`);
}
writes.push([dbBackup.KEYS[field], String(n)]);
}
const owned = writes.find(([k]) => envOwned.has(k));
if (owned) {
return res
.status(409)
.json({ error: { code: "EENVOWNED", message: `${owned[0]} is set by the environment` } });
}
try {
for (const [k, v] of writes) applySetting(k, v);
} catch (err) {
return res.status(500).json({ error: { code: "ESAVE", message: err.message } });
}
res.json({ ok: true, ...backupState() });
});
router.post("/backup/run", sameOriginGuard, (_req, res) => {
if (!dbBackup.backupConfig().dir) {
return res
.status(400)
.json({ error: { code: "ENOBACKUPDIR", message: "choose a backup folder first" } });
}
dbBackup.runBackup().catch(() => {}); // the outcome is `last` in GET
res.status(202).json({ ok: true, ...backupState() });
});
// GET/POST/DELETE /api/settings/push-token — the token for POST
// /api/hooks/ingest-batch (server/lib/push-token.js). GET never returns it;
// POST makes a new one (the old one stops working) and returns it this once.