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
+94 -9
View File
@@ -677,7 +677,14 @@ router.get("/:id/transcript", async (req, res) => {
// the live file is gone once Claude Code prunes it under cleanupPeriodDays
// (default 30 days), but the snapshot taken at import time survives.
let jsonlPath;
if (agentId && agentId !== "main") {
// A Codex session's conversation is its root thread's rollout journal
// (`codex_rollouts`, recorded by codex-usage.js), never a Claude transcript.
const isCodex = session.tool === "codex";
if (isCodex) {
jsonlPath = db
.prepare("SELECT path FROM codex_rollouts WHERE session_id = ? AND thread_id = ?")
.get(req.params.id, req.params.id)?.path;
} else if (agentId && agentId !== "main") {
jsonlPath =
getSubagentTranscriptPath(req.params.id, session.cwd, agentId, runId) ||
findSubagentTranscriptPath(req.params.id, agentId, runId) ||
@@ -707,6 +714,10 @@ router.get("/:id/transcript", async (req, res) => {
crlfDelay: Infinity,
});
const renders = isCodex
? (e) => e.type === "response_item"
: (e) => TRANSCRIPT_RENDER_TYPES.has(e.type);
// Dedupe state for synthetic rename markers: custom-title lines can repeat
// with the same value across a transcript, so only emit when the title
// actually changes from the last one we surfaced.
@@ -870,14 +881,14 @@ router.get("/:id/transcript", async (req, res) => {
} catch {
continue;
}
if (!TRANSCRIPT_RENDER_TYPES.has(entry.type)) continue;
if (!renders(entry)) continue;
if (!foundStart) {
if (lineNum <= afterLine) continue;
foundStart = true;
}
const message = parseMessage(entry, lineNum);
const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum);
if (!message) continue;
total++;
messages.push(message);
@@ -904,7 +915,7 @@ router.get("/:id/transcript", async (req, res) => {
} catch {
continue;
}
if (!TRANSCRIPT_RENDER_TYPES.has(entry.type)) continue;
if (!renders(entry)) continue;
if (lineNum >= beforeLine) {
// Reached the boundary — stop reading
rl.close();
@@ -912,7 +923,7 @@ router.get("/:id/transcript", async (req, res) => {
break;
}
const message = parseMessage(entry, lineNum);
const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum);
if (!message) continue;
total++;
messages.push(message);
@@ -934,9 +945,9 @@ router.get("/:id/transcript", async (req, res) => {
} catch {
continue;
}
if (!TRANSCRIPT_RENDER_TYPES.has(entry.type)) continue;
if (!renders(entry)) continue;
const message = parseMessage(entry, lineNum);
const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum);
if (!message) continue;
total++;
@@ -963,9 +974,9 @@ router.get("/:id/transcript", async (req, res) => {
} catch {
continue;
}
if (!TRANSCRIPT_RENDER_TYPES.has(entry.type)) continue;
if (!renders(entry)) continue;
const message = parseMessage(entry, lineNum);
const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum);
if (!message) continue;
total++;
messages.push(message);
@@ -998,6 +1009,80 @@ router.get("/:id/transcript", async (req, res) => {
}
});
// Context Codex injects as a user message: AGENTS.md and the `<snake_case>`
// blocks (environment_context, user_instructions, …).
const CODEX_INJECTED = /^(# AGENTS\.md instructions for |<[a-z]+(?:_[a-z]+)+[\s>])/;
const codexText = (parts) =>
Array.isArray(parts)
? parts.map((p) => (p && typeof p.text === "string" ? p.text : "")).join("")
: typeof parts === "string"
? parts
: "";
/**
* One Codex rollout `response_item` → a transcript message in the Claude shape
* the client renders, or null. Developer messages, injected context and
* encrypted-only reasoning are left out.
*/
function codexMessage(entry, num) {
const p = entry.payload || {};
const at = entry.timestamp || null;
const msg = (type, sender, content) => ({ type, sender, timestamp: at, content, line: num });
const out = (o) => truncate(codexText(o), 10240);
switch (p.type) {
case "message": {
if (p.role !== "user" && p.role !== "assistant") return null;
const content = (p.content || [])
.map((c) => (c && typeof c.text === "string" ? c.text : ""))
.filter((t) => t.trim() && !(p.role === "user" && CODEX_INJECTED.test(t.trimStart())))
.map((t) => ({ type: "text", text: truncate(t, 10240) }));
return content.length ? msg(p.role, p.role, content) : null;
}
case "reasoning": {
const t = codexText(p.summary);
return t.trim()
? msg("assistant", "assistant", [{ type: "thinking", text: truncate(t, 10240) }])
: null;
}
case "function_call":
case "custom_tool_call": {
let input = p.input;
if (p.type === "function_call") {
try {
input = JSON.parse(p.arguments);
} catch {
input = { arguments: p.arguments };
}
} else input = { input };
return msg("assistant", "assistant", [
{
type: "tool_use",
name: p.name || "unknown",
id: p.call_id || null,
input: truncateObj(input, 10240),
},
]);
}
case "function_call_output":
case "custom_tool_call_output": {
const o =
p.output && !Array.isArray(p.output) && typeof p.output === "object"
? p.output.content
: p.output;
return msg("user", "tool", [
{ type: "tool_result", id: p.call_id || null, output: out(o), is_error: false },
]);
}
case "agent_message": {
const t = codexText(p.content);
return t.trim() ? msg("user", "system", [{ type: "text", text: truncate(t, 10240) }]) : null;
}
default:
return null;
}
}
function truncate(str, maxLen) {
if (!str || str.length <= maxLen) return str;
return str.slice(0, maxLen) + "[truncated]";