1c5521cf2e
UI
- No animation runs forever: Sagi moods, the custom pet sprite, status dots
(pulse-dot, pulse-slow, ping) and the chat shimmer play a few cycles and
rest. An infinite animation kept the browser drawing 60 frames a second.
- useSagiBrain: frames that only move lastActivityAt by <1 s keep the old
state; the 1 s tick re-renders only when the mood changes.
- ConversationView and MessageList are memoized and PaneChat passes stable
callbacks: typing in the chat box no longer re-renders the conversation.
- The chat's 700 ms menu poll reads plain text first and scans the terminal
cell by cell (reusing one cell) only when a row could be a menu cursor row.
Server
- GET /sessions/:id/transcript?after=N skips lines <= N before JSON.parse.
- GET /sessions/:id/outline is cached until the session's events change.
- broadcastLane uses the cached tmux.liveSessions(); tmux.hasSession answers
"yes" from the fresh shared list instead of a synchronous spawn (50-150 ms
per psmux call on Windows, blocking terminal echo).
- pane-mode polls every 150 ms; writeHourly writes only changed hour buckets.
- /api/metrics: agenthub_event_loop_delay_seconds{quantile}.
Hooks
- PreToolUse/PostToolUse run with "async": true (settings installer,
plugin.json, S3 managed settings): Claude Code no longer waits a Node start
before and after every tool call.
Committed with --no-verify at the owner's request: the pre-commit full server
suite fails on 9 tests that fail identically on 0c2f498 (lane-sync merge: no
git identity on this host; read-pool; resolveHost; codex-usage 1 ms flake).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1452 lines
56 KiB
JavaScript
1452 lines
56 KiB
JavaScript
/**
|
|
* @file Express router for session endpoints, allowing creation, retrieval, and updating of sessions with optional pagination and filtering by status. It also computes costs for sessions based on token usage and pricing rules, and broadcasts session changes to connected WebSocket clients for real-time updates.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { Router } = require("express");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const { stmts, db } = require("../db");
|
|
const { broadcast } = require("../websocket");
|
|
const { calculateCost, attachAgentCosts } = require("./pricing");
|
|
const { parseSources, sourceColumnClause } = require("../lib/source-filter");
|
|
const { memberId, sessionWhere, canSeeSession } = require("../lib/scope");
|
|
const { isServerMode } = require("../lib/auth");
|
|
const {
|
|
projectsDirFor,
|
|
sessionProjectsDirs,
|
|
getTranscriptPath,
|
|
getSubagentTranscriptPath,
|
|
getSnapshotTranscriptPath,
|
|
getSnapshotSubagentTranscriptPath,
|
|
findTranscriptPath,
|
|
findSubagentTranscriptPath,
|
|
} = require("../lib/claude-home");
|
|
|
|
const router = Router();
|
|
|
|
// JSONL entry types the transcript reader turns into renderable messages.
|
|
// `user`/`assistant` are the conversation. `custom-title` is the metadata line
|
|
// written by /rename, `claude -n`, and the picker's Ctrl+R — surfaced as an
|
|
// inline rename marker so a rename is visible even when there is no command
|
|
// line (e.g. `claude -n` at startup). `system` carries local slash-command I/O
|
|
// in newer Claude Code builds — `system`/`local_command` lines hold the TUI
|
|
// markup (`<command-name>`, `<local-command-stdout>`, …) in a top-level
|
|
// `content` string, so /color, /rename, /clear, and custom commands render as
|
|
// command pills + their captured output; every other `system` subtype
|
|
// (turn_duration, stop_hook_summary, away_summary, …) is dropped as noise.
|
|
// (ai-title is intentionally excluded: it repeats on nearly every turn and
|
|
// would flood the stream; it drives the session NAME instead, not the chat.)
|
|
// `attachment` is included ONLY for its `queued_command` subtype: a message the
|
|
// human typed mid-turn (while Claude was still working) is written to the JSONL
|
|
// as `queue-operation` bookkeeping lines plus a `queued_command` attachment at
|
|
// the point the model actually saw it — there is NO `type:"user"` line for it,
|
|
// so without this the Conversation tab silently drops mid-turn messages. Every
|
|
// other attachment subtype (task_reminder, hook_success, skill_listing, …) is
|
|
// harness noise and stays hidden.
|
|
const TRANSCRIPT_RENDER_TYPES = new Set([
|
|
"user",
|
|
"assistant",
|
|
"custom-title",
|
|
"system",
|
|
"attachment",
|
|
]);
|
|
|
|
/**
|
|
* Classify the TRUE sender of a transcript entry. A JSONL `type:"user"` line is
|
|
* not always the human: it also carries tool results, harness-injected
|
|
* task-notifications, /loop re-injections (`isMeta`), and — in a subagent
|
|
* transcript — the task prompt handed down by the orchestrator. Attributing all
|
|
* of those to "User" is wrong; the UI styles each sender distinctly.
|
|
*
|
|
* Returns: "user" | "assistant" | "orchestrator" | "system" | "tool".
|
|
* user — a real message typed by the human
|
|
* assistant — the agent's own turn
|
|
* orchestrator — a subagent's task, assigned by its parent/main agent
|
|
* system — harness/tooling injection (task-notification, /loop meta, …)
|
|
* tool — a tool_result echoed back on a `user` line
|
|
*/
|
|
function classifyTranscriptSender(entry, isSubagentFile) {
|
|
if (entry.type === "assistant") return "assistant";
|
|
// `system`/local_command lines are surfaced as the human's slash-command I/O.
|
|
if (entry.type !== "user") return "user";
|
|
|
|
const content = entry.message ? entry.message.content : undefined;
|
|
const onlyToolResults =
|
|
Array.isArray(content) &&
|
|
content.length > 0 &&
|
|
content.every((b) => b && b.type === "tool_result");
|
|
if (entry.toolUseResult !== undefined || onlyToolResults) return "tool";
|
|
|
|
const text =
|
|
typeof content === "string"
|
|
? content
|
|
: Array.isArray(content)
|
|
? (content.find((b) => b && b.type === "text") || {}).text || ""
|
|
: "";
|
|
const lead = text.replace(/^\s+/, "");
|
|
|
|
// Harness injections that masquerade as a user line.
|
|
if (entry.isMeta === true) return "system";
|
|
if (lead.startsWith("<task-notification>") || lead.startsWith("<task-notification ")) {
|
|
return "system";
|
|
}
|
|
// Background-task event banner ("[SYSTEM NOTIFICATION - NOT USER INPUT] …")
|
|
// that newer harness builds prefix ahead of the <task-notification> payload.
|
|
if (lead.startsWith("[SYSTEM NOTIFICATION")) return "system";
|
|
|
|
// In a subagent transcript, a user line with no human prompt provenance is the
|
|
// task injected by the Task/Agent tool. A real human message to the subagent
|
|
// (rare, but allowed) carries promptSource/origin and stays "user".
|
|
if (isSubagentFile && entry.promptSource === undefined && entry.origin === undefined) {
|
|
return "orchestrator";
|
|
}
|
|
|
|
return "user";
|
|
}
|
|
|
|
/**
|
|
* Read only the first non-empty line from a JSONL file using streaming.
|
|
* Avoids loading the entire file into memory.
|
|
*/
|
|
async function readFirstLine(filePath) {
|
|
// readLines, never readline: a member's one-line 512 MB file crashes readline.
|
|
for await (const line of require("../lib/run-as").readLines(filePath)) return line;
|
|
return null;
|
|
}
|
|
|
|
// Every priced column, as `stmts.getTokensBySession` reads one session — so a
|
|
// list row's cost equals the session's own `/api/pricing/cost/:id` (speed, geo,
|
|
// tier, the 1h cache write and server-tool fees included). ≤ 900 ids per call.
|
|
function tokensForSessions(ids) {
|
|
const out = {};
|
|
if (ids.length === 0) return out;
|
|
const rows = db
|
|
.prepare(
|
|
`SELECT session_id, model, speed, inference_geo, service_tier,
|
|
input_tokens + baseline_input as input_tokens,
|
|
output_tokens + baseline_output as output_tokens,
|
|
cache_read_tokens + baseline_cache_read as cache_read_tokens,
|
|
cache_write_tokens + baseline_cache_write as cache_write_tokens,
|
|
cache_write_1h_tokens + baseline_cache_write_1h as cache_write_1h_tokens,
|
|
web_search_requests + baseline_web_search as web_search_requests,
|
|
web_fetch_requests + baseline_web_fetch as web_fetch_requests,
|
|
code_execution_requests + baseline_code_execution as code_execution_requests,
|
|
long_input_tokens, long_output_tokens, long_cache_read_tokens,
|
|
long_cache_write_tokens, long_cache_write_1h_tokens
|
|
FROM token_usage WHERE session_id IN (${ids.map(() => "?").join(",")})`
|
|
)
|
|
.all(...ids);
|
|
for (const t of rows) (out[t.session_id] ||= []).push(t);
|
|
return out;
|
|
}
|
|
|
|
router.get("/", (req, res) => {
|
|
const limit = Math.min(parseInt(req.query.limit) || 50, 10000);
|
|
const offset = parseInt(req.query.offset) || 0;
|
|
const status = req.query.status;
|
|
const q = typeof req.query.q === "string" ? req.query.q.trim() : "";
|
|
const cwd = req.query.cwd;
|
|
// Which CLI's sessions (claude / codex / antigravity). Applied here, before
|
|
// the limit, so a busy Codex lane cannot push Claude rows out of a page.
|
|
const tool = typeof req.query.tool === "string" ? req.query.tool : "";
|
|
// `lane` = the sessions a workspace owns (`sessions.lane_id`).
|
|
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 sortBy = req.query.sort_by || "time"; // "time", "duration", "price"
|
|
const sortDesc = req.query.sort_desc !== "false";
|
|
|
|
let where = [];
|
|
let params = [];
|
|
|
|
if (q) {
|
|
const like = `%${q}%`;
|
|
where.push("(s.id LIKE ? OR s.name LIKE ? OR s.cwd LIKE ?)");
|
|
params.push(like, like, like);
|
|
}
|
|
if (status) {
|
|
where.push("s.status = ?");
|
|
params.push(status);
|
|
}
|
|
if (cwd) {
|
|
where.push("s.cwd = ?");
|
|
params.push(cwd);
|
|
}
|
|
if (tool) {
|
|
where.push("s.tool = ?");
|
|
params.push(tool);
|
|
}
|
|
if (lane !== undefined) {
|
|
// Also a session one of the lane's runs holds that nothing stamped yet:
|
|
// two dashboards may share the DB and hooks reach only one of them, which
|
|
// may be an older build that never stamps (bindRunSession).
|
|
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));
|
|
}
|
|
// `awaiting=1` = only sessions blocked on the user (an active session with
|
|
// `awaiting_input_since`), the UI's "Waiting" filter, done before the limit.
|
|
if (req.query.awaiting === "1") {
|
|
where.push("s.awaiting_input_since IS NOT NULL AND s.status = 'active'");
|
|
}
|
|
// `from`/`to` (ISO) bound `started_at`; an unparseable value is ignored.
|
|
for (const [key, op] of [
|
|
["from", ">="],
|
|
["to", "<="],
|
|
]) {
|
|
const raw = req.query[key];
|
|
const d = typeof raw === "string" && raw ? new Date(raw) : null;
|
|
if (d && !Number.isNaN(d.getTime())) {
|
|
where.push(`s.started_at ${op} ?`);
|
|
params.push(d.toISOString());
|
|
}
|
|
}
|
|
// `archived=0|1` = only live / only archived; absent = both, as before.
|
|
if (req.query.archived === "0") where.push("s.archived_at IS NULL");
|
|
if (req.query.archived === "1") where.push("s.archived_at IS NOT NULL");
|
|
// Data-scope filter: restrict to sessions collected from a chosen set of
|
|
// machines (local + any configured remote sources). Absent = all sources.
|
|
const sourceFilter = sourceColumnClause(parseSources(req));
|
|
if (sourceFilter.clause) {
|
|
where.push(sourceFilter.clause);
|
|
params.push(...sourceFilter.params);
|
|
}
|
|
// Server mode: a member lists only their own workspaces' sessions.
|
|
if (memberId(req) != null) {
|
|
const own = sessionWhere(req, "s.id", "s.lane_id");
|
|
where.push(own.where);
|
|
params.push(...own.params);
|
|
}
|
|
|
|
const whereSql = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
|
|
const total = db.prepare(`SELECT COUNT(*) as c FROM sessions s ${whereSql}`).get(...params).c;
|
|
|
|
let rows = [];
|
|
|
|
if (sortBy === "price") {
|
|
const allRows = db
|
|
.prepare(
|
|
`SELECT s.*, COUNT(a.id) as agent_count, s.updated_at as last_activity
|
|
FROM sessions s LEFT JOIN agents a ON a.session_id = s.id
|
|
${whereSql}
|
|
GROUP BY s.id`
|
|
)
|
|
.all(...params);
|
|
|
|
if (allRows.length > 0) {
|
|
const rules = stmts.listPricing.all();
|
|
|
|
for (let i = 0; i < allRows.length; i += 900) {
|
|
const chunk = allRows.slice(i, i + 900);
|
|
const ids = chunk.map((r) => r.id);
|
|
const tokensBySession = tokensForSessions(ids);
|
|
|
|
for (const row of chunk) {
|
|
const sessionTokens = tokensBySession[row.id];
|
|
row.cost = sessionTokens
|
|
? calculateCost(sessionTokens, rules, row.started_at).total_cost
|
|
: 0;
|
|
}
|
|
}
|
|
|
|
allRows.sort((a, b) => {
|
|
return sortDesc ? b.cost - a.cost : a.cost - b.cost;
|
|
});
|
|
rows = allRows.slice(offset, offset + limit);
|
|
}
|
|
} else {
|
|
let orderSql = "s.updated_at DESC";
|
|
if (sortBy === "time") {
|
|
orderSql = `s.updated_at ${sortDesc ? "DESC" : "ASC"}`;
|
|
} else if (sortBy === "duration") {
|
|
orderSql = `(julianday(COALESCE(s.ended_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) - julianday(s.started_at)) ${sortDesc ? "DESC" : "ASC"}`;
|
|
}
|
|
|
|
rows = db
|
|
.prepare(
|
|
`SELECT s.*, COUNT(a.id) as agent_count, s.updated_at as last_activity
|
|
FROM sessions s LEFT JOIN agents a ON a.session_id = s.id
|
|
${whereSql}
|
|
GROUP BY s.id ORDER BY ${orderSql} LIMIT ? OFFSET ?`
|
|
)
|
|
.all(...params, limit, offset);
|
|
|
|
if (rows.length > 0) {
|
|
const rules = stmts.listPricing.all();
|
|
const tokensBySession = tokensForSessions(rows.map((r) => r.id));
|
|
|
|
for (const row of rows) {
|
|
const sessionTokens = tokensBySession[row.id];
|
|
row.cost = sessionTokens
|
|
? calculateCost(sessionTokens, rules, row.started_at).total_cost
|
|
: 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
res.json({ sessions: rows, limit, offset, total });
|
|
});
|
|
|
|
router.get("/facets", (req, res) => {
|
|
const own = sessionWhere(req);
|
|
const rows = db
|
|
.prepare(
|
|
`SELECT DISTINCT cwd FROM sessions WHERE cwd IS NOT NULL AND cwd != '' AND ${own.where} ORDER BY cwd`
|
|
)
|
|
.all(...own.params);
|
|
// Distinct origins present in the data, so the UI can offer a source facet /
|
|
// scope selector. Always includes at least 'local' (the column default).
|
|
const sources = (
|
|
memberId(req) == null
|
|
? stmts.distinctSessionSources.all()
|
|
: db
|
|
.prepare(
|
|
`SELECT DISTINCT source FROM sessions WHERE source IS NOT NULL AND source != '' AND ${own.where} ORDER BY source`
|
|
)
|
|
.all(...own.params)
|
|
).map((r) => r.source);
|
|
res.json({ cwds: rows.map((r) => r.cwd), sources });
|
|
});
|
|
|
|
router.get("/:id", (req, res) => {
|
|
const session = stmts.getSession.get(req.params.id);
|
|
if (!session) {
|
|
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Session not found" } });
|
|
}
|
|
// 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));
|
|
// `?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) => {
|
|
let phases = [];
|
|
let progress = [];
|
|
try {
|
|
phases = w.phases ? JSON.parse(w.phases) : [];
|
|
} catch {
|
|
phases = [];
|
|
}
|
|
try {
|
|
progress = w.progress ? JSON.parse(w.progress) : [];
|
|
} catch {
|
|
progress = [];
|
|
}
|
|
return { ...w, phases, progress };
|
|
});
|
|
res.json({ session, agents, events, workflows });
|
|
});
|
|
|
|
/**
|
|
* GET /:id/stats — Aggregated counts for the SessionOverview panel.
|
|
*
|
|
* Returns at-a-glance metrics used by the Agents tab on the Session detail page.
|
|
* All aggregation runs in SQL so we don't ship 14k+ event rows to the client.
|
|
*/
|
|
router.get("/:id/stats", (req, res) => {
|
|
const session = stmts.getSession.get(req.params.id);
|
|
if (!session) {
|
|
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Session not found" } });
|
|
}
|
|
res.json(sessionStats(req.params.id));
|
|
});
|
|
|
|
const OUTLINE_TOOLS = [
|
|
"TaskCreate",
|
|
"TaskUpdate",
|
|
"TodoWrite",
|
|
"Write",
|
|
"Edit",
|
|
"MultiEdit",
|
|
"NotebookEdit",
|
|
"Read",
|
|
"Skill",
|
|
];
|
|
const outlineEvents = db.prepare(
|
|
`SELECT tool_name, data FROM events
|
|
WHERE session_id = ?
|
|
AND ((event_type = 'PostToolUse'
|
|
AND (tool_name IN (${OUTLINE_TOOLS.map(() => "?").join(",")})
|
|
OR (tool_name >= 'mcp__' AND tool_name < 'mcp_\`')))
|
|
OR event_type = 'UserPromptSubmit')
|
|
ORDER BY id`
|
|
);
|
|
// A file the composer attached (`/api/run/drop-*` names it), as its path is
|
|
// pasted into the prompt; and a Claude Code memory file.
|
|
// The drop dir is `<data dir>/drops/`; older transcripts name `<tmp>/agenthub-drops-<uid>/`.
|
|
const UPLOAD_RE =
|
|
/(?:^|\s)(\S*\/(?:agenthub-drops-\d+|drops)\/[0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+)/g;
|
|
const isUpload = (p) => /\/(?:agenthub-drops-\d+|drops)\/[0-9a-f]{16}/.test(p);
|
|
const isMemory = (p) => /\/\.claude\/.*\/memory\//.test(p);
|
|
/** Records `p` once, newest last. */
|
|
const touch = (set, p) => {
|
|
set.delete(p);
|
|
set.add(p);
|
|
};
|
|
|
|
/** The `/:id/outline` body: the session's plan, the files it wrote or edited
|
|
* (`outputs`), what it used — the files it read, the files attached to its
|
|
* prompts (`uploads`), the memory files it read or wrote, skills and MCP
|
|
* servers — read off its successful tool calls (`PostToolUse`) and prompts.
|
|
* A `TodoWrite` replaces the plan wholesale; `TaskCreate` / `TaskUpdate` edit
|
|
* it by task id (a hook posted twice changes nothing). Lists are newest first,
|
|
* except skills and MCP servers (first use first). */
|
|
function sessionOutline(sessionId) {
|
|
let tasks = new Map();
|
|
const outputs = new Set();
|
|
const files = new Set();
|
|
const uploads = new Set();
|
|
const memory = new Set();
|
|
const skills = new Set();
|
|
const mcp = new Set();
|
|
for (const e of outlineEvents.all(sessionId, ...OUTLINE_TOOLS)) {
|
|
let d;
|
|
try {
|
|
d = JSON.parse(e.data);
|
|
} catch {
|
|
continue;
|
|
}
|
|
const input = d?.tool_input || {};
|
|
const t = e.tool_name;
|
|
const p = input.file_path || input.notebook_path;
|
|
if (!t) {
|
|
// A prompt: the paths it carries of files the composer attached.
|
|
for (const m of String(d?.prompt ?? "").matchAll(UPLOAD_RE)) touch(uploads, m[1]);
|
|
} else if (typeof p === "string" && isMemory(p)) {
|
|
touch(memory, p);
|
|
} else if (t === "Read") {
|
|
if (typeof p === "string" && !isUpload(p) && !outputs.has(p)) touch(files, p);
|
|
} else if (t === "TodoWrite" && Array.isArray(input.todos)) {
|
|
tasks = new Map(
|
|
input.todos.map((x, i) => [
|
|
String(i),
|
|
{ id: String(i), subject: String(x?.content ?? ""), status: x?.status || "pending" },
|
|
])
|
|
);
|
|
} else if (t === "TaskCreate") {
|
|
const id = d?.tool_response?.task?.id;
|
|
if (id != null && !tasks.has(String(id)))
|
|
tasks.set(String(id), {
|
|
id: String(id),
|
|
subject: String(input.subject ?? ""),
|
|
status: "pending",
|
|
});
|
|
} else if (t === "TaskUpdate") {
|
|
const task = tasks.get(String(input.taskId));
|
|
if (!task) continue;
|
|
if (input.status === "deleted") tasks.delete(task.id);
|
|
else {
|
|
if (input.status) task.status = input.status;
|
|
if (input.subject) task.subject = String(input.subject);
|
|
}
|
|
} else if (["Write", "Edit", "MultiEdit", "NotebookEdit"].includes(t)) {
|
|
if (typeof p === "string") {
|
|
touch(outputs, p);
|
|
files.delete(p); // a file it changed is an output, not just used
|
|
}
|
|
} else if (t === "Skill") {
|
|
if (typeof input.skill === "string") skills.add(input.skill);
|
|
} else {
|
|
const server = t.slice(5, t.lastIndexOf("__"));
|
|
if (server) mcp.add(server.replace(/^plugin_/, ""));
|
|
}
|
|
}
|
|
return {
|
|
progress: [...tasks.values()],
|
|
outputs: [...outputs].reverse(),
|
|
files: [...files].reverse(),
|
|
uploads: [...uploads].reverse(),
|
|
memory: [...memory].reverse(),
|
|
skills: [...skills],
|
|
mcp: [...mcp],
|
|
};
|
|
}
|
|
|
|
// Each open chat polls its outline every few seconds, and building it parses
|
|
// every tool event of the session: keep the last answer per session until its
|
|
// events change (a new id, or fewer rows after a cleanup).
|
|
const outlineStamp = db.prepare(
|
|
"SELECT MAX(id) AS max_id, COUNT(*) AS n FROM events WHERE session_id = ?"
|
|
);
|
|
const OUTLINE_CACHE_MAX = 100;
|
|
const outlineCache = new Map();
|
|
function cachedOutline(sessionId) {
|
|
const st = outlineStamp.get(sessionId);
|
|
const stamp = `${st.max_id}:${st.n}`;
|
|
const hit = outlineCache.get(sessionId);
|
|
if (hit && hit.stamp === stamp) return hit.body;
|
|
const body = sessionOutline(sessionId);
|
|
outlineCache.delete(sessionId);
|
|
outlineCache.set(sessionId, { stamp, body });
|
|
if (outlineCache.size > OUTLINE_CACHE_MAX) outlineCache.delete(outlineCache.keys().next().value);
|
|
return body;
|
|
}
|
|
|
|
router.get("/:id/outline", (req, res) => {
|
|
if (!stmts.getSession.get(req.params.id)) {
|
|
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Session not found" } });
|
|
}
|
|
res.json(cachedOutline(req.params.id));
|
|
});
|
|
|
|
// An image the session's own Read tool returned (`?path=`, exact), for the Chat
|
|
// pane's Files read preview when it lies outside the workspace (a screenshot
|
|
// under ~/.cache, /tmp). The path is authorized only by that event, so: raster
|
|
// types only, under a sandbox CSP with nosniff; never for a pushed session
|
|
// (another machine's paths). In server mode only with S3, where a member's hook
|
|
// reports sessions in its own home only (hook-auth.js): a member's session gets
|
|
// only a file inside that home and owned by them; a host session (a trusted
|
|
// peer's) anything. Without S3 a member's hook could name a host-only file.
|
|
const READ_IMAGE_TYPES = {
|
|
png: "image/png",
|
|
jpg: "image/jpeg",
|
|
jpeg: "image/jpeg",
|
|
gif: "image/gif",
|
|
webp: "image/webp",
|
|
};
|
|
const READ_IMAGE_MAX = 20 * 1024 * 1024;
|
|
const sessionReadFile = db.prepare(
|
|
`SELECT 1 FROM events
|
|
WHERE session_id = ? AND event_type = 'PostToolUse' AND tool_name = 'Read'
|
|
AND json_extract(data, '$.tool_input.file_path') = ?
|
|
LIMIT 1`
|
|
);
|
|
router.get("/:id/image", async (req, res) => {
|
|
const fail = (status, code, message) => res.status(status).json({ error: { code, message } });
|
|
const session = stmts.getSession.get(req.params.id);
|
|
if (!session) return fail(404, "NOT_FOUND", "Session not found");
|
|
const s3 = require("../lib/linux-accounts").s3Enabled();
|
|
if ((isServerMode() && !s3) || String(session.source || "").startsWith("push:")) {
|
|
return fail(403, "EUNSUPPORTED", "not available for this session");
|
|
}
|
|
const file = typeof req.query.path === "string" ? req.query.path : "";
|
|
const type = READ_IMAGE_TYPES[path.extname(file).slice(1).toLowerCase()];
|
|
if (!path.isAbsolute(file) || !type) return fail(400, "ENOTIMAGE", "not an image path");
|
|
const runAs = require("../lib/run-as");
|
|
const member = s3 ? runAs.ownerForRead(session.cwd) : null;
|
|
if (member && !require("../lib/hook-auth").inside(member.home, file)) {
|
|
return fail(404, "ENOTFOUND", "the session never read that image");
|
|
}
|
|
if (!sessionReadFile.get(session.id, file)) {
|
|
return fail(404, "ENOTFOUND", "the session never read that image");
|
|
}
|
|
let fh;
|
|
try {
|
|
// NONBLOCK: a FIFO named like an image must not hang the event loop.
|
|
fh = await fs.promises.open(file, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0));
|
|
const st = await fh.stat();
|
|
// A file in a member's home is read only when that member owns it (openSafe's rule).
|
|
const fileOwner = s3 ? runAs.ownerForRead(file) : null;
|
|
if (!st.isFile() || (fileOwner && st.uid !== fileOwner.uid)) {
|
|
return fail(404, "ENOTFOUND", "no such image");
|
|
}
|
|
if (st.size > READ_IMAGE_MAX) return fail(400, "ETOOBIG", "image is larger than 20 MB");
|
|
const buf = await fh.readFile();
|
|
res.set({
|
|
"Content-Type": type,
|
|
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",
|
|
"X-Content-Type-Options": "nosniff",
|
|
"Cache-Control": "no-store",
|
|
});
|
|
res.send(buf);
|
|
} catch {
|
|
if (!res.headersSent) fail(404, "ENOTFOUND", "no such image");
|
|
} finally {
|
|
await fh?.close();
|
|
}
|
|
});
|
|
|
|
// A text file the session's own Read, Write, Edit, MultiEdit or NotebookEdit
|
|
// named (`?path=`, exact), for the Chat pane's Outputs/Files preview when it
|
|
// lies outside the workspace (a report written to ~/Documents, issue #76).
|
|
// The same authorization as `/image` — that event, never a pushed session,
|
|
// server mode only with S3 and a member's session only inside their own home
|
|
// on a file they own — plus: never a credential by name
|
|
// (`isSecretConfigPath`, a `settings.local.json`), never a binary or a
|
|
// non-regular file, at most READ_TEXT_MAX bytes (the rest is `truncated`).
|
|
const READ_TEXT_MAX = 512 * 1024;
|
|
const sessionTouchedFile = db.prepare(
|
|
`SELECT 1 FROM events
|
|
WHERE session_id = ? AND event_type = 'PostToolUse'
|
|
AND tool_name IN ('Read', 'Write', 'Edit', 'MultiEdit', 'NotebookEdit')
|
|
AND (json_extract(data, '$.tool_input.file_path') = ?
|
|
OR json_extract(data, '$.tool_input.notebook_path') = ?)
|
|
LIMIT 1`
|
|
);
|
|
router.get("/:id/file", async (req, res) => {
|
|
const fail = (status, code, message) => res.status(status).json({ error: { code, message } });
|
|
const session = stmts.getSession.get(req.params.id);
|
|
if (!session) return fail(404, "NOT_FOUND", "Session not found");
|
|
const s3 = require("../lib/linux-accounts").s3Enabled();
|
|
if ((isServerMode() && !s3) || String(session.source || "").startsWith("push:")) {
|
|
return fail(403, "EUNSUPPORTED", "not available for this session");
|
|
}
|
|
const file = typeof req.query.path === "string" ? req.query.path : "";
|
|
if (!path.isAbsolute(file)) return fail(400, "EBADPATH", "not an absolute path");
|
|
const { isSecretConfigPath } = require("../lib/cc-discovery");
|
|
const secret = (p) =>
|
|
isSecretConfigPath(p) || path.basename(p).toLowerCase() === "settings.local.json";
|
|
if (secret(file)) return fail(403, "ESECRET", "a credential file is never served");
|
|
const runAs = require("../lib/run-as");
|
|
const member = s3 ? runAs.ownerForRead(session.cwd) : null;
|
|
if (member && !require("../lib/hook-auth").inside(member.home, file)) {
|
|
return fail(404, "ENOTFOUND", "the session never touched that file");
|
|
}
|
|
if (!sessionTouchedFile.get(session.id, file, file)) {
|
|
return fail(404, "ENOTFOUND", "the session never touched that file");
|
|
}
|
|
let fh;
|
|
try {
|
|
// By what it RESOLVES to as well: a link named `notes.md` to a key file.
|
|
if (secret(await fs.promises.realpath(file))) {
|
|
return fail(403, "ESECRET", "a credential file is never served");
|
|
}
|
|
// NONBLOCK: a FIFO must not hang the event loop.
|
|
fh = await fs.promises.open(file, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0));
|
|
const st = await fh.stat();
|
|
const fileOwner = s3 ? runAs.ownerForRead(file) : null;
|
|
if (!st.isFile() || (fileOwner && st.uid !== fileOwner.uid)) {
|
|
return fail(404, "ENOTFOUND", "no such file");
|
|
}
|
|
const buf = Buffer.alloc(Math.min(st.size, READ_TEXT_MAX));
|
|
const { bytesRead } = await fh.read(buf, 0, buf.length, 0);
|
|
const head = buf.subarray(0, bytesRead);
|
|
if (head.subarray(0, 8000).includes(0)) return fail(415, "EBINARY", "not a text file");
|
|
res.set("Cache-Control", "no-store");
|
|
res.json({ text: head.toString("utf8"), truncated: st.size > READ_TEXT_MAX });
|
|
} catch {
|
|
if (!res.headersSent) fail(404, "ENOTFOUND", "no such file");
|
|
} finally {
|
|
await fh?.close();
|
|
}
|
|
});
|
|
|
|
/** The `/:id/stats` body — also frozen into a session snapshot. */
|
|
function sessionStats(sessionId) {
|
|
const totalEvents = stmts.sessionEventCount.get(sessionId)?.count ?? 0;
|
|
const eventsByType = stmts.sessionEventTypeCounts.all(sessionId);
|
|
const tools = stmts.sessionToolUsageCounts.all(sessionId);
|
|
// Exact totals: `tools_used` is a top-15 list, so its sum is not the count.
|
|
// A tool call is a PreToolUse; a session with none (imported) counts PostToolUse.
|
|
const toolTotals = stmts.sessionToolCallTotals.get(sessionId) || {};
|
|
const errors = stmts.sessionErrorCount.get(sessionId)?.count ?? 0;
|
|
const timeRange = stmts.sessionEventTimeRange.get(sessionId) || {};
|
|
const subagentTypes = stmts.sessionAgentTypeCounts.all(sessionId);
|
|
const agentStatusRows = stmts.sessionAgentStatusCounts.all(sessionId);
|
|
const tokens = stmts.sessionTokenTotals.get(sessionId) || {
|
|
input_tokens: 0,
|
|
output_tokens: 0,
|
|
cache_read_tokens: 0,
|
|
cache_write_tokens: 0,
|
|
};
|
|
|
|
// Aggregate agent counts by category
|
|
const agentCounts = {
|
|
total: 0,
|
|
main: 0,
|
|
subagent: 0,
|
|
compaction: 0,
|
|
by_status: {},
|
|
};
|
|
for (const row of agentStatusRows) {
|
|
agentCounts.total += row.count;
|
|
agentCounts.by_status[row.status] = row.count;
|
|
}
|
|
// Compactions: count agents whose subagent_type === 'compaction'
|
|
const compactionRow = subagentTypes.find((r) => r.subagent_type === "compaction");
|
|
agentCounts.compaction = compactionRow?.count ?? 0;
|
|
// Main vs sub: count by type in SQL (avoids loading all agents)
|
|
const typeCounts = db
|
|
.prepare(`SELECT type, COUNT(*) as count FROM agents WHERE session_id = ? GROUP BY type`)
|
|
.all(sessionId);
|
|
for (const row of typeCounts) {
|
|
if (row.type === "main") agentCounts.main = row.count;
|
|
else if (row.type === "subagent") agentCounts.subagent = row.count;
|
|
}
|
|
|
|
return {
|
|
session_id: sessionId,
|
|
total_events: totalEvents,
|
|
events_by_type: eventsByType,
|
|
tools_used: tools,
|
|
tool_call_count: toolTotals.pre > 0 ? toolTotals.pre : (toolTotals.post ?? 0),
|
|
distinct_tool_count: toolTotals.distinct_tools ?? 0,
|
|
error_count: errors,
|
|
first_event_at: timeRange.first_at ?? null,
|
|
last_event_at: timeRange.last_at ?? null,
|
|
agents: agentCounts,
|
|
subagent_types: subagentTypes.filter((r) => r.subagent_type !== "compaction"),
|
|
tokens,
|
|
};
|
|
}
|
|
|
|
router.post("/", (req, res) => {
|
|
// Server mode: sessions come from hooks and the dashboard's own panes; a
|
|
// member hand-making one would plant an unowned row every admin sees.
|
|
if (memberId(req) != null) {
|
|
return res
|
|
.status(403)
|
|
.json({ error: { code: "EADMIN", message: "a member cannot create a session directly" } });
|
|
}
|
|
const { id, name, cwd, model, metadata } = req.body;
|
|
if (!id) {
|
|
return res.status(400).json({ error: { code: "INVALID_INPUT", message: "id is required" } });
|
|
}
|
|
|
|
const existing = stmts.getSession.get(id);
|
|
if (existing) {
|
|
// A member may not read another's session by naming its id here.
|
|
if (!canSeeSession(req, id)) {
|
|
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Session not found" } });
|
|
}
|
|
return res.json({ session: existing, created: false });
|
|
}
|
|
|
|
stmts.insertSession.run(
|
|
id,
|
|
name || null,
|
|
"active",
|
|
cwd || null,
|
|
model || null,
|
|
metadata ? JSON.stringify(metadata) : null
|
|
);
|
|
const session = stmts.getSession.get(id);
|
|
broadcast("session_created", session);
|
|
res.status(201).json({ session, created: true });
|
|
});
|
|
|
|
router.patch("/:id", (req, res) => {
|
|
const { name, status, ended_at, metadata } = req.body;
|
|
const existing = stmts.getSession.get(req.params.id);
|
|
if (!existing) {
|
|
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Session not found" } });
|
|
}
|
|
if (Object.hasOwn(req.body, "archived")) {
|
|
if (typeof req.body.archived !== "boolean") {
|
|
return res
|
|
.status(400)
|
|
.json({ error: { code: "INVALID_INPUT", message: "archived must be a boolean" } });
|
|
}
|
|
db.prepare(
|
|
"UPDATE sessions SET archived_at = CASE WHEN ? THEN COALESCE(archived_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) END WHERE id = ?"
|
|
).run(req.body.archived ? 1 : 0, req.params.id);
|
|
}
|
|
|
|
stmts.updateSession.run(
|
|
name || null,
|
|
status || null,
|
|
ended_at || null,
|
|
metadata ? JSON.stringify(metadata) : null,
|
|
req.params.id
|
|
);
|
|
|
|
const session = stmts.getSession.get(req.params.id);
|
|
broadcast("session_updated", session);
|
|
res.json({ session });
|
|
});
|
|
|
|
// GET /:id/transcripts — List available transcript files for a session (main + sub-agents)
|
|
router.get("/:id/transcripts", async (req, res) => {
|
|
const session = stmts.getSession.get(req.params.id);
|
|
if (!session) {
|
|
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Session not found" } });
|
|
}
|
|
|
|
const result = [];
|
|
|
|
// Query database agent list for db_agent_id association
|
|
const dbAgents = stmts.listAgentsBySession.all(req.params.id) || [];
|
|
|
|
// Main session transcript (live, else the durable import-time snapshot)
|
|
const mainPath =
|
|
getTranscriptPath(req.params.id, session.cwd) ||
|
|
findTranscriptPath(req.params.id, session.cwd) ||
|
|
getSnapshotTranscriptPath(req.params.id);
|
|
if (mainPath && fs.existsSync(mainPath)) {
|
|
// Main agent database ID format: <sessionId>-main
|
|
const mainDbAgent = dbAgents.find((a) => a.type === "main");
|
|
result.push({
|
|
id: "main",
|
|
name: "Main Agent",
|
|
type: "main",
|
|
has_transcript: true,
|
|
db_agent_id: mainDbAgent ? mainDbAgent.id : null,
|
|
});
|
|
}
|
|
|
|
// Sub-agent transcript files
|
|
const encoded = session.cwd ? session.cwd.replace(/[^a-zA-Z0-9]/g, "-") : null;
|
|
const subagentDirs = [];
|
|
|
|
// Direct path
|
|
if (encoded) {
|
|
// A member's session (S3) wrote under their own home.
|
|
const directDir = path.join(projectsDirFor(session.cwd), encoded, req.params.id, "subagents");
|
|
if (fs.existsSync(directDir)) subagentDirs.push(directDir);
|
|
}
|
|
|
|
// Fallback: scan the owner's project directory when the direct path doesn't exist
|
|
if (subagentDirs.length === 0) {
|
|
for (const projectsDir of sessionProjectsDirs(session.cwd)) {
|
|
if (fs.existsSync(projectsDir)) {
|
|
try {
|
|
for (const d of fs.readdirSync(projectsDir, { withFileTypes: true })) {
|
|
if (!d.isDirectory()) continue;
|
|
const candidate = path.join(projectsDir, d.name, req.params.id, "subagents");
|
|
if (fs.existsSync(candidate)) subagentDirs.push(candidate);
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const dir of subagentDirs) {
|
|
try {
|
|
const files = fs.readdirSync(dir);
|
|
for (const file of files) {
|
|
if (!file.endsWith(".jsonl")) continue;
|
|
// File name format: agent-<shortId>.jsonl
|
|
const shortId = file.replace(/^agent-/, "").replace(/\.jsonl$/, "");
|
|
// Try reading meta.json for agent type info
|
|
let meta = null;
|
|
const metaPath = path.join(dir, file.replace(".jsonl", ".meta.json"));
|
|
if (fs.existsSync(metaPath)) {
|
|
try {
|
|
meta = JSON.parse(
|
|
require("../lib/run-as").readFileSafe(metaPath, "utf8", { max: 1 << 20 })
|
|
);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
const isCompact = shortId.startsWith("acompact-");
|
|
const transcriptName = isCompact
|
|
? "Context Compaction"
|
|
: meta?.description || meta?.agentType || shortId;
|
|
const transcriptSubagentType = meta?.agentType || null;
|
|
|
|
// Read first-line timestamp from JSONL for time-based matching
|
|
let transcriptTimestamp = null;
|
|
try {
|
|
const jsonlPath = path.join(dir, file);
|
|
const firstLine = await readFirstLine(jsonlPath);
|
|
if (firstLine) {
|
|
const entry = JSON.parse(firstLine);
|
|
transcriptTimestamp = entry.timestamp || null;
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
|
|
result.push({
|
|
id: shortId,
|
|
name: transcriptName,
|
|
type: isCompact ? "compaction" : "subagent",
|
|
subagent_type: transcriptSubagentType,
|
|
has_transcript: true,
|
|
db_agent_id: null, // matched later after all transcripts are collected
|
|
_timestamp: transcriptTimestamp,
|
|
});
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
// Match database agents to transcripts using best-effort strategies
|
|
// Strategy: sort both sides by time within each type, then match by index order.
|
|
// This works because agents and transcripts are created in chronological order.
|
|
|
|
// Step 1: Sort all non-main transcripts by timestamp
|
|
for (const t of result) {
|
|
if (t.type === "main") continue;
|
|
// Store parseable timestamp for sorting
|
|
t._sortTime = t._timestamp ? new Date(t._timestamp).getTime() : Infinity;
|
|
}
|
|
|
|
// Step 2: Sort DB agents by started_at within each subagent_type
|
|
const agentsByType = {};
|
|
for (const a of dbAgents) {
|
|
const key = a.subagent_type || a.type;
|
|
if (!agentsByType[key]) agentsByType[key] = [];
|
|
agentsByType[key].push(a);
|
|
}
|
|
for (const key of Object.keys(agentsByType)) {
|
|
agentsByType[key].sort((a, b) => (a.started_at || "").localeCompare(b.started_at || ""));
|
|
}
|
|
|
|
// Step 3: Sort transcripts by type+time, then match by index within each type group
|
|
// Group transcripts by their effective type key
|
|
const transcriptsByType = {};
|
|
for (const t of result) {
|
|
if (t.type === "main") continue;
|
|
// Compaction transcripts have subagent_type=null, use type as key
|
|
const key = t.subagent_type || t.type;
|
|
if (!transcriptsByType[key]) transcriptsByType[key] = [];
|
|
transcriptsByType[key].push(t);
|
|
}
|
|
// Sort each group by timestamp
|
|
for (const key of Object.keys(transcriptsByType)) {
|
|
transcriptsByType[key].sort((a, b) => (a._sortTime || Infinity) - (b._sortTime || Infinity));
|
|
}
|
|
|
|
// Step 4: Match by index within each type group
|
|
// First try db_agent_id exact match, then fall back to positional match
|
|
for (const key of Object.keys(transcriptsByType)) {
|
|
const tGroup = transcriptsByType[key];
|
|
const aGroup = agentsByType[key] || [];
|
|
const usedAgentIds = new Set();
|
|
|
|
for (let i = 0; i < tGroup.length; i++) {
|
|
const t = tGroup[i];
|
|
|
|
// Try exact db_agent_id match first (for non-compact sub-agents with meta.json data)
|
|
if (t.db_agent_id) {
|
|
usedAgentIds.add(t.db_agent_id);
|
|
continue;
|
|
}
|
|
|
|
// Positional match: i-th transcript → i-th agent in the same type group
|
|
if (i < aGroup.length && !usedAgentIds.has(aGroup[i].id)) {
|
|
t.db_agent_id = aGroup[i].id;
|
|
usedAgentIds.add(aGroup[i].id);
|
|
}
|
|
// If no agent at this position, db_agent_id stays null — client will show "info missing"
|
|
}
|
|
}
|
|
|
|
// Clean up internal fields before sending response
|
|
for (const t of result) {
|
|
delete t._timestamp;
|
|
delete t._sortTime;
|
|
}
|
|
|
|
// Sort transcripts: main first, then by time ascending (consistent with agents list order)
|
|
result.sort((a, b) => {
|
|
if (a.type === "main") return -1;
|
|
if (b.type === "main") return 1;
|
|
const aAgent = dbAgents.find((ag) => ag.id === a.db_agent_id);
|
|
const bAgent = dbAgents.find((ag) => ag.id === b.db_agent_id);
|
|
const aTime = aAgent?.started_at ? new Date(aAgent.started_at).getTime() : 0;
|
|
const bTime = bAgent?.started_at ? new Date(bAgent.started_at).getTime() : 0;
|
|
if (aTime && bTime) return aTime - bTime;
|
|
if (aTime) return -1;
|
|
if (bTime) return 1;
|
|
return (a.name || "").localeCompare(b.name || "");
|
|
});
|
|
|
|
res.json({ transcripts: result });
|
|
});
|
|
|
|
// GET /:id/transcript — Read session JSONL transcript, return structured message list
|
|
// Query params:
|
|
// agent_id: file-level short ID ("main" or "ad18a79192af10ed1", "acompact-xxx")
|
|
// run_id: Workflow run id ("wf_...") — disambiguates a workflow inner agent's
|
|
// nested transcript (subagents/workflows/<run_id>/agent-<agent_id>.jsonl)
|
|
// limit: max messages to return (default 50, max 200)
|
|
// after: JSONL line number, only return messages after this line (incremental mode)
|
|
// before: JSONL line number, only return messages before this line (history mode)
|
|
// offset: legacy pagination offset (compatible, mutually exclusive with after/before)
|
|
router.get("/:id/transcript", async (req, res) => {
|
|
const session = stmts.getSession.get(req.params.id);
|
|
if (!session) {
|
|
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Session not found" } });
|
|
}
|
|
|
|
const agentId = req.query.agent_id || null;
|
|
const runId = req.query.run_id || null;
|
|
// Both are joined into a file path below: one plain name each, never a path.
|
|
const SAFE_ID = /^[\w.-]{1,200}$/;
|
|
for (const v of [agentId, runId]) {
|
|
if (v !== null && (typeof v !== "string" || !SAFE_ID.test(v) || v.includes(".."))) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: { code: "EBADID", message: "invalid agent_id or run_id" } });
|
|
}
|
|
}
|
|
// Subagent transcripts (anything but the main session file) need different
|
|
// sender attribution: their first user line is the orchestrator's task.
|
|
const isSubagentFile = !!(agentId && agentId !== "main");
|
|
const limit = Math.min(parseInt(req.query.limit) || 50, 200);
|
|
const afterLine = req.query.after ? parseInt(req.query.after) : null;
|
|
const beforeLine = req.query.before ? parseInt(req.query.before) : null;
|
|
const offset = parseInt(req.query.offset) || 0;
|
|
|
|
// Determine the JSONL file path to read. Prefer the live file under
|
|
// ~/.claude/projects, then fall back to the dashboard's durable snapshot —
|
|
// 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;
|
|
// 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, session.cwd) ||
|
|
getSnapshotSubagentTranscriptPath(req.params.id, agentId, runId);
|
|
} else {
|
|
jsonlPath =
|
|
getTranscriptPath(req.params.id, session.cwd) ||
|
|
findTranscriptPath(req.params.id, session.cwd) ||
|
|
getSnapshotTranscriptPath(req.params.id);
|
|
}
|
|
|
|
if (!jsonlPath || !fs.existsSync(jsonlPath)) {
|
|
return res.json({ messages: [], total: 0, has_more: false, last_line: 0, first_line: 0 });
|
|
}
|
|
|
|
try {
|
|
// Stream-parse JSONL with early termination for efficiency.
|
|
// Instead of loading all messages into memory, we use pagination-aware
|
|
// strategies to stop reading as soon as we have enough data.
|
|
const messages = [];
|
|
let lineNum = 0;
|
|
let total = 0; // total valid messages seen (exact for early-terminated streams, indicates >= actual)
|
|
let hasMore = false;
|
|
|
|
// readLines, never readline: a member's one-line 512 MB file crashes readline.
|
|
const rl = require("../lib/run-as").readLines(jsonlPath);
|
|
|
|
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.
|
|
let lastRenameTitle = null;
|
|
|
|
// Helper: parse a JSONL line into a message object, or null if not a displayable message
|
|
function parseMessage(entry, num) {
|
|
// /rename, `claude -n`, picker Ctrl+R → custom-title metadata line. These
|
|
// commands produce no user/assistant turn, so without this they'd be
|
|
// invisible. Surface a compact "renamed" marker instead.
|
|
if (entry.type === "custom-title") {
|
|
const title = typeof entry.customTitle === "string" ? entry.customTitle.trim() : "";
|
|
if (!title || title === lastRenameTitle) return null;
|
|
lastRenameTitle = title;
|
|
return {
|
|
type: "session_event",
|
|
event_kind: "rename",
|
|
title,
|
|
timestamp: entry.timestamp || null,
|
|
content: [],
|
|
line: num,
|
|
};
|
|
}
|
|
|
|
// Local slash-command I/O. Newer Claude Code builds write the command
|
|
// invocation and its captured output as `system`/`local_command` lines
|
|
// with the TUI markup in a top-level `content` string (older builds used
|
|
// `user` messages, handled below). Surface those as user-side text so the
|
|
// client's tuiSegments parser renders the command pill + stdout/stderr
|
|
// (e.g. /color → "/color" pill + "Session color set to: cyan"). Skip
|
|
// every other system subtype (turn_duration, stop_hook_summary, …) and
|
|
// empty local_command lines (e.g. /clear writes a content-less one).
|
|
if (entry.type === "system") {
|
|
if (entry.subtype !== "local_command") return null;
|
|
const sysText = typeof entry.content === "string" ? entry.content : "";
|
|
if (!sysText.trim()) return null;
|
|
return {
|
|
type: "user",
|
|
sender: "user", // local slash-command I/O is the human's own action
|
|
timestamp: entry.timestamp || null,
|
|
content: [{ type: "text", text: truncate(sysText, TEXT_MAX) }],
|
|
line: num,
|
|
};
|
|
}
|
|
|
|
// Mid-turn queued message: journaled as a `queued_command` attachment
|
|
// (never as a `user` line). Surface it at the position the model actually
|
|
// received it. NOT everything in the queue is the human, though — the
|
|
// harness delivers its own injections (task-notifications from background
|
|
// agents, "[SYSTEM NOTIFICATION …]" banners) through the same queue, and
|
|
// those attachments carry NO `origin` field, while a genuinely typed
|
|
// message carries `origin.kind = "human"`. So: harness-marker text or a
|
|
// non-human origin → "system"; everything else → "user". Other attachment
|
|
// subtypes are harness noise → dropped.
|
|
if (entry.type === "attachment") {
|
|
const att = entry.attachment;
|
|
if (!att || att.type !== "queued_command") return null;
|
|
const prompt = typeof att.prompt === "string" ? att.prompt : "";
|
|
if (!prompt.trim()) return null;
|
|
const lead = prompt.replace(/^\s+/, "");
|
|
// Same harness markers classifyTranscriptSender strips off user lines.
|
|
const isHarnessText =
|
|
lead.startsWith("<task-notification") || lead.startsWith("[SYSTEM NOTIFICATION");
|
|
const kind = att.origin && typeof att.origin.kind === "string" ? att.origin.kind : null;
|
|
const isSystem = isHarnessText || (kind !== null && kind !== "human");
|
|
return {
|
|
type: "user",
|
|
sender: isSystem ? "system" : "user",
|
|
timestamp: entry.timestamp || att.timestamp || null,
|
|
content: [{ type: "text", text: truncate(prompt, TEXT_MAX) }],
|
|
line: num,
|
|
};
|
|
}
|
|
|
|
const msg = entry.type === "assistant" ? entry.message || {} : {};
|
|
const content = [];
|
|
|
|
if (entry.type === "user") {
|
|
const msgContent = entry.message?.content;
|
|
if (typeof msgContent === "string") {
|
|
content.push({ type: "text", text: truncate(msgContent, TEXT_MAX) });
|
|
} else if (Array.isArray(msgContent)) {
|
|
for (const block of msgContent) {
|
|
if (block.type === "text" && block.text) {
|
|
content.push({ type: "text", text: truncate(block.text, TEXT_MAX) });
|
|
} else if (block.type === "tool_result") {
|
|
// A line carries one tool result; an edit's +/- counts ride on it.
|
|
const diff = patchCounts(entry.toolUseResult);
|
|
const answers = askAnswers(entry.toolUseResult);
|
|
content.push({
|
|
type: "tool_result",
|
|
id: block.tool_use_id || null,
|
|
output: truncate(
|
|
typeof block.content === "string"
|
|
? block.content
|
|
: JSON.stringify(block.content || ""),
|
|
10240
|
|
),
|
|
is_error: !!block.is_error,
|
|
...(diff ? { diff } : {}),
|
|
...(answers ? { answers } : {}),
|
|
});
|
|
}
|
|
}
|
|
} else if (msgContent === undefined || msgContent === null) {
|
|
return null;
|
|
}
|
|
} else {
|
|
const msgContent = msg.content || [];
|
|
if (Array.isArray(msgContent)) {
|
|
for (const block of msgContent) {
|
|
if (block.type === "text" && block.text) {
|
|
content.push({ type: "text", text: truncate(block.text, TEXT_MAX) });
|
|
} else if (block.type === "thinking" && block.thinking) {
|
|
content.push({ type: "thinking", text: truncate(block.thinking, 10240) });
|
|
} else if (block.type === "tool_use") {
|
|
content.push({
|
|
type: "tool_use",
|
|
name: block.name || "unknown",
|
|
id: block.id || null,
|
|
input: truncateObj(block.input, 10240),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (content.length === 0) return null;
|
|
|
|
const message = {
|
|
type: entry.type,
|
|
sender: classifyTranscriptSender(entry, isSubagentFile),
|
|
timestamp: entry.timestamp
|
|
? typeof entry.timestamp === "number"
|
|
? new Date(entry.timestamp).toISOString()
|
|
: entry.timestamp
|
|
: null,
|
|
content,
|
|
line: num,
|
|
};
|
|
|
|
if (entry.type === "assistant") {
|
|
if (msg.model) message.model = msg.model;
|
|
// "end_turn" closes the turn; "tool_use" means more work follows.
|
|
if (typeof msg.stop_reason === "string") message.stop_reason = msg.stop_reason;
|
|
if (msg.usage) {
|
|
message.usage = {
|
|
input_tokens: msg.usage.input_tokens || 0,
|
|
output_tokens: msg.usage.output_tokens || 0,
|
|
cache_read_input_tokens: msg.usage.cache_read_input_tokens || 0,
|
|
cache_creation_input_tokens: msg.usage.cache_creation_input_tokens || 0,
|
|
};
|
|
}
|
|
}
|
|
|
|
return message;
|
|
}
|
|
|
|
if (afterLine !== null) {
|
|
// Incremental mode: skip lines until after afterLine, collect up to limit, then stop.
|
|
// A line the client already has is skipped BEFORE it is parsed: every open
|
|
// chat tails its transcript every few seconds, and parsing the whole JSONL
|
|
// (MBs for a long session) each time was most of the server's steady CPU.
|
|
for await (const line of rl) {
|
|
lineNum++;
|
|
if (lineNum <= afterLine) continue;
|
|
if (!line.trim()) continue;
|
|
let entry;
|
|
try {
|
|
entry = JSON.parse(line);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (!renders(entry)) continue;
|
|
|
|
const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum);
|
|
if (!message) continue;
|
|
total++;
|
|
messages.push(message);
|
|
if (messages.length >= limit) {
|
|
// Check if there's at least one more valid message
|
|
hasMore = true;
|
|
break;
|
|
}
|
|
}
|
|
// If we exhausted the stream without hitting limit, hasMore stays false
|
|
} else if (beforeLine !== null) {
|
|
// History mode: collect messages with line < beforeLine using a sliding window.
|
|
// hasMore here means "more *older* messages exist before what we're returning"
|
|
// — the only way to know that is if we shifted any out of the window
|
|
// (total > limit). Hitting the boundary tells us nothing about older history.
|
|
for await (const line of rl) {
|
|
lineNum++;
|
|
if (!line.trim()) continue;
|
|
let entry;
|
|
try {
|
|
entry = JSON.parse(line);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (!renders(entry)) continue;
|
|
if (lineNum >= beforeLine) {
|
|
// Reached the boundary — stop reading
|
|
break;
|
|
}
|
|
|
|
const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum);
|
|
if (!message) continue;
|
|
total++;
|
|
messages.push(message);
|
|
// Sliding window: only keep the last `limit` messages
|
|
if (messages.length > limit) {
|
|
messages.shift();
|
|
}
|
|
}
|
|
if (total > limit) hasMore = true;
|
|
} else if (offset > 0) {
|
|
// Legacy offset pagination: skip `offset` valid messages, then collect `limit`
|
|
let skipped = 0;
|
|
for await (const line of rl) {
|
|
lineNum++;
|
|
if (!line.trim()) continue;
|
|
let entry;
|
|
try {
|
|
entry = JSON.parse(line);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (!renders(entry)) continue;
|
|
|
|
const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum);
|
|
if (!message) continue;
|
|
total++;
|
|
|
|
if (skipped < offset) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
messages.push(message);
|
|
if (messages.length >= limit) {
|
|
hasMore = true; // assume more exist
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
// Default: return the latest N messages (chat-flow mode) using a sliding window
|
|
for await (const line of rl) {
|
|
lineNum++;
|
|
if (!line.trim()) continue;
|
|
let entry;
|
|
try {
|
|
entry = JSON.parse(line);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (!renders(entry)) continue;
|
|
|
|
const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum);
|
|
if (!message) continue;
|
|
total++;
|
|
messages.push(message);
|
|
// Sliding window: only keep the last `limit` messages in memory
|
|
if (messages.length > limit) {
|
|
messages.shift();
|
|
}
|
|
}
|
|
// If we shifted any messages out, there are more
|
|
hasMore = total > limit;
|
|
}
|
|
|
|
const lastLine = messages.length > 0 ? messages[messages.length - 1].line : 0;
|
|
const firstLine = messages.length > 0 ? messages[0].line : 0;
|
|
|
|
// Remove internal line field from messages
|
|
for (const m of messages) {
|
|
delete m.line;
|
|
}
|
|
|
|
res.json({
|
|
messages,
|
|
total,
|
|
has_more: hasMore,
|
|
last_line: lastLine,
|
|
first_line: firstLine,
|
|
});
|
|
} catch (err) {
|
|
res.json({ messages: [], total: 0, has_more: false, last_line: 0, first_line: 0 });
|
|
}
|
|
});
|
|
|
|
// 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, TEXT_MAX) }));
|
|
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, TEXT_MAX) }])
|
|
: null;
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** The +/- line counts of a Write/Edit/MultiEdit result (`toolUseResult`):
|
|
* its `structuredPatch` hunks' `+`/`-` lines, or every line of a file it
|
|
* created. Null for any other result. */
|
|
function patchCounts(r) {
|
|
if (!r || typeof r !== "object") return null;
|
|
if (Array.isArray(r.structuredPatch) && r.structuredPatch.length) {
|
|
let added = 0;
|
|
let removed = 0;
|
|
for (const h of r.structuredPatch) {
|
|
for (const l of Array.isArray(h?.lines) ? h.lines : []) {
|
|
if (typeof l !== "string") continue;
|
|
if (l.startsWith("+")) added++;
|
|
else if (l.startsWith("-")) removed++;
|
|
}
|
|
}
|
|
return { added, removed };
|
|
}
|
|
if (r.type === "create" && typeof r.content === "string") {
|
|
return { added: r.content.split("\n").length - (r.content.endsWith("\n") ? 1 : 0), removed: 0 };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** An AskUserQuestion result's answers (`toolUseResult.answers`): question
|
|
* text → the option picked, or what the user typed. Null for any other result. */
|
|
function askAnswers(r) {
|
|
if (!r || typeof r !== "object" || !r.answers || typeof r.answers !== "object") return null;
|
|
if (Array.isArray(r.answers)) return null;
|
|
const out = {};
|
|
for (const [q, a] of Object.entries(r.answers)) if (typeof a === "string") out[q] = a;
|
|
return Object.keys(out).length ? out : null;
|
|
}
|
|
|
|
// A conversation turn — a prompt, an answer — is shown whole: it is bounded by
|
|
// the model's output and what the user typed. Only tool payloads and thinking
|
|
// (the parts that can run to megabytes) keep the 10 KB cap. This one guards
|
|
// against a pathological line, not a long answer.
|
|
const TEXT_MAX = 1024 * 1024;
|
|
|
|
function truncate(str, maxLen) {
|
|
if (!str || str.length <= maxLen) return str;
|
|
return str.slice(0, maxLen) + "[truncated]";
|
|
}
|
|
|
|
function truncateObj(obj, maxLen) {
|
|
if (!obj) return obj;
|
|
const json = JSON.stringify(obj);
|
|
if (json.length <= maxLen) return obj;
|
|
return { _truncated: truncate(json, maxLen) };
|
|
}
|
|
|
|
module.exports = router;
|
|
module.exports.sessionStats = sessionStats;
|
|
// Exported for unit tests — sender attribution is correctness-critical.
|
|
module.exports.classifyTranscriptSender = classifyTranscriptSender;
|
|
module.exports.patchCounts = patchCounts;
|
|
module.exports.askAnswers = askAnswers;
|