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:
2026-10-19 16:49:59 +07:00
parent 0434ffc750
commit f8d07dab61
33 changed files with 2007 additions and 103 deletions
+34 -3
View File
@@ -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
View File
@@ -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. */