feat(chat): terminal chat parity for every tool; Desktop-like artifacts

- The chat over a terminal pane now covers Claude, Codex, omp, Cursor and
  Antigravity: Cursor's agent transcript and agy's step log
  (brain/<id>/.system_generated/logs/transcript_full.jsonl) are read as
  conversations; Cursor sessions are claimed by their workspace
- Replies appear as soon as they are written: the projects-tree watcher
  broadcasts `transcript_changed` (at most every 250 ms per session) and the
  chat re-reads the transcript while the terminal underneath draws
- Stop shows from the pane's own busy line (Claude/Codex `esc to interrupt`,
  agy `… esc to cancel`), or fresh output for omp/Cursor — no hooks needed
- Permission prompts read off the terminal become a card with the tool, its
  command and "Deny with a note"; Codex gets Permission mode / Model buttons
  that open its own numbered pickers, answered from the menu card
- Artifacts: cards for pages/documents/diagrams/tables/React components, a
  Preview | Code panel with versions, copy, download, save to the Artifacts
  Hub, full screen; sandboxed iframes without allow-same-origin
- Claude panes get CLAUDE_CODE_ENABLE_TODO_TOOLS=1 so the Progress panel has a
  plan; multi-question pane menus read their Next row and question tabs
- The realtime chat bridge (structured stdio, /api/chat-bridge, /ws-chat)
  from the unreleased ad9d475 is not kept
This commit is contained in:
2026-10-21 15:44:17 +07:00
parent aee4fc190e
commit 383b9ae231
41 changed files with 2582 additions and 426 deletions
+10 -3
View File
@@ -632,12 +632,19 @@ An `antigravity` workspace carries no pipeline either.
per-conversation SQLite files (`~/.gemini/antigravity-cli/conversations/<id>.db`)
every `DASHBOARD_ANTIGRAVITY_POLL_MS` ms (default 15000, `0` disables). They
become `sessions` rows (`tool = 'antigravity'`) and `token_usage` rows, and
`antigravity_conversations` holds the mtime read for each file.
`antigravity_conversations` holds the mtime read for each file. The
session's conversation (`GET /api/sessions/:id/transcript`) is read from its
step log (`brain/<id>/.system_generated/logs/transcript_full.jsonl`,
`transcriptPath`): prompts, the model's text, thinking, tool calls and
tokens, and each tool's result tied to its call by step.
- **Cursor sessions:** `server/lib/cursor-usage.js` reads Cursor's agent
transcripts (`~/.cursor/projects/*/agent-transcripts/<id>/<id>.jsonl`) every
`DASHBOARD_CURSOR_POLL_MS` ms (default 15000, `0` disables) into `sessions`
rows (`tool = 'cursor'`) — no `token_usage`, the transcripts carry none —
and `cursor_transcripts` holds the mtime read for each file.
and `cursor_transcripts` holds the mtime read for each file. The session's
conversation (`GET /api/sessions/:id/transcript`) is read from that recorded
file: prompts, replies, thinking, tool calls and results, with no timestamps
or model (Cursor writes none).
- **Oh My Pi sessions:** `server/lib/omp-usage.js` reads omp's session journals
(`~/.omp/agent/sessions/<encoded cwd>/<timestamp>_<id>.jsonl`, plus every
subagent journal under `<timestamp>_<id>/`) every `DASHBOARD_OMP_POLL_MS` ms
@@ -803,7 +810,7 @@ HTTP surface for the tmux-backed terminal run: one session per workspace, named
Codex-only spawn options (`sandbox`, `approval`, `resumeLast`) are accepted by the workspace action `POST /api/lanes/:id/start`, not by `POST /api/run` — see [`docs/API.md`](../docs/API.md).
The pane itself is not JSON: its bytes stream over `/ws-pty/:runId` as binary WebSocket frames. The JSON WebSocket surface adds `cc_config_changed` (broadcast by `lib/cc-watcher.js` on `fs.watch` events under `~/.claude/` and by `routes/cc-config.js` after every successful PUT/DELETE — debounced at 500 ms, payload `{ source: "dashboard"|"fs", action?, scope?, type?, name?, paths? }`) and its Codex counterpart `codex_config_changed` (broadcast by `routes/codex-config.js` after every successful file mutation and CLI-backed mcp/plugin/marketplace action — always `source: "dashboard"`, there is no `$CODEX_HOME` filesystem watcher).
The pane itself is not JSON: its bytes stream over `/ws-pty/:runId` as binary WebSocket frames. The JSON WebSocket surface adds `cc_config_changed` (broadcast by `lib/cc-watcher.js` on `fs.watch` events under `~/.claude/` and by `routes/cc-config.js` after every successful PUT/DELETE — debounced at 500 ms, payload `{ source: "dashboard"|"fs", action?, scope?, type?, name?, paths? }`) and its Codex counterpart `codex_config_changed` (broadcast by `routes/codex-config.js` after every successful file mutation and CLI-backed mcp/plugin/marketplace action — always `source: "dashboard"`, there is no `$CODEX_HOME` filesystem watcher), and `transcript_changed` `{ session_id }` — the session sync's `fs.watch` on the projects tree saw a Claude transcript grow (`pushTranscript` in `server/index.js`, at most every 250 ms per session; nothing is parsed for it), so a chat showing that session reads its new lines at once instead of at its next 3 s poll. It reaches whoever the session's own `new_event` reaches (`broadcastAudience`).
### Import History
@@ -0,0 +1,146 @@
/**
* @file antigravity-transcript.test.js
* @description An Antigravity (agy) session's conversation view: its
* `transcript_full.jsonl` step log mapped to the Claude transcript shape —
* the prompt out of `<USER_REQUEST>`, the model's thinking, text, tool calls
* and tokens, each tool result tied to its call by step and an invalid call
* marked an error — one "main" transcript, no subagent files, and a session
* id that is no UUID finding nothing. The agy dir and the DB are temp dirs;
* the steps are synthetic, in the shape agy 1.4 writes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const fs = require("node:fs");
const path = require("node:path");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-agy-transcript-"));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
process.env.AGENTHUB_GEMINI_DIR = path.join(ROOT, "gemini");
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
const { describe, it, after } = require("node:test");
const assert = require("node:assert/strict");
const { db } = require("../db");
const agy = require("../lib/antigravity-usage");
let server;
after(() => {
server?.close();
fs.rmSync(ROOT, { recursive: true, force: true });
});
const ID = "57154e1a-65e5-4b51-a116-1890f5ca831f";
const AT = "2026-10-21T05:08:24Z";
function steps(id) {
const dir = path.join(
process.env.AGENTHUB_GEMINI_DIR,
"antigravity-cli",
"brain",
id,
".system_generated",
"logs"
);
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, "transcript_full.jsonl");
const lines = [
{
step_index: 0,
source: "USER_EXPLICIT",
type: "USER_INPUT",
status: "DONE",
created_at: AT,
content:
"<USER_REQUEST>\nCreate c.txt\n</USER_REQUEST>\n<ADDITIONAL_METADATA>\nThe current local time is: x.\n</ADDITIONAL_METADATA>",
},
{
step_index: 1,
source: "MODEL",
type: "PLANNER_RESPONSE",
status: "DONE",
created_at: AT,
input_tokens: 3889,
cache_read_tokens: 768,
output_tokens: 12,
thinking: "Two commands.",
content: "Running them now.",
tool_calls: [
{ name: "run_command", args: { CommandLine: "echo ok > c.txt" } },
{ name: "read", args: { path: "c.txt" } },
],
},
{
step_index: 2,
source: "MODEL",
type: "GENERIC",
status: "DONE",
created_at: AT,
content: "Created At: x\nCompleted At: y\n\nThe command exited with code 0.\n",
},
{
step_index: 3,
source: "MODEL",
type: "GENERIC",
status: "INVALID",
error: 'unknown tool: "read"',
created_at: AT,
content: 'Created At: x\nCompleted At: y\nError invalid tool call: unknown tool: "read"',
},
{
step_index: 4,
source: "MODEL",
type: "PLANNER_RESPONSE",
status: "DONE",
created_at: AT,
content: "Done.",
},
];
fs.writeFileSync(file, lines.map((l) => JSON.stringify(l)).join("\n") + "\n");
return file;
}
describe("an Antigravity session's conversation", () => {
it("reads agy's step log in the Claude transcript shape", async () => {
const file = steps(ID);
assert.equal(agy.transcriptPath(ID), file);
assert.equal(agy.transcriptPath("../../etc"), null);
db.prepare(
"INSERT INTO sessions (id, name, status, started_at, tool) VALUES (?, 'c', 'completed', ?, 'antigravity')"
).run(ID, AT);
const { createApp, startServer } = require("../index");
server ||= await startServer(createApp(), 0);
const BASE = `http://127.0.0.1:${server.address().port}`;
const list = await (await fetch(`${BASE}/api/sessions/${ID}/transcripts`)).json();
assert.deepEqual(
list.transcripts.map((t) => [t.id, t.type]),
[["main", "main"]]
);
const body = await (await fetch(`${BASE}/api/sessions/${ID}/transcript`)).json();
assert.deepEqual(
body.messages.map((m) => [m.sender, m.content.map((c) => c.type)]),
[
["user", ["text"]],
["assistant", ["thinking", "text", "tool_use", "tool_use"]],
["tool", ["tool_result"]],
["tool", ["tool_result"]],
["assistant", ["text"]],
]
);
const [user, plan, ok, bad] = body.messages;
assert.equal(user.content[0].text, "Create c.txt");
assert.equal(user.timestamp, AT);
// Each result names the call it answers: call i of step s lands at step s+1+i.
assert.deepEqual(
plan.content.filter((c) => c.type === "tool_use").map((c) => c.id),
[ok.content[0].id, bad.content[0].id]
);
assert.equal(ok.content[0].output, "The command exited with code 0.\n");
assert.equal(ok.content[0].is_error, false);
assert.equal(bad.content[0].is_error, true);
assert.equal(plan.usage.input_tokens, 3889);
assert.equal(plan.usage.cache_read_input_tokens, 768);
const sub = await (await fetch(`${BASE}/api/sessions/${ID}/transcript?agent_id=x`)).json();
assert.equal(sub.messages.length, 0);
});
});
+72 -4
View File
@@ -4,8 +4,10 @@
* project slug walked back onto the filesystem) and no tokens, an unchanged
* file never re-read, a quiet one settled to completed, a session the user
* deleted staying deleted, and a non-cursor row with the same id never
* touched. The Cursor dir and the DB are temp dirs; the transcripts are
* synthetic, in the shape Cursor writes.
* touched, a session claimed by the Cursor pane it started in, and the
* transcript mapped to the Claude transcript shape by the
* transcript routes. The Cursor dir and the DB are temp dirs; the transcripts
* are synthetic, in the shape Cursor writes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -16,13 +18,18 @@ const path = require("node:path");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-cursor-usage-"));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
process.env.AGENTHUB_CURSOR_DIR = path.join(ROOT, "cursor");
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
const { describe, it, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const { db } = require("../db");
const cursor = require("../lib/cursor-usage");
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
let server;
after(() => {
server?.close();
fs.rmSync(ROOT, { recursive: true, force: true });
});
const ID = "7481e37e-9157-4145-b985-2ea9a2117f52";
const PROJECT = path.join(fs.realpathSync(ROOT), "work", "my app");
@@ -65,7 +72,8 @@ const session = (id) => db.prepare("SELECT * FROM sessions WHERE id = ?").get(id
beforeEach(() => {
fs.rmSync(process.env.AGENTHUB_CURSOR_DIR, { recursive: true, force: true });
db.exec(
"DELETE FROM token_usage; DELETE FROM agents; DELETE FROM sessions; DELETE FROM cursor_transcripts;"
"DELETE FROM token_usage; DELETE FROM agents; DELETE FROM sessions; DELETE FROM cursor_transcripts;" +
" DELETE FROM dashboard_runs; DELETE FROM lanes;"
);
});
@@ -175,4 +183,64 @@ describe("cursor usage", () => {
assert.equal(session(ID).name, `Cursor ${ID.slice(0, 8)}`);
assert.equal(session(ID).cwd, null);
});
it("maps the transcript to the transcript shape", async () => {
const file = transcript(ID);
const more = [
{
role: "assistant",
message: {
content: [
{ type: "text", text: "Reading it now." },
{ type: "tool_use", id: "t1", name: "Grep", input: { pattern: "x" } },
],
},
},
{
role: "user",
message: { content: [{ type: "tool_result", tool_use_id: "t1", content: "no match" }] },
},
{ role: "assistant", message: { content: [{ type: "text", text: "Done." }] } },
];
fs.appendFileSync(file, more.map((l) => JSON.stringify(l)).join("\n") + "\n");
await cursor.ingestCursorUsage();
assert.equal(cursor.transcriptPath(ID), file);
const { createApp, startServer } = require("../index");
server ||= await startServer(createApp(), 0);
const BASE = `http://127.0.0.1:${server.address().port}`;
const list = await (await fetch(`${BASE}/api/sessions/${ID}/transcripts`)).json();
assert.deepEqual(
list.transcripts.map((t) => [t.id, t.type]),
[["main", "main"]]
);
const body = await (await fetch(`${BASE}/api/sessions/${ID}/transcript`)).json();
assert.deepEqual(
body.messages.map((m) => [m.sender, m.content.map((c) => c.type)]),
[
["user", ["text"]],
["assistant", ["tool_use"]],
["assistant", ["text", "tool_use"]],
["tool", ["tool_result"]],
["assistant", ["text"]],
]
);
assert.equal(body.messages[0].content[0].text, "Review the campaign code\nplease");
assert.equal(body.messages[3].content[0].output, "no match");
const sub = await (await fetch(`${BASE}/api/sessions/${ID}/transcript?agent_id=x`)).json();
assert.equal(sub.messages.length, 0);
});
it("gives the session to the Cursor pane it started in", async () => {
db.prepare("INSERT INTO lanes (id, title, cwd) VALUES (5, 'w', ?)").run(PROJECT);
db.prepare(
`INSERT INTO dashboard_runs (id, lane_id, cwd, status, started_at, tool)
VALUES ('agenthub-lane-5', 5, ?, 'running', ?, 'cursor')`
).run(PROJECT, new Date(Date.now() - 60_000).toISOString());
transcript(ID);
await cursor.ingestCursorUsage();
assert.equal(session(ID).lane_id, 5);
const run = db.prepare("SELECT session_id FROM dashboard_runs WHERE id = 'agenthub-lane-5'");
assert.equal(run.get().session_id, ID);
});
});
+3
View File
@@ -205,6 +205,9 @@ describe("lane-sources instructions and launch", () => {
assert.equal(argv[5], "--permission-mode");
assert.ok(!argv.some((a) => a.startsWith("--append-system-prompt")));
assert.ok(newSession.includes("CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1"));
// Claude Code hides its task tools from newer models without this, and the
// chat Progress panel is drawn from those calls.
assert.ok(newSession.includes("CLAUDE_CODE_ENABLE_TODO_TOOLS=1"));
assert.deepEqual(fs.readdirSync(dir), ["CLAUDE.md"]);
const text = fs.readFileSync(path.join(dir, "CLAUDE.md"), "utf8");
assert.match(text, /^- always answer in Vietnamese/);
+3
View File
@@ -227,6 +227,9 @@ describe("broadcastAudience", () => {
assert.equal(reaches("agent_updated", { session_id: "s3" }, m1), false);
assert.equal(reaches("new_event", { session_id: "s2" }, admin.user), true);
assert.equal(reaches("new_event", { session_id: "s2" }, undefined), true);
// A transcript's growth notice goes where the session's events go.
assert.equal(reaches("transcript_changed", { session_id: "s2" }, m2), true);
assert.equal(reaches("transcript_changed", { session_id: "s2" }, m1), false);
});
it("routes workspace messages by owner and keeps unknown types from members", () => {
+9
View File
@@ -1827,6 +1827,15 @@ try {
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN pane_token_hash TEXT").run();
}
// A realtime chat run's bridge secret (`AGENTHUB_CHAT_TOKEN`), SHA-256: only the
// pane's own bridge may stream that run's events (server/routes/chat.js).
// Never listed.
try {
db.prepare("SELECT chat_token_hash FROM dashboard_runs LIMIT 1").get();
} catch {
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN chat_token_hash TEXT").run();
}
// Server mode: whose browser a push subscription is (`users.id`); NULL in
// local mode. A user's notifications go only to their own subscriptions.
try {
+33 -5
View File
@@ -1032,7 +1032,9 @@ function startWorkflowPoll(broadcast) {
* sessions show up immediately rather than on the next poll. Events for
* files already in `mtimeCache` (active transcripts being appended to) are
* ignored, so a busy session never thrashes the importer — the poll picks
* up its growth. Recursive watching is used only on macOS/Windows (native,
* up its growth; such an append only broadcasts `transcript_changed`
* `{session_id}` (at most every 250 ms per session), so an open chat reads
* the new lines at once. Recursive watching is used only on macOS/Windows (native,
* stable); on Linux, where Node's userland recursive watcher trips on the
* high-churn projects tree (see lib/cc-watcher.js), we watch the root plus
* each immediate child folder non-recursively instead.
@@ -1148,11 +1150,37 @@ function startSessionSync(broadcast) {
}, DEBOUNCE_MS);
if (debounce.unref) debounce.unref();
}
// Only a path we don't already track is interesting (a new session file or a
// new project folder). Appends to a known active transcript are left to the
// poll, so the watcher never re-parses a busy session every write.
// An append to a session's own transcript: a chat showing that session reads
// it now instead of at its next poll. One `transcript_changed` per session
// per PUSH_MS — a notice only, nothing is parsed here.
const PUSH_MS = 250;
const pushing = new Map(); // session id → pending timer
function pushTranscript(fullPath) {
const m =
/(?:^|[\\/])([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i.exec(
fullPath
);
if (!m || pushing.has(m[1])) return;
const sessionId = m[1];
const timer = setTimeout(() => {
pushing.delete(sessionId);
try {
broadcast("transcript_changed", { session_id: sessionId });
} catch {
/* best-effort — the chat's poll still catches up */
}
}, PUSH_MS);
if (timer.unref) timer.unref();
pushing.set(sessionId, timer);
}
// Only a path we don't already track is interesting to the importer (a new
// session file or a new project folder). Appends to a known active
// transcript are left to the poll, so the watcher never re-parses a busy
// session every write — it only tells the chats (`pushTranscript`).
function onFsEvent(fullPath) {
if (fullPath && mtimeCache.has(fullPath)) return;
if (fullPath && mtimeCache.has(fullPath)) return void pushTranscript(fullPath);
if (fullPath) pushTranscript(fullPath);
scheduleSweep();
}
+18 -1
View File
@@ -17,7 +17,8 @@
* 5 cache read.
* The session's name and start come from its transcript
* (`brain/<id>/.system_generated/logs/transcript_full.jsonl`, first
* `USER_INPUT`). A file is re-read whole when its mtime (or its non-empty
* `USER_INPUT`), and the same file is the conversation view's
* (`transcriptPath`, read by `GET /api/sessions/:id/transcript`). A file is re-read whole when its mtime (or its non-empty
* `-wal`'s) changes — they are small — and the mtime per file lives in
* `antigravity_conversations`, so a session the user deleted stays deleted
* until agy writes to it again. Opening read-only still lets SQLite leave an
@@ -157,6 +158,21 @@ const transcriptFiles = (id) =>
return ["transcript_full.jsonl", "transcript.jsonl"].map((name) => path.join(dir, name));
});
/** The transcript of conversation `id` (this account's or a member's) the
* conversation view reads, or null. Only a UUID names one. */
function transcriptPath(id) {
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(id || "")))
return null;
for (const file of transcriptFiles(id)) {
try {
if (fs.statSync(file).isFile()) return file;
} catch {
/* try the next name */
}
}
return null;
}
/** The transcript's last write, 0 when it has none. */
function transcriptMtime(id) {
for (const file of transcriptFiles(id)) {
@@ -531,4 +547,5 @@ module.exports = {
readConversation,
ingestAntigravityUsage,
startAntigravityUsagePoll,
transcriptPath,
};
+16 -1
View File
@@ -18,7 +18,11 @@
* is resolved only when exactly such a path still exists, else left empty.
* A file is re-read when its mtime changes, and the mtime per file lives in
* `cursor_transcripts`, so a session the user deleted stays deleted until
* Cursor writes to it again. No transcript is ever stored as `transcript_path`.
* Cursor writes to it again. A session a dashboard pane started is claimed
* for that pane's workspace and run (`claimToolSessions`), which gives the
* pane its session id and so its Chat view. No transcript is ever stored as
* `transcript_path`; the conversation view finds the file through
* `transcriptPath` instead.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -26,6 +30,7 @@ const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { db, stmts } = require("../db");
const { claimToolSessions } = require("./dashboard-runs");
const ACTIVE_MS = 120_000; // the window codex-usage.js calls a session active
const FUTURE_SLACK_MS = 1000;
@@ -148,6 +153,14 @@ function transcriptFilesIn(cursorDir) {
}
const getSeen = db.prepare("SELECT * FROM cursor_transcripts WHERE path = ?");
const getPathOf = db.prepare(
"SELECT path FROM cursor_transcripts WHERE session_id = ? ORDER BY mtime_ms DESC LIMIT 1"
);
/** The transcript a Cursor session was read from (recorded by the poller), or null. */
function transcriptPath(sessionId) {
return getPathOf.get(sessionId)?.path || null;
}
const upsertSeen = db.prepare(
`INSERT INTO cursor_transcripts (path, session_id, mtime_ms, updated_at)
VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
@@ -235,6 +248,7 @@ function ingestCursorUsage({ broadcast = () => {}, now = Date.now() } = {}) {
upsertSeen.run(f.path, f.id, f.mtime);
if (type) {
written += 1;
claimToolSessions(f.id); // its workspace and live pane, before the broadcast
broadcast(type, stmts.getSession.get(f.id));
}
} catch (err) {
@@ -273,4 +287,5 @@ module.exports = {
firstPrompt,
ingestCursorUsage,
startCursorUsagePoll,
transcriptPath,
};
+4 -2
View File
@@ -90,7 +90,7 @@ function bindable(v) {
*/
/** A run row as it may leave this machine: never a pane credential's hash. */
function exportableRun(row) {
const { pane_token_hash: _secret, ...rest } = row;
const { pane_token_hash: _secret, chat_token_hash: _chat, ...rest } = row;
return rest;
}
@@ -185,7 +185,9 @@ function importExportBundle(db, bundle) {
events: makeInserter(db, "events", { omit: ["id"], wrap: { data: "trim_event_data" } }),
token_usage: makeInserter(db, "token_usage"),
workflows: makeInserter(db, "workflows"),
dashboard_runs: makeInserter(db, "dashboard_runs", { omit: ["pane_token_hash"] }),
dashboard_runs: makeInserter(db, "dashboard_runs", {
omit: ["pane_token_hash", "chat_token_hash"],
}),
alert_rules: makeInserter(db, "alert_rules"),
model_pricing: makeInserter(db, "model_pricing"),
};
+5
View File
@@ -640,6 +640,11 @@ function spawnRun(args) {
...(entry.launcher === "claude" && systemPromptFile
? { CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: "1" }
: {}),
// Claude Code hides TaskCreate/TaskUpdate from its newer models unless
// this is set, and those calls are all the chat Progress panel draws
// (routes/sessions.js sessionOutline). A user's settings.json `env` still
// wins, so `"0"` there turns them back off.
...(entry.launcher === "claude" ? { CLAUDE_CODE_ENABLE_TODO_TOOLS: "1" } : {}),
// No CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: an inline claude duplicated its
// output whenever the pane narrowed (tmux re-wraps the full-width rules and
// pushes lines into history its redraw cannot clear). The user's own
+1
View File
@@ -288,6 +288,7 @@ function broadcastAudience(type, data) {
case "agent_updated":
case "new_event":
case "workflow_upserted":
case "transcript_changed":
return { owners: sessionOwners(d.session_id) };
case "alert_triggered":
case "alert_updated":
+324
View File
@@ -0,0 +1,324 @@
/**
* @file Turns one Claude Code transcript line (or a stream-json event shaped
* like one) into the conversation message the chat renders: sender, text,
* thinking, tool calls and results (with an edit's +/- counts, an
* AskUserQuestion's answers, a subagent's facts), model, usage. Shared by the
* transcript route and the realtime chat runtime so both draw the same thing.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/**
* 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";
}
/** 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;
}
/** An Agent/Task result's facts (`toolUseResult`): the subagent's id (its
* transcript is `agent-<id>.jsonl`), the model it ran on and, once it
* finished in the foreground, its time, tokens and tool calls. Null for any
* other result. */
function agentInfo(r) {
if (!r || typeof r !== "object" || typeof r.agentId !== "string") return null;
if (!/^[\w-]{1,200}$/.test(r.agentId)) return null;
const num = (v) => (Number.isFinite(v) ? v : null);
return {
id: r.agentId,
model: typeof r.resolvedModel === "string" ? r.resolvedModel : null,
status: typeof r.status === "string" ? r.status : null,
duration_ms: num(r.totalDurationMs),
tokens: num(r.totalTokens),
tool_uses: num(r.totalToolUseCount),
};
}
// 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;
// The tools whose input carries a file's text (artifactVersions on the client).
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit"]);
const EDIT_INPUT_MAX = 256 * 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) };
}
/** A parser for one transcript: it remembers the last rename it surfaced, so
* a repeated `custom-title` line shows once. `isSubagentFile` tells a
* subagent's task (orchestrator) from a human prompt. */
function createClaudeLineParser(isSubagentFile = false) {
// 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);
const agent = agentInfo(entry.toolUseResult);
// A subagent's report is a reply, read as prose: its text blocks
// whole, not a 10 KB slice of their JSON.
const report =
agent && Array.isArray(block.content)
? block.content
.filter((c) => c?.type === "text" && typeof c.text === "string")
.map((c) => c.text)
.join("\n\n")
: null;
content.push({
type: "tool_result",
id: block.tool_use_id || null,
output:
report !== null
? truncate(report, TEXT_MAX)
: truncate(
typeof block.content === "string"
? block.content
: JSON.stringify(block.content || ""),
10240
),
is_error: !!block.is_error,
...(diff ? { diff } : {}),
...(answers ? { answers } : {}),
...(agent ? { agent } : {}),
});
}
}
} 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,
// A file a Write/Edit made is an artifact the chat can show in
// versions, rebuilt from these inputs: they keep up to 256 KB.
input: truncateObj(block.input, EDIT_TOOLS.has(block.name) ? EDIT_INPUT_MAX : 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;
}
return parseMessage;
}
module.exports = {
classifyTranscriptSender,
patchCounts,
askAnswers,
agentInfo,
truncate,
truncateObj,
TEXT_MAX,
createClaudeLineParser,
};
+178 -295
View File
@@ -22,6 +22,16 @@ const {
findTranscriptPath,
findSubagentTranscriptPath,
} = require("../lib/claude-home");
const {
classifyTranscriptSender,
patchCounts,
askAnswers,
agentInfo,
truncate,
truncateObj,
TEXT_MAX,
createClaudeLineParser,
} = require("../lib/transcript-message");
const router = Router();
@@ -52,59 +62,6 @@ const TRANSCRIPT_RENDER_TYPES = new Set([
"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.
@@ -866,6 +823,26 @@ router.get("/:id/transcripts", async (req, res) => {
}
return res.json({ transcripts: list });
}
// Cursor and Antigravity: one transcript per session, no subagent files.
if (session.tool === "cursor" || session.tool === "antigravity") {
const file =
session.tool === "cursor"
? require("../lib/cursor-usage").transcriptPath(req.params.id)
: require("../lib/antigravity-usage").transcriptPath(req.params.id);
const list =
file && fs.existsSync(file)
? [
{
id: "main",
name: "Main Agent",
type: "main",
has_transcript: true,
db_agent_id: null,
},
]
: [];
return res.json({ transcripts: list });
}
// OpenCode: a subagent is a child session of the store, tied to the agent
// row its poller made.
if (session.tool === "opencode") {
@@ -1120,6 +1097,10 @@ router.get("/:id/transcript", async (req, res) => {
const isCodex = session.tool === "codex";
// An omp session's (or subagent's) journal, recorded by omp-usage.js.
const isOmp = session.tool === "omp";
// A Cursor session's agent transcript, recorded by cursor-usage.js.
const isCursor = session.tool === "cursor";
// An agy conversation's step log (`transcript_full.jsonl`), found by antigravity-usage.js.
const isAgy = session.tool === "antigravity";
// An OpenCode conversation lives in its SQLite store, not a JSONL file: read
// whole (one session's rows), then paged like the files below.
if (session.tool === "opencode") {
@@ -1136,6 +1117,14 @@ router.get("/:id/transcript", async (req, res) => {
}
if (isOmp) {
jsonlPath = require("../lib/omp-usage").journalPath(req.params.id, agentId || "");
} else if (isCursor) {
jsonlPath = isSubagentFile
? null
: require("../lib/cursor-usage").transcriptPath(req.params.id);
} else if (isAgy) {
jsonlPath = isSubagentFile
? null
: require("../lib/antigravity-usage").transcriptPath(req.params.id);
} else if (isCodex) {
jsonlPath = db
.prepare("SELECT path FROM codex_rollouts WHERE session_id = ? AND thread_id = ?")
@@ -1172,184 +1161,22 @@ router.get("/:id/transcript", async (req, res) => {
? (e) => e.type === "response_item"
: isOmp
? (e) => e.type === "message"
: (e) => TRANSCRIPT_RENDER_TYPES.has(e.type);
: isCursor
? (e) => e.role === "user" || e.role === "assistant"
: isAgy
? (e) => AGY_RENDER_TYPES.has(e.type)
: (e) => TRANSCRIPT_RENDER_TYPES.has(e.type);
const toMessage = isCodex
? codexMessage
: isOmp
? (e, n) => ompMessage(e, n, isSubagentFile)
: (e, n) => parseMessage(e, n);
: isCursor
? cursorMessage
: isAgy
? agyMessage
: (e, n) => parseMessage(e, n);
// 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);
const agent = agentInfo(entry.toolUseResult);
// A subagent's report is a reply, read as prose: its text blocks
// whole, not a 10 KB slice of their JSON.
const report =
agent && Array.isArray(block.content)
? block.content
.filter((c) => c?.type === "text" && typeof c.text === "string")
.map((c) => c.text)
.join("\n\n")
: null;
content.push({
type: "tool_result",
id: block.tool_use_id || null,
output:
report !== null
? truncate(report, TEXT_MAX)
: truncate(
typeof block.content === "string"
? block.content
: JSON.stringify(block.content || ""),
10240
),
is_error: !!block.is_error,
...(diff ? { diff } : {}),
...(answers ? { answers } : {}),
...(agent ? { agent } : {}),
});
}
}
} 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;
}
const parseMessage = createClaudeLineParser(isSubagentFile);
if (afterLine !== null) {
// Incremental mode: skip lines until after afterLine, collect up to limit, then stop.
@@ -1626,6 +1453,131 @@ function ompMessage(entry, num, isSubagent = false) {
}
}
/**
* One line of a Cursor agent transcript (`{role, message:{content}}`, see
* cursor-usage.js) as a transcript message. Cursor writes no timestamps and no
* model, so neither is set. A user line wraps the typed prompt in
* `<user_query>` after a `<timestamp>` block; only the prompt is shown.
*/
function cursorMessage(entry, num) {
const m = entry.message || {};
const parts = Array.isArray(m.content)
? m.content
: typeof m.content === "string"
? [{ type: "text", text: m.content }]
: [];
const msg = (type, sender, content) => ({ type, sender, timestamp: null, content, line: num });
const content = [];
if (entry.role === "user") {
for (const c of parts) {
if (c && c.type === "text" && typeof c.text === "string") {
const q = /<user_query>\s*([\s\S]*?)\s*<\/user_query>/.exec(c.text);
const text = (q ? q[1] : c.text.replace(/<timestamp>[\s\S]*?<\/timestamp>/, "")).trim();
if (text) content.push({ type: "text", text: truncate(text, TEXT_MAX) });
} else if (c && c.type === "tool_result") {
const out = Array.isArray(c.content)
? c.content.map((x) => (x && typeof x.text === "string" ? x.text : "")).join("")
: typeof c.content === "string"
? c.content
: "";
content.push({
type: "tool_result",
id: c.tool_use_id || null,
output: truncate(out, 10240),
is_error: c.is_error === true,
});
}
}
if (!content.length) return null;
const onlyResults = content.every((c) => c.type === "tool_result");
return msg("user", onlyResults ? "tool" : "user", content);
}
for (const c of parts) {
if (!c) continue;
if (c.type === "text" && typeof c.text === "string" && c.text.trim()) {
content.push({ type: "text", text: truncate(c.text, TEXT_MAX) });
} else if (c.type === "thinking" && typeof c.thinking === "string" && c.thinking.trim()) {
content.push({ type: "thinking", text: truncate(c.thinking, 10240) });
} else if (c.type === "tool_use") {
content.push({
type: "tool_use",
name: c.name || "unknown",
id: c.id || null,
input: truncateObj(c.input && typeof c.input === "object" ? c.input : {}, 10240),
});
}
}
return content.length ? msg("assistant", "assistant", content) : null;
}
/** The agy steps a conversation view shows (see `agyMessage`). */
const AGY_RENDER_TYPES = new Set(["USER_INPUT", "PLANNER_RESPONSE", "GENERIC"]);
/**
* One step of an agy conversation (`brain/<id>/.system_generated/logs/
* transcript_full.jsonl`, see antigravity-usage.js) as a transcript message:
* `USER_INPUT` the prompt (inside `<USER_REQUEST>`), `PLANNER_RESPONSE` the
* model's text, thinking, tool calls and tokens, `GENERIC` a tool's result
* (`status: "INVALID"` + `error` an error). agy gives a call no id: its result
* is the step right after it (call `i` of step `s` lands at step `s + 1 + i`),
* so both are named by that step — the same on every page of the file.
*/
function agyMessage(entry, num) {
const at = typeof entry.created_at === "string" ? entry.created_at : null;
const step = Number.isInteger(entry.step_index) ? entry.step_index : num;
const msg = (type, sender, content) => ({ type, sender, timestamp: at, content, line: num });
const text = typeof entry.content === "string" ? entry.content : "";
switch (entry.type) {
case "USER_INPUT": {
const req = /<USER_REQUEST>\s*([\s\S]*?)\s*<\/USER_REQUEST>/.exec(text);
const prompt = (req ? req[1] : text).trim();
if (!prompt) return null;
return msg("user", entry.source === "USER_EXPLICIT" ? "user" : "system", [
{ type: "text", text: truncate(prompt, TEXT_MAX) },
]);
}
case "PLANNER_RESPONSE": {
const content = [];
if (typeof entry.thinking === "string" && entry.thinking.trim())
content.push({ type: "thinking", text: truncate(entry.thinking, 10240) });
if (text.trim()) content.push({ type: "text", text: truncate(text, TEXT_MAX) });
(Array.isArray(entry.tool_calls) ? entry.tool_calls : []).forEach((c, i) => {
if (!c) return;
content.push({
type: "tool_use",
name: c.name || "unknown",
id: `agy-${step + 1 + i}`,
input: truncateObj(c.args && typeof c.args === "object" ? c.args : {}, 10240),
});
});
if (!content.length) return null;
const out = msg("assistant", "assistant", content);
if (Number.isFinite(entry.input_tokens) || Number.isFinite(entry.output_tokens))
out.usage = {
input_tokens: entry.input_tokens || 0,
output_tokens: entry.output_tokens || 0,
cache_read_input_tokens: entry.cache_read_tokens || 0,
cache_creation_input_tokens: 0,
};
return out;
}
case "GENERIC": {
// Its first lines say when the call ran: not part of what it returned.
const output = text.replace(/^Created At: [^\n]*\nCompleted At: [^\n]*\n\n?/, "");
return msg("user", "tool", [
{
type: "tool_result",
id: `agy-${step}`,
output: truncate(output, 10240),
is_error: entry.status === "INVALID" || entry.status === "ERROR" || !!entry.error,
},
]);
}
default:
return null;
}
}
/**
* The transcript route's paging over a conversation already in memory (an
* OpenCode store): message N is "line" N, so `after`/`before`/`offset` and the
@@ -1662,75 +1614,6 @@ function pageMessages(all, { limit, afterLine, beforeLine, offset }) {
};
}
/** 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;
}
/** An Agent/Task result's facts (`toolUseResult`): the subagent's id (its
* transcript is `agent-<id>.jsonl`), the model it ran on and, once it
* finished in the foreground, its time, tokens and tool calls. Null for any
* other result. */
function agentInfo(r) {
if (!r || typeof r !== "object" || typeof r.agentId !== "string") return null;
if (!/^[\w-]{1,200}$/.test(r.agentId)) return null;
const num = (v) => (Number.isFinite(v) ? v : null);
return {
id: r.agentId,
model: typeof r.resolvedModel === "string" ? r.resolvedModel : null,
status: typeof r.status === "string" ? r.status : null,
duration_ms: num(r.totalDurationMs),
tokens: num(r.totalTokens),
tool_uses: num(r.totalToolUseCount),
};
}
// 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.