fix: event-loop freezes, plugin-install boot crash, tmux attach/scrollbar, Codex transcripts

- GET /api/workflows: one LEAD() window pass in a read worker (read-pool.js,
  workflow-stats.js); idx_events_session restored, idx_events_tool added
- compat-sqlite gains function/pluck/readonly so a plugin install without a
  better-sqlite3 build boots again; antigravity-usage uses db.js's Database
- install-hooks adds no checkout hooks while the agenthub plugin is enabled
  (events were doubled)
- terminal attach runs the live tmux server's own binary; the pane scrollbar
  gets a visible 2-column style, and claude panes start with
  CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 so tmux keeps history to scroll
- Codex sessions' conversation in GET /api/sessions/:id/transcript
- Codex/Antigravity sessions claimed by their workspace without hooks
- UI-audit follow-ups, docs and OpenAPI
This commit is contained in:
2026-10-05 17:01:05 +07:00
parent 442f133930
commit 198fbb7980
78 changed files with 2716 additions and 873 deletions
+82 -1
View File
@@ -26,6 +26,7 @@ const { getSettingsPath, getClaudeHome, setClaudeHome } = require("../lib/claude
const { getCodexHome, setCodexHome } = require("../lib/codex-home");
const { envOwned } = require("../lib/settings-env");
const pricingSync = require("../lib/pricing-sync");
const { EXPORT_FORMAT, EXPORT_VERSION, importExportBundle } = require("../lib/data-transfer");
function getDbSize() {
try {
@@ -64,6 +65,17 @@ function getHookStatus() {
"SessionStart",
"SessionEnd",
];
// The enabled plugin runs the hooks from its own plugin.json, so
// settings.json rightly holds none of ours (installHooks removes them).
const { pluginProvidesHooks } = require("../../scripts/install-hooks");
if (pluginProvidesHooks(settings)) {
return {
installed: true,
source: "plugin",
path: getSettingsPath(),
hooks: Object.fromEntries(hookTypes.map((ht) => [ht, true])),
};
}
const hooks = {};
for (const ht of hookTypes) {
const entries = settings.hooks?.[ht] || [];
@@ -288,7 +300,10 @@ router.get("/export", async (req, res) => {
};
const field = (name, value) => `,${JSON.stringify(name)}:${JSON.stringify(value)}`;
try {
await write(`{"exported_at":${JSON.stringify(new Date().toISOString())}`);
await write(
`{"format":${JSON.stringify(EXPORT_FORMAT)},"version":${EXPORT_VERSION}` +
field("exported_at", new Date().toISOString())
);
await write(
field("sessions", db.prepare("SELECT * FROM sessions ORDER BY started_at DESC").all())
);
@@ -311,6 +326,16 @@ router.get("/export", async (req, res) => {
}
await write("]");
await write(field("token_usage", db.prepare("SELECT * FROM token_usage").all()));
await write(field("workflows", db.prepare("SELECT * FROM workflows").all()));
await write(
field(
"dashboard_runs",
db.prepare("SELECT * FROM dashboard_runs ORDER BY started_at DESC").all()
)
);
await write(
field("alert_rules", db.prepare("SELECT * FROM alert_rules ORDER BY created_at ASC").all())
);
await write(field("model_pricing", stmts.listPricing.all()) + "}");
res.end();
} catch (err) {
@@ -319,6 +344,62 @@ router.get("/export", async (req, res) => {
}
});
// POST /api/settings/import — restore a file written by GET /export, uploaded
// as multipart `file`. Non-destructive (data-transfer.js: a session already
// here is skipped whole, nothing is overwritten). The whole file is parsed in
// memory, hence the size cap; a bad file is answered without echoing its text.
// ponytail: one JSON.parse of the whole bundle; stream-parse if backups outgrow 512 MB.
const IMPORT_MAX_BYTES = 512 * 1024 * 1024;
router.post("/import", sameOriginGuard, (req, res) => {
const multer = require("multer");
const upload = multer({
dest: os.tmpdir(),
limits: { files: 1, fileSize: IMPORT_MAX_BYTES, fields: 8 },
}).single("file");
upload(req, res, (uploadErr) => {
const file = req.file;
const done = (status, body) => {
if (file) fs.rm(file.path, { force: true }, () => {});
res.status(status).json(body);
};
if (uploadErr) {
const tooBig = uploadErr.code === "LIMIT_FILE_SIZE";
return done(tooBig ? 413 : 400, {
error: {
code: tooBig ? "FILE_TOO_LARGE" : "BAD_UPLOAD",
message: tooBig ? "the backup is larger than 512 MB" : uploadErr.message,
},
});
}
if (!file) {
return done(400, { error: { code: "NO_FILE", message: "attach the export as `file`" } });
}
let bundle;
try {
bundle = JSON.parse(fs.readFileSync(file.path, "utf8"));
} catch {
return done(400, {
error: { code: "INVALID_FORMAT", message: "Not a valid export file (not JSON)." },
});
}
try {
const counts = importExportBundle(db, bundle);
done(200, {
ok: true,
source: file.originalname,
format: bundle.format ?? null,
...counts,
});
} catch (err) {
if (err.code === "INVALID_FORMAT") {
return done(400, { error: { code: err.code, message: err.message } });
}
console.warn("import failed:", err.message);
done(500, { error: { code: "IMPORT_FAILED", message: err.message } });
}
});
});
// The two editable home directories share one shape: GET answers the value and
// whether the real environment set it (`env_owned` — then a saved value is
// overridden again at the next start), PUT validates, applies and persists