feat(ai-tools): omp shared + OpenCode/omp sessions, tokens, subagents; Grok hooks; resume by id
- omp-usage.js / opencode-usage.js: poll each CLI's own session store into sessions (tool omp/opencode), token_usage per model and subagent agents; transcript routes read them (omp journals, OpenCode's opencode.db). - Grok runs the Claude hooks in ~/.claude/settings.json: its hooks are now recognised (hookEventName + workspaceRoot), tagged tool=grok, and their transcript_path dropped instead of being parsed as a Claude transcript. - Catalog `resumeById`: omp/Grok/Cursor `--resume <id>`, OpenCode `--session <id>`; session ids may carry `_`. The client offers Resume for any tool that resumes by id. - Kimi Code and GitHub Copilot leave the catalog (their session stores could not be verified).
This commit is contained in:
+34
-3
@@ -1458,7 +1458,33 @@ function antigravityHookInput(hookType, data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const HOOK_TOOLS = { codex: codexHookInput, antigravity: antigravityHookInput };
|
||||
/**
|
||||
* A Grok Build (`grok`) hook. Grok runs the hooks in `~/.claude/settings.json`
|
||||
* itself (its Claude compatibility, on by default), so AgentHub's own Claude
|
||||
* hook reaches here with no `tool` — told apart by the camelCase keys only
|
||||
* Grok sends beside Claude's snake_case aliases (`hookEventName`,
|
||||
* `workspaceRoot`). Its `transcript_path` is Grok's file, never a Claude
|
||||
* transcript, so it is dropped before anything could parse it.
|
||||
*/
|
||||
const isGrokPayload = (data) =>
|
||||
!!data &&
|
||||
typeof data === "object" &&
|
||||
typeof data.hookEventName === "string" &&
|
||||
typeof data.workspaceRoot === "string";
|
||||
|
||||
function grokHookInput(hookType, data) {
|
||||
if (typeof data.session_id !== "string" || !data.session_id) return null;
|
||||
const d = { ...data };
|
||||
delete d.transcript_path;
|
||||
delete d.agent_transcript_path;
|
||||
return { hookType, data: d };
|
||||
}
|
||||
|
||||
const HOOK_TOOLS = {
|
||||
codex: codexHookInput,
|
||||
antigravity: antigravityHookInput,
|
||||
grok: grokHookInput,
|
||||
};
|
||||
|
||||
/**
|
||||
* Record which session a dashboard pane holds. The pane's CLI inherits
|
||||
@@ -1524,9 +1550,14 @@ function ingestHook(body, pushSource = null, peer = { kind: "host" }) {
|
||||
}
|
||||
const ignored = { status: 200, body: { ok: true, ignored: true } };
|
||||
// Which CLI sent it: `codex` from Codex's hooks (codex-hooks.js),
|
||||
// `antigravity` from agy's (antigravity-hooks.js), anything else is Claude
|
||||
// `antigravity` from agy's (antigravity-hooks.js), `grok` for Grok running
|
||||
// the Claude hooks (by its payload's own keys), anything else is Claude
|
||||
// Code — an allowlist, never a value stored as sent.
|
||||
const tool = Object.hasOwn(HOOK_TOOLS, body.tool) ? body.tool : "claude";
|
||||
const tool = Object.hasOwn(HOOK_TOOLS, body.tool)
|
||||
? body.tool
|
||||
: isGrokPayload(data)
|
||||
? "grok"
|
||||
: "claude";
|
||||
if (tool !== "claude") {
|
||||
if (!data || typeof data !== "object") return ignored;
|
||||
require("../lib/hook-auth").dropForeignPaths(peer, data);
|
||||
|
||||
+170
-6
@@ -840,6 +840,45 @@ router.get("/:id/transcripts", async (req, res) => {
|
||||
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Session not found" } });
|
||||
}
|
||||
|
||||
// An omp session's journals are known by path (omp-usage.js): its own,
|
||||
// then one per subagent, each tied to the agent row made from it.
|
||||
if (session.tool === "omp") {
|
||||
const omp = require("../lib/omp-usage");
|
||||
const list = [];
|
||||
if (omp.journalPath(req.params.id)) {
|
||||
list.push({ id: "main", name: "Main Agent", type: "main", has_transcript: true, db_agent_id: null });
|
||||
}
|
||||
for (const j of omp.subagentJournals(req.params.id)) {
|
||||
list.push({
|
||||
id: j.agent,
|
||||
name: j.name,
|
||||
type: "subagent",
|
||||
subagent_type: "task",
|
||||
has_transcript: true,
|
||||
db_agent_id: stmts.getAgent.get(j.rowId) ? j.rowId : 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") {
|
||||
const list = [
|
||||
{ id: "main", name: "Main Agent", type: "main", has_transcript: true, db_agent_id: null },
|
||||
];
|
||||
for (const sub of require("../lib/opencode-usage").subagentSessions(req.params.id)) {
|
||||
list.push({
|
||||
id: sub.id,
|
||||
name: sub.name,
|
||||
type: "subagent",
|
||||
subagent_type: "task",
|
||||
has_transcript: true,
|
||||
db_agent_id: stmts.getAgent.get(sub.rowId) ? sub.rowId : null,
|
||||
});
|
||||
}
|
||||
return res.json({ transcripts: list });
|
||||
}
|
||||
|
||||
const result = [];
|
||||
|
||||
// Query database agent list for db_agent_id association
|
||||
@@ -1071,7 +1110,25 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
// 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) {
|
||||
// An omp session's (or subagent's) journal, recorded by omp-usage.js.
|
||||
const isOmp = session.tool === "omp";
|
||||
// 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") {
|
||||
let all = null;
|
||||
try {
|
||||
all = require("../lib/opencode-usage").transcriptMessages(
|
||||
req.params.id,
|
||||
isSubagentFile ? agentId : null
|
||||
);
|
||||
} catch {
|
||||
all = null;
|
||||
}
|
||||
return res.json(pageMessages(all || [], { limit, afterLine, beforeLine, offset }));
|
||||
}
|
||||
if (isOmp) {
|
||||
jsonlPath = require("../lib/omp-usage").journalPath(req.params.id, agentId || "");
|
||||
} else if (isCodex) {
|
||||
jsonlPath = db
|
||||
.prepare("SELECT path FROM codex_rollouts WHERE session_id = ? AND thread_id = ?")
|
||||
.get(req.params.id, req.params.id)?.path;
|
||||
@@ -1105,7 +1162,14 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
|
||||
const renders = isCodex
|
||||
? (e) => e.type === "response_item"
|
||||
: (e) => TRANSCRIPT_RENDER_TYPES.has(e.type);
|
||||
: isOmp
|
||||
? (e) => e.type === "message"
|
||||
: (e) => TRANSCRIPT_RENDER_TYPES.has(e.type);
|
||||
const toMessage = isCodex
|
||||
? codexMessage
|
||||
: isOmp
|
||||
? (e, n) => ompMessage(e, n, isSubagentFile)
|
||||
: (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
|
||||
@@ -1282,7 +1346,7 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
}
|
||||
if (!renders(entry)) continue;
|
||||
|
||||
const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum);
|
||||
const message = toMessage(entry, lineNum);
|
||||
if (!message) continue;
|
||||
total++;
|
||||
messages.push(message);
|
||||
@@ -1313,7 +1377,7 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
break;
|
||||
}
|
||||
|
||||
const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum);
|
||||
const message = toMessage(entry, lineNum);
|
||||
if (!message) continue;
|
||||
total++;
|
||||
messages.push(message);
|
||||
@@ -1337,7 +1401,7 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
}
|
||||
if (!renders(entry)) continue;
|
||||
|
||||
const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum);
|
||||
const message = toMessage(entry, lineNum);
|
||||
if (!message) continue;
|
||||
total++;
|
||||
|
||||
@@ -1364,7 +1428,7 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
}
|
||||
if (!renders(entry)) continue;
|
||||
|
||||
const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum);
|
||||
const message = toMessage(entry, lineNum);
|
||||
if (!message) continue;
|
||||
total++;
|
||||
messages.push(message);
|
||||
@@ -1473,6 +1537,106 @@ function codexMessage(entry, num) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One omp journal `message` line → a transcript message in the Claude shape
|
||||
* the client renders, or null. A user line omp wrote for itself (no
|
||||
* `attribution: "user"`) is the system's — in a subagent's journal, the first
|
||||
* one is the orchestrator's task.
|
||||
*/
|
||||
function ompMessage(entry, num, isSubagent = false) {
|
||||
const m = entry.message || {};
|
||||
const at = entry.timestamp || null;
|
||||
const msg = (type, sender, content) => ({ type, sender, timestamp: at, content, line: num });
|
||||
const parts = Array.isArray(m.content)
|
||||
? m.content
|
||||
: typeof m.content === "string"
|
||||
? [{ type: "text", text: m.content }]
|
||||
: [];
|
||||
switch (m.role) {
|
||||
case "user": {
|
||||
const content = parts
|
||||
.filter((c) => c && c.type === "text" && typeof c.text === "string" && c.text.trim())
|
||||
.map((c) => ({ type: "text", text: truncate(c.text, TEXT_MAX) }));
|
||||
if (!content.length) return null;
|
||||
const sender = m.attribution === "user" ? "user" : isSubagent ? "orchestrator" : "system";
|
||||
return msg("user", sender, content);
|
||||
}
|
||||
case "assistant": {
|
||||
const 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 === "toolCall") {
|
||||
content.push({
|
||||
type: "tool_use",
|
||||
name: c.name || "unknown",
|
||||
id: c.id || null,
|
||||
input: truncateObj(c.arguments && typeof c.arguments === "object" ? c.arguments : {}, 10240),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!content.length) return null;
|
||||
const out = msg("assistant", "assistant", content);
|
||||
if (m.model) out.model = m.model;
|
||||
return out;
|
||||
}
|
||||
case "toolResult": {
|
||||
const text = parts
|
||||
.map((c) => (c && c.type === "text" && typeof c.text === "string" ? c.text : ""))
|
||||
.join("");
|
||||
return msg("user", "tool", [
|
||||
{
|
||||
type: "tool_result",
|
||||
id: m.toolCallId || null,
|
||||
output: truncate(text, 10240),
|
||||
is_error: m.isError === true,
|
||||
},
|
||||
]);
|
||||
}
|
||||
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
|
||||
* default latest-`limit` window answer exactly as they do over a file.
|
||||
*/
|
||||
function pageMessages(all, { limit, afterLine, beforeLine, offset }) {
|
||||
const lined = all.map((m, i) => ({ ...m, line: i + 1 }));
|
||||
let picked;
|
||||
let hasMore;
|
||||
if (afterLine !== null) {
|
||||
const rest = lined.filter((m) => m.line > afterLine);
|
||||
picked = rest.slice(0, limit);
|
||||
hasMore = rest.length > limit;
|
||||
} else if (beforeLine !== null) {
|
||||
const older = lined.filter((m) => m.line < beforeLine);
|
||||
picked = older.slice(-limit);
|
||||
hasMore = older.length > limit;
|
||||
} else if (offset > 0) {
|
||||
picked = lined.slice(offset, offset + limit);
|
||||
hasMore = lined.length > offset + limit;
|
||||
} else {
|
||||
picked = lined.slice(-limit);
|
||||
hasMore = lined.length > limit;
|
||||
}
|
||||
const lastLine = picked.length ? picked[picked.length - 1].line : 0;
|
||||
const firstLine = picked.length ? picked[0].line : 0;
|
||||
for (const m of picked) delete m.line;
|
||||
return {
|
||||
messages: picked,
|
||||
total: lined.length,
|
||||
has_more: hasMore,
|
||||
last_line: lastLine,
|
||||
first_line: firstLine,
|
||||
};
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
|
||||
Reference in New Issue
Block a user