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
+6 -1
View File
@@ -88,7 +88,7 @@ function validate(doc, source) {
if (typeof doc.label !== "string" || !doc.label.trim()) return null;
if (!isStrArray(doc.detect, CMD_RE) || !doc.detect.length) return null;
if (doc.requires !== undefined && !isStrArray(doc.requires, CMD_RE)) return null;
for (const k of ["args", "resume", "bypass"]) {
for (const k of ["args", "resume", "resumeById", "bypass"]) {
if (doc[k] !== undefined && !isStrArray(doc[k])) return null;
}
if (doc.bypassEnv !== undefined) {
@@ -145,6 +145,9 @@ function validate(doc, source) {
faviconDomain: typeof doc.faviconDomain === "string" ? doc.faviconDomain : null,
prompt,
resume: doc.resume || null,
// `resumeById`: the flag(s) a NAMED session's id follows, as one more argv
// element (`omp --resume <id>`) — the id is validated at spawn time.
resumeById: doc.resumeById || null,
bypass: doc.bypass || null,
bypassEnv: doc.bypassEnv || null,
launcher,
@@ -416,6 +419,8 @@ function view() {
launcher: t.launcher,
prompt: t.prompt ? { mode: t.prompt.mode } : null,
resume: !!t.resume,
// claude/codex resume a named session through their own argv builders.
resumeById: Boolean(t.resumeById || t.launcher),
bypass: !!(t.bypass || t.bypassEnv),
proxyCapable: PROXY_CAPABLE.has(t.id),
// claude/codex take a model through their own argv builders.
+36
View File
@@ -0,0 +1,36 @@
/**
* @file Reads what an append-only JSONL journal gained since a byte offset —
* the incremental read a session-journal poller does (omp-usage.js). Only
* complete lines are returned: one still being written
* stays for the next pass. Every open goes through `openSafeAsync`, so a
* member's journal (S3) is read only when it is a regular file they own,
* never through a link.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const CHUNK = 8 * 1024 * 1024; // read per file per pass; the rest waits for the next
/**
* The complete lines written between `offset` and `size`: `{lines, offset}`.
* A chunk with no newline at all (one line over CHUNK) is skipped whole.
*/
async function readNew(file, offset, size, { chunk = CHUNK } = {}) {
const fh = await require("./run-as").openSafeAsync(file);
try {
const len = Math.min(size - offset, chunk);
if (len <= 0) return { lines: [], offset };
const buf = Buffer.alloc(len);
let got = 0;
for (let n; got < len; got += n) {
({ bytesRead: n } = await fh.read(buf, got, len - got, offset + got));
if (!n) break;
}
const end = got ? buf.lastIndexOf(0x0a, got - 1) : -1;
if (end === -1) return { lines: [], offset: got === chunk ? offset + got : offset };
return { lines: buf.toString("utf8", 0, end).split("\n"), offset: offset + end + 1 };
} finally {
await fh.close();
}
}
module.exports = { readNew };
+526
View File
@@ -0,0 +1,526 @@
/**
* @file Oh My Pi (`omp`) sessions, subagents and token usage, read from the
* session journals omp writes under `~/.omp/agent/sessions/<encoded cwd>/`.
* This is how an omp session reaches `sessions` (as `tool = 'omp'`), its
* tokens `token_usage` and its subagents `agents` — omp has no hooks.
*
* What a journal holds (omp 18.x, verified against files it wrote — not a
* documented format):
* - its file name is `<timestamp>_<session id>.jsonl`, and `omp --resume <id>`
* takes that id back;
* - line 1 is a fixed-width `{"type":"title", title}` slot omp rewrites in
* place, line 2 the `{"type":"session", id, cwd, timestamp}` header;
* - every assistant turn is a `{"type":"message"}` whose `message.usage` is
* `{input, output, cacheRead, cacheWrite}` (input NOT including cache reads)
* under `message.model`;
* - a process that ends appends `{"type":"custom","customType":"session_exit"}`;
* - a subagent the `task` tool spawns writes its own journal, in the same
* format, under the folder named like the session file
* (`<timestamp>_<session id>/<Agent name>.jsonl`, nested agents deeper).
* Journals only grow (bar the title slot), so each is read from where the last
* pass stopped; what it said so far is kept per file in `omp_sessions`, so a
* session the user deleted stays deleted until omp writes to it again. A
* session's tokens are the sum of its own journal and its subagents'.
*
* Status: completed once its process wrote `session_exit`, else active while
* a journal changed in the last 2 minutes or a live dashboard pane holds the
* session (an idle omp waiting for its user writes nothing), else completed.
* S3: every member's own `~/.omp` is read too, only through `openSafeAsync`
* (never a link, only a file that member owns), and only for a session whose
* folder is that same member's.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
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 pricingSync = require("./pricing-sync");
const { readNew } = require("./jsonl-tail");
const ACTIVE_MS = 120_000; // the window codex-usage.js calls a session active
const FUTURE_SLACK_MS = 1000;
const SESSION_FILE = /^.+_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/;
const SESSION_DIR = /^.+_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/;
const HEAD_BYTES = 4096;
const MAX_DEPTH = 4; // nested subagents
const MAX_FILES = 20_000;
/** `~/.omp`, or `AGENTHUB_OMP_DIR`; read at call time. */
function getOmpDir() {
return process.env.AGENTHUB_OMP_DIR || path.join(os.homedir(), ".omp");
}
/** A subagent's key: its path under the session folder, as one plain name. */
const agentKey = (rel) =>
rel
.replace(/\.jsonl$/, "")
.split(path.sep)
.join(".")
.replace(/[^\w.-]/g, "_")
.replace(/^\.+/, "")
.slice(0, 120);
/** The agents-table id of a subagent journal. */
const agentRowId = (sessionId, key) => `omp-${sessionId}-${key}`;
/** Every journal: `{path, sessionId, agent ('' = the session's own), mtime, size}`. */
function journalFiles() {
const members = require("./run-as")
.memberHomes()
.map((h) => path.join(h, ".omp"));
const out = [];
for (const dir of [getOmpDir(), ...members]) {
journalFilesIn(path.join(dir, "agent", "sessions"), out);
}
return out;
}
function statFile(file) {
try {
const st = fs.lstatSync(file);
return st.isFile() ? st : null;
} catch {
return null;
}
}
function journalFilesIn(root, out) {
let projects;
try {
projects = fs.readdirSync(root, { withFileTypes: true });
} catch {
return;
}
for (const p of projects) {
if (!p.isDirectory()) continue;
const dir = path.join(root, p.name);
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
continue;
}
// The session's own journal before its subagents': a subagent row needs
// its session row.
entries.sort((a, b) => Number(b.isFile()) - Number(a.isFile()));
for (const e of entries) {
if (out.length >= MAX_FILES) return;
const full = path.join(dir, e.name);
let m;
if (e.isFile() && (m = SESSION_FILE.exec(e.name))) {
const st = statFile(full);
if (st) {
out.push({
path: full,
sessionId: m[1],
agent: "",
mtime: Math.floor(st.mtimeMs),
size: st.size,
});
}
} else if (e.isDirectory() && (m = SESSION_DIR.exec(e.name))) {
subagentFiles(full, full, m[1], 0, out);
}
}
}
}
function subagentFiles(base, dir, sessionId, depth, out) {
if (depth >= MAX_DEPTH) return;
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
if (out.length >= MAX_FILES) return;
const full = path.join(dir, e.name);
// Real folders and files only: a link is never followed.
if (e.isDirectory()) subagentFiles(base, full, sessionId, depth + 1, out);
else if (e.isFile() && e.name.endsWith(".jsonl")) {
const st = statFile(full);
const key = agentKey(path.relative(base, full));
if (st && key) {
out.push({
path: full,
sessionId,
agent: key,
mtime: Math.floor(st.mtimeMs),
size: st.size,
});
}
}
}
}
/** A fresh per-journal state. */
const emptyState = () => ({
v: 1,
cwd: null,
startedAt: null,
lastAt: null,
model: null,
firstPrompt: null,
exited: false,
usage: {},
});
/** A message's text parts, joined. */
function textOf(content) {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.map((c) => (c && c.type === "text" && typeof c.text === "string" ? c.text : ""))
.join("");
}
/** A model id without its provider (`anthropic/claude-sonnet-4-5` → `claude-sonnet-4-5`). */
const bareModel = (m) =>
typeof m === "string" && m ? m.slice(m.lastIndexOf("/") + 1).slice(0, 128) : null;
const num = (v) => (Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
/** Fold one journal line into `state`. */
function applyEntry(state, e) {
if (!e || typeof e !== "object") return;
const at = typeof e.timestamp === "string" ? e.timestamp : null;
switch (e.type) {
case "session":
if (typeof e.cwd === "string" && path.isAbsolute(e.cwd)) state.cwd = e.cwd;
if (at && !state.startedAt) state.startedAt = at;
break;
case "model_change":
state.model = bareModel(e.model) || state.model;
state.exited = false;
break;
case "message": {
const m = e.message || {};
state.exited = false;
if (at) state.lastAt = at;
if (m.role === "user" && !state.firstPrompt) {
const text = textOf(m.content).trim();
if (text && !text.startsWith("<system-reminder>")) state.firstPrompt = text.slice(0, 500);
}
if (m.role === "assistant" && m.usage && typeof m.usage === "object") {
const model = bareModel(m.model) || state.model || "unknown";
state.model = model;
const u = (state.usage[model] ||= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
u.input += num(m.usage.input);
u.output += num(m.usage.output);
u.cacheRead += num(m.usage.cacheRead);
u.cacheWrite += num(m.usage.cacheWrite);
}
break;
}
case "custom":
if (e.customType === "session_exit") state.exited = true;
break;
default:
break;
}
}
/** The journal's title slot (line 1), or null. */
async function readTitle(file) {
let fh;
try {
fh = await require("./run-as").openSafeAsync(file);
const buf = Buffer.alloc(HEAD_BYTES);
const { bytesRead } = await fh.read(buf, 0, HEAD_BYTES, 0);
const first = buf.toString("utf8", 0, bytesRead).split("\n")[0];
const o = JSON.parse(first);
return o?.type === "title" && typeof o.title === "string" && o.title.trim()
? o.title.trim().slice(0, 200)
: null;
} catch {
return null;
} finally {
await fh?.close();
}
}
const getSeen = db.prepare("SELECT * FROM omp_sessions WHERE path = ?");
const upsertSeen = db.prepare(
`INSERT INTO omp_sessions (path, session_id, agent, size, mtime_ms, offset, state, title, updated_at)
VALUES (@path, @sessionId, @agent, @size, @mtime, @offset, @state, @title,
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
ON CONFLICT(path) DO UPDATE SET session_id = excluded.session_id, agent = excluded.agent,
size = excluded.size, mtime_ms = excluded.mtime_ms, offset = excluded.offset,
state = excluded.state, title = excluded.title, updated_at = excluded.updated_at`
);
const filesOf = db.prepare("SELECT * FROM omp_sessions WHERE session_id = ? ORDER BY agent");
const activeOmp = db.prepare("SELECT id FROM sessions WHERE tool = 'omp' AND status = 'active'");
const heldByPane = db.prepare(
"SELECT 1 FROM dashboard_runs WHERE session_id = ? AND status IN ('running', 'spawning') LIMIT 1"
);
const insertSession = db.prepare(
`INSERT INTO sessions (id, name, status, cwd, model, started_at, ended_at, updated_at, metadata, tool)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'omp')`
);
const updateSession = db.prepare(
`UPDATE sessions SET name = ?, status = ?, model = COALESCE(?, model), ended_at = ?,
updated_at = ?, cwd = COALESCE(cwd, ?), started_at = COALESCE(?, started_at)
WHERE id = ? AND tool = 'omp'`
);
const clearUsage = db.prepare("DELETE FROM token_usage WHERE session_id = ?");
const agentTimes = db.prepare(
"UPDATE agents SET started_at = COALESCE(?, started_at), ended_at = ? WHERE id = ?"
);
// A write "in the future" (a clock that runs ahead) is not proof of activity.
const fresh = (mtime, now) => mtime - now < FUTURE_SLACK_MS && now - mtime < ACTIVE_MS;
const parseState = (row) => {
try {
return { ...emptyState(), ...JSON.parse(row.state) };
} catch {
return emptyState();
}
};
/** Read what one journal wrote since the last pass into its `omp_sessions` row. */
async function readJournal(f) {
const seen = getSeen.get(f.path);
let state = seen && seen.session_id === f.sessionId ? parseState(seen) : emptyState();
let offset = seen && seen.session_id === f.sessionId ? seen.offset : 0;
// Smaller than what was read: rewritten, so read again from the top.
if (f.size < offset) {
state = emptyState();
offset = 0;
}
const got = await readNew(f.path, offset, f.size);
for (const line of got.lines) {
if (!line) continue;
try {
applyEntry(state, JSON.parse(line));
} catch {
/* a line that is not JSON: skipped */
}
}
const title = f.agent ? null : await readTitle(f.path);
upsertSeen.run({
path: f.path,
sessionId: f.sessionId,
agent: f.agent,
size: f.size,
// A journal read only part-way is re-read next pass even if unchanged.
mtime: got.offset < f.size ? -1 : f.mtime,
offset: got.offset,
state: JSON.stringify(state),
title,
});
}
/**
* Rewrite one session's row, token usage and subagents from its journals'
* rows. @returns the session's broadcast type, or null (nothing written).
*/
function writeSession(sessionId, now, broadcast) {
const files = filesOf.all(sessionId);
const main = files.find((r) => r.agent === "");
if (!main) return null;
const existing = stmts.getSession.get(sessionId);
if (existing && existing.tool !== "omp") return null;
const ms = parseState(main);
const runAs = require("./run-as");
// S3: a member's journal names only their own folder and session.
if (!runAs.sameOwner(main.path, ms.cwd, existing?.cwd)) return null;
const lastMtime = Math.max(...files.map((r) => r.mtime_ms));
const lastWrite = new Date(lastMtime > 0 ? lastMtime : now).toISOString();
const active = !ms.exited && (fresh(lastMtime, now) || !!heldByPane.get(sessionId));
const status = active ? "active" : "completed";
const endedAt = active ? null : lastWrite;
const firstLine = ms.firstPrompt ? ms.firstPrompt.split("\n")[0].slice(0, 60) : null;
const name = main.title || firstLine || existing?.name || `OMP ${sessionId.slice(0, 8)}`;
const usage = {};
for (const r of files) {
for (const [m, u] of Object.entries(parseState(r).usage)) {
const t = (usage[m] ||= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
t.input += u.input;
t.output += u.output;
t.cacheRead += u.cacheRead;
t.cacheWrite += u.cacheWrite;
}
}
const agentEvents = [];
db.transaction(() => {
if (existing) {
updateSession.run(name, status, ms.model, endedAt, lastWrite, ms.cwd, ms.startedAt, sessionId);
} else {
insertSession.run(
sessionId,
name,
status,
ms.cwd,
ms.model,
ms.startedAt || lastWrite,
endedAt,
lastWrite,
JSON.stringify({ source: "omp-journal" })
);
}
claimToolSessions(sessionId); // its workspace, before the broadcast
// Every usage row of an omp session is this module's: rewritten whole.
clearUsage.run(sessionId);
for (const [m, t] of Object.entries(usage)) {
stmts.setTokenUsage.run(
sessionId,
m,
"standard",
"global",
"standard",
t.input,
t.output,
t.cacheRead,
t.cacheWrite,
0,
0,
0,
0
);
}
for (const r of files) {
if (!r.agent || !runAs.sameOwner(main.path, r.path)) continue;
const s = parseState(r);
const id = agentRowId(sessionId, r.agent);
const done = !active || s.exited || !fresh(r.mtime_ms, now);
const agentStatus = done ? "completed" : "working";
const ended = done ? new Date(r.mtime_ms > 0 ? r.mtime_ms : now).toISOString() : null;
const label = r.agent.split(".").pop() || r.agent;
const prior = stmts.getAgent.get(id);
if (prior && prior.session_id !== sessionId) continue;
if (!prior) {
stmts.insertAgent.run(
id,
sessionId,
label,
"subagent",
"task",
agentStatus,
s.firstPrompt,
null,
JSON.stringify({ source: "omp-journal", transcript: r.agent })
);
} else if (prior.status !== agentStatus || (s.firstPrompt && prior.task !== s.firstPrompt)) {
stmts.updateAgent.run(null, agentStatus, s.firstPrompt, null, ended, null, id);
} else continue;
agentTimes.run(s.startedAt, ended, id);
agentEvents.push([prior ? "agent_updated" : "agent_created", id]);
}
})();
for (const [type, id] of agentEvents) broadcast(type, stmts.getAgent.get(id));
return existing ? "session_updated" : "session_created";
}
let running = null;
// Fresh sessions the last pass left for a later one to settle (the poll keeps
// its base pace while there are any).
let lastPending = 0;
/**
* One pass: every changed journal is read on from where it stopped, then each
* session it touched — and each one still `active` — is rewritten.
* @returns {Promise<{files: number, sessions: number}>}
*/
function ingestOmpUsage({ broadcast = () => {}, now = Date.now() } = {}) {
if (running) return running;
running = (async () => {
let read = 0;
let written = 0;
const touched = new Set();
for (const f of journalFiles()) {
const seen = getSeen.get(f.path);
if (seen && seen.mtime_ms === f.mtime && seen.size === f.size) continue;
try {
await readJournal(f);
read += 1;
touched.add(f.sessionId);
} catch (err) {
console.warn(`[omp-usage] skipped ${f.path}: ${err.message}`);
}
await new Promise((r) => setImmediate(r));
}
for (const { id } of activeOmp.all()) touched.add(id);
let pending = 0;
for (const id of touched) {
try {
const type = writeSession(id, now, broadcast);
if (!type) continue;
const s = stmts.getSession.get(id);
if (s?.status === "active") pending += 1;
written += 1;
broadcast(type, s);
} catch (err) {
console.warn(`[omp-usage] session ${id}: ${err.message}`);
}
}
lastPending = pending;
return { files: read, sessions: written };
})().finally(() => {
running = null;
});
return running;
}
/**
* The journal behind a session's transcript: its own (`agent` '' / "main"),
* or one subagent's by key. Null when unknown.
*/
function journalPath(sessionId, agent = "") {
const key = agent === "main" ? "" : agent || "";
const row = db
.prepare("SELECT path FROM omp_sessions WHERE session_id = ? AND agent = ?")
.get(sessionId, key);
return row?.path || null;
}
/** A session's subagent journals: `[{agent, name, task, startedAt}]`. */
function subagentJournals(sessionId) {
return filesOf
.all(sessionId)
.filter((r) => r.agent)
.map((r) => {
const s = parseState(r);
return {
agent: r.agent,
name: r.agent.split(".").pop() || r.agent,
task: s.firstPrompt,
startedAt: s.startedAt,
rowId: agentRowId(sessionId, r.agent),
};
});
}
/**
* First pass at once (in the background), then every DASHBOARD_OMP_POLL_MS
* (default 15 s; 0 disables) — slowed to once a minute after a pass that read
* nothing, wrote nothing and left no active session to settle.
*/
function startOmpUsagePoll(broadcast) {
const POLL_MS = process.env.DASHBOARD_OMP_POLL_MS
? Number(process.env.DASHBOARD_OMP_POLL_MS)
: 15_000;
if (!Number.isFinite(POLL_MS) || POLL_MS <= 0) return;
require("./backoff-poll").startBackoffPoll(
() =>
ingestOmpUsage({ broadcast }).then((r) => {
if (r.sessions > 0 && pricingSync.syncEnabled()) void pricingSync.syncPricing(db);
return r.files + r.sessions + lastPending > 0;
}),
{ baseMs: POLL_MS }
);
}
module.exports = {
getOmpDir,
agentKey,
applyEntry,
emptyState,
ingestOmpUsage,
journalPath,
subagentJournals,
startOmpUsagePoll,
};
+504
View File
@@ -0,0 +1,504 @@
/**
* @file OpenCode (`opencode`) sessions, subagents and token usage, read from
* OpenCode's own SQLite store (`~/.local/share/opencode/opencode.db`, under
* `XDG_DATA_HOME` when set). This is how an OpenCode session reaches
* `sessions` (as `tool = 'opencode'`), its tokens `token_usage` and its
* subagents `agents` — OpenCode has no hooks AgentHub installs.
*
* What the store holds (opencode 1.19, verified against a file it wrote — not
* a documented format):
* - `session(id, parent_id, directory, title, time_created, time_updated,
* time_archived)` — a session the `task` tool spawned has `parent_id` set,
* and is shown as a subagent of its root session;
* - `message(session_id, data)` — an assistant message's JSON carries
* `modelID` and `tokens: {input, output, reasoning, cache: {read, write}}`
* (input NOT including cache reads; reasoning is counted as output here);
* - `part(message_id, session_id, data)` — the message's text, reasoning and
* tool parts, read only for a transcript.
* The file is re-read when its mtime (or its non-empty `-wal`'s) changes; per
* root session the newest `time_updated` seen lives in `opencode_sessions`,
* so a session the user deleted stays deleted until OpenCode writes to it
* again. Status: active while its tree was updated in the last 2 minutes or a
* live dashboard pane holds it, else completed. S3: a member's store (and its
* WAL) is read through the guard into a private copy, never opened in place.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { db, stmts, Database } = require("../db");
const { claimToolSessions } = require("./dashboard-runs");
const pricingSync = require("./pricing-sync");
const ACTIVE_MS = 120_000; // the window codex-usage.js calls a session active
const FUTURE_SLACK_MS = 1000;
const SESSION_ID = /^[A-Za-z0-9_-]{8,120}$/;
const MAX_COPY = 256 << 20;
/** OpenCode's data dir: `AGENTHUB_OPENCODE_DIR`, `$XDG_DATA_HOME/opencode`, else `~/.local/share/opencode`. */
function getOpencodeDir() {
if (process.env.AGENTHUB_OPENCODE_DIR) return process.env.AGENTHUB_OPENCODE_DIR;
const xdg = process.env.XDG_DATA_HOME;
return path.join(xdg && path.isAbsolute(xdg) ? xdg : path.join(os.homedir(), ".local", "share"), "opencode");
}
/** Every store: `{path, mtime}` — this account's, then every member's. */
function storeFiles() {
const members = require("./run-as")
.memberHomes()
.map((h) => path.join(h, ".local", "share", "opencode"));
const out = [];
for (const dir of [getOpencodeDir(), ...members]) {
const file = path.join(dir, "opencode.db");
try {
const st = fs.lstatSync(file);
if (!st.isFile()) continue;
let mtime = st.mtimeMs;
try {
const wal = fs.lstatSync(`${file}-wal`);
if (wal.isFile() && wal.size > 0) mtime = Math.max(mtime, wal.mtimeMs);
} catch {
/* no WAL right now */
}
out.push({ path: file, mtime: Math.floor(mtime) });
} catch {
/* no store */
}
}
return out;
}
/**
* Run `fn(conn)` on a store, read-only. A member's store (S3) and its WAL are
* copied through the guard first: SQLite opens by NAME, and a member's FIFO
* standing in for the `-wal` would block this thread.
*/
function withStore(file, fn) {
const { ownerForRead, readFileSafe } = require("./run-as");
let tmp = null;
try {
let open = file;
if (require("./linux-accounts").s3Enabled() && ownerForRead(file)) {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-opencode-"));
open = path.join(tmp, "opencode.db");
fs.writeFileSync(open, readFileSafe(file, null, { max: MAX_COPY }));
try {
fs.writeFileSync(`${open}-wal`, readFileSafe(`${file}-wal`, null, { max: MAX_COPY }));
} catch {
/* no WAL (or not theirs): what is in the main file */
}
}
const conn = new Database(open, { readonly: !tmp, fileMustExist: true });
try {
return fn(conn);
} finally {
conn.close();
}
} finally {
if (tmp) fs.rmSync(tmp, { recursive: true, force: true });
}
}
/** A session's model column (`{"id": …}` JSON, or a plain id), or null. */
function modelOf(raw) {
if (typeof raw !== "string" || !raw) return null;
try {
const o = JSON.parse(raw);
return typeof o?.id === "string" ? o.id.slice(0, 128) : null;
} catch {
return raw.slice(0, 128);
}
}
/**
* Everything a pass needs from one store: `[{root, children, usage, updated}]`
* per root session — `usage` per model over the root and every descendant.
*/
function readStore(file) {
return withStore(file, (conn) => {
const rows = conn
.prepare(
`SELECT id, parent_id, directory, title, time_created, time_updated, time_archived, model
FROM session`
)
.all();
const byId = new Map(rows.map((r) => [r.id, r]));
const rootOf = (r) => {
let cur = r;
for (let i = 0; i < 32 && cur.parent_id && byId.has(cur.parent_id); i++) {
cur = byId.get(cur.parent_id);
}
return cur;
};
const trees = new Map();
for (const r of rows) {
if (!SESSION_ID.test(r.id)) continue;
const root = rootOf(r);
if (!SESSION_ID.test(root.id)) continue;
const t = trees.get(root.id) || { root, children: [], usage: {}, updated: 0, model: null };
if (r !== root) t.children.push(r);
t.updated = Math.max(t.updated, Number(r.time_updated) || 0);
trees.set(root.id, t);
}
const sums = conn
.prepare(
`SELECT session_id,
json_extract(data, '$.modelID') AS model,
SUM(COALESCE(json_extract(data, '$.tokens.input'), 0)) AS input,
SUM(COALESCE(json_extract(data, '$.tokens.output'), 0)
+ COALESCE(json_extract(data, '$.tokens.reasoning'), 0)) AS output,
SUM(COALESCE(json_extract(data, '$.tokens.cache.read'), 0)) AS cache_read,
SUM(COALESCE(json_extract(data, '$.tokens.cache.write'), 0)) AS cache_write,
MAX(time_created) AS last
FROM message
WHERE json_extract(data, '$.role') = 'assistant'
GROUP BY session_id, model`
)
.all();
for (const s of sums) {
const r = byId.get(s.session_id);
const t = r && trees.get(rootOf(r).id);
if (!t) continue;
const model = typeof s.model === "string" && s.model ? s.model.slice(0, 128) : "unknown";
const u = (t.usage[model] ||= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
u.input += Number(s.input) || 0;
u.output += Number(s.output) || 0;
u.cacheRead += Number(s.cache_read) || 0;
u.cacheWrite += Number(s.cache_write) || 0;
if (r === t.root && (!t.lastAt || s.last > t.lastAt)) {
t.lastAt = s.last;
t.model = model;
}
}
return [...trees.values()];
});
}
const getStore = db.prepare("SELECT * FROM opencode_stores WHERE path = ?");
const upsertStore = db.prepare(
`INSERT INTO opencode_stores (path, mtime_ms, updated_at)
VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
ON CONFLICT(path) DO UPDATE SET mtime_ms = excluded.mtime_ms, updated_at = excluded.updated_at`
);
const getTracked = db.prepare("SELECT * FROM opencode_sessions WHERE session_id = ?");
const upsertTracked = db.prepare(
`INSERT INTO opencode_sessions (session_id, store, time_updated, updated_at)
VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
ON CONFLICT(session_id) DO UPDATE SET store = excluded.store,
time_updated = excluded.time_updated, updated_at = excluded.updated_at`
);
const activeOpencode = db.prepare(
"SELECT s.id, t.time_updated FROM sessions s JOIN opencode_sessions t ON t.session_id = s.id WHERE s.tool = 'opencode' AND s.status = 'active'"
);
const heldByPane = db.prepare(
"SELECT 1 FROM dashboard_runs WHERE session_id = ? AND status IN ('running', 'spawning') LIMIT 1"
);
const insertSession = db.prepare(
`INSERT INTO sessions (id, name, status, cwd, model, started_at, ended_at, updated_at, metadata, tool)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'opencode')`
);
const updateSession = db.prepare(
`UPDATE sessions SET name = ?, status = ?, model = COALESCE(?, model), ended_at = ?,
updated_at = ?, cwd = COALESCE(cwd, ?), started_at = COALESCE(?, started_at)
WHERE id = ? AND tool = 'opencode'`
);
const settle = db.prepare(
"UPDATE sessions SET status = 'completed', ended_at = ? WHERE id = ? AND tool = 'opencode'"
);
const clearUsage = db.prepare("DELETE FROM token_usage WHERE session_id = ?");
const agentTimes = db.prepare(
"UPDATE agents SET started_at = COALESCE(?, started_at), ended_at = ? WHERE id = ?"
);
const fresh = (t, now) => t - now < FUTURE_SLACK_MS && now - t < ACTIVE_MS;
const iso = (t) => (Number.isFinite(Number(t)) && Number(t) > 0 ? new Date(Number(t)).toISOString() : null);
/** Write one root session's row, usage and subagents. @returns its broadcast type, or null. */
function writeSession(store, t, now, broadcast) {
const r = t.root;
const existing = stmts.getSession.get(r.id);
if (existing && existing.tool !== "opencode") return null;
const cwd = typeof r.directory === "string" && path.isAbsolute(r.directory) ? r.directory : null;
const runAs = require("./run-as");
// S3: a member's store names only their own folder and session.
if (!runAs.sameOwner(store, cwd, existing?.cwd)) return null;
const lastWrite = iso(t.updated) || new Date(now).toISOString();
const active = !r.time_archived && (fresh(t.updated, now) || !!heldByPane.get(r.id));
const status = active ? "active" : "completed";
const endedAt = active ? null : lastWrite;
const name =
(typeof r.title === "string" && r.title.trim().slice(0, 200)) ||
existing?.name ||
`OpenCode ${r.id.slice(4, 12)}`;
const model = t.model || modelOf(r.model);
const agentEvents = [];
db.transaction(() => {
if (existing) {
updateSession.run(name, status, model, endedAt, lastWrite, cwd, iso(r.time_created), r.id);
} else {
insertSession.run(
r.id,
name,
status,
cwd,
model,
iso(r.time_created) || lastWrite,
endedAt,
lastWrite,
JSON.stringify({ source: "opencode-store" })
);
}
claimToolSessions(r.id);
clearUsage.run(r.id);
for (const [m, u] of Object.entries(t.usage)) {
stmts.setTokenUsage.run(
r.id,
m,
"standard",
"global",
"standard",
u.input,
u.output,
u.cacheRead,
u.cacheWrite,
0,
0,
0,
0
);
}
for (const c of t.children) {
const id = `opencode-${c.id}`;
const done = !active || !fresh(Number(c.time_updated) || 0, now);
const agentStatus = done ? "completed" : "working";
const ended = done ? iso(c.time_updated) : null;
const label = (typeof c.title === "string" && c.title.trim().slice(0, 120)) || c.id;
const prior = stmts.getAgent.get(id);
if (prior && prior.session_id !== r.id) continue;
if (!prior) {
stmts.insertAgent.run(
id,
r.id,
label,
"subagent",
"task",
agentStatus,
label,
null,
JSON.stringify({ source: "opencode-store", transcript: c.id })
);
} else if (prior.status !== agentStatus || prior.name !== label) {
stmts.updateAgent.run(label, agentStatus, label, null, ended, null, id);
} else continue;
agentTimes.run(iso(c.time_created), ended, id);
agentEvents.push([prior ? "agent_updated" : "agent_created", id]);
}
upsertTracked.run(r.id, store, t.updated);
})();
for (const [type, id] of agentEvents) broadcast(type, stmts.getAgent.get(id));
return existing ? "session_updated" : "session_created";
}
let running = null;
let lastPending = 0;
/**
* One pass: every store whose file changed is read, and each root session
* updated since it was last written is rewritten; an `active` one that went
* quiet is settled.
* @returns {Promise<{files: number, sessions: number}>}
*/
function ingestOpencodeUsage({ broadcast = () => {}, now = Date.now() } = {}) {
if (running) return running;
running = (async () => {
let read = 0;
let written = 0;
const done = new Set();
for (const f of storeFiles()) {
const seen = getStore.get(f.path);
if (seen && seen.mtime_ms === f.mtime) continue;
let trees;
try {
trees = readStore(f.path);
} catch (err) {
if (getStore.get(f.path)?.mtime_ms !== f.mtime) {
console.warn(`[opencode-usage] skipped ${f.path}: ${err.message}`);
}
upsertStore.run(f.path, f.mtime);
continue;
}
read += 1;
for (const t of trees) {
const tracked = getTracked.get(t.root.id);
const changed = !tracked || tracked.time_updated !== t.updated;
const s = stmts.getSession.get(t.root.id);
// Unchanged and deleted by the user: stays deleted.
if (!changed && (!s || s.status !== "active")) continue;
try {
const type = writeSession(f.path, t, now, broadcast);
if (!type) continue;
done.add(t.root.id);
written += 1;
broadcast(type, stmts.getSession.get(t.root.id));
} catch (err) {
console.warn(`[opencode-usage] session ${t.root.id}: ${err.message}`);
}
}
upsertStore.run(f.path, f.mtime);
await new Promise((r) => setImmediate(r));
}
// An active session in a store that did not change: settled once quiet.
let pending = 0;
for (const { id, time_updated: updated } of activeOpencode.all()) {
if (done.has(id)) {
pending += 1;
continue;
}
if (fresh(updated, now) || heldByPane.get(id)) {
pending += 1;
continue;
}
settle.run(iso(updated) || new Date(now).toISOString(), id);
for (const a of stmts.listAgentsBySession.all(id)) {
if (a.status === "completed" || a.status === "error") continue;
stmts.updateAgent.run(null, "completed", null, null, iso(updated), null, a.id);
broadcast("agent_updated", stmts.getAgent.get(a.id));
}
written += 1;
broadcast("session_updated", stmts.getSession.get(id));
}
lastPending = pending;
return { files: read, sessions: written };
})().finally(() => {
running = null;
});
return running;
}
/** A tool part's input/output, kept small. */
const short = (v, max = 10240) => {
const s = typeof v === "string" ? v : v === undefined ? "" : JSON.stringify(v);
return s.length > max ? `${s.slice(0, max)}[truncated]` : s;
};
const TEXT_MAX = 1024 * 1024;
/**
* One OpenCode session's conversation (`sessionId`, or one of its subagent
* sessions by `childId`), in the Claude transcript shape the client renders:
* `[{type, sender, timestamp, content}]`, oldest first. Null when unknown.
*/
function transcriptMessages(sessionId, childId = null) {
const tracked = getTracked.get(sessionId);
if (!tracked) return null;
const target = childId || sessionId;
if (!SESSION_ID.test(target)) return null;
return withStore(tracked.store, (conn) => {
if (childId) {
// Only a session of this tree.
let cur = conn.prepare("SELECT id, parent_id FROM session WHERE id = ?").get(childId);
for (let i = 0; i < 32 && cur && cur.id !== sessionId; i++) {
cur = cur.parent_id
? conn.prepare("SELECT id, parent_id FROM session WHERE id = ?").get(cur.parent_id)
: null;
}
if (!cur) return null;
}
const messages = conn
.prepare("SELECT id, time_created, data FROM message WHERE session_id = ? ORDER BY time_created, id")
.all(target);
const parts = conn
.prepare("SELECT message_id, data FROM part WHERE session_id = ? ORDER BY time_created, id")
.all(target);
const byMessage = new Map();
for (const p of parts) {
let d;
try {
d = JSON.parse(p.data);
} catch {
continue;
}
(byMessage.get(p.message_id) || byMessage.set(p.message_id, []).get(p.message_id)).push(d);
}
const out = [];
for (const m of messages) {
let info;
try {
info = JSON.parse(m.data);
} catch {
continue;
}
const at = iso(m.time_created);
const role = info.role === "user" ? "user" : info.role === "assistant" ? "assistant" : null;
if (!role) continue;
const content = [];
const results = [];
for (const p of byMessage.get(m.id) || []) {
if (p.type === "text" && typeof p.text === "string" && p.text.trim() && !p.synthetic) {
content.push({ type: "text", text: short(p.text, TEXT_MAX) });
} else if (p.type === "reasoning" && typeof p.text === "string" && p.text.trim()) {
content.push({ type: "thinking", text: short(p.text) });
} else if (p.type === "tool") {
const st = p.state || {};
content.push({
type: "tool_use",
name: p.tool || "unknown",
id: p.callID || null,
input: st.input && typeof st.input === "object" ? st.input : {},
});
if (st.status === "completed" || st.status === "error") {
results.push({
type: "tool_result",
id: p.callID || null,
output: short(st.status === "error" ? st.error : st.output),
is_error: st.status === "error",
});
}
}
}
if (content.length) {
const sender = role === "user" ? (childId && !out.length ? "orchestrator" : "user") : "assistant";
const msg = { type: role, sender, timestamp: at, content };
if (role === "assistant" && info.modelID) msg.model = info.modelID;
out.push(msg);
}
if (results.length) out.push({ type: "user", sender: "tool", timestamp: at, content: results });
}
return out;
});
}
/** A root session's subagent sessions: `[{id, name, rowId}]`. */
function subagentSessions(sessionId) {
return stmts
.listAgentsBySession.all(sessionId)
.filter((a) => a.id.startsWith("opencode-"))
.map((a) => ({ id: a.id.slice("opencode-".length), name: a.name, rowId: a.id }));
}
/**
* First pass at once, then every DASHBOARD_OPENCODE_POLL_MS (default 15 s; 0
* disables) — once a minute after a pass that found nothing to do.
*/
function startOpencodeUsagePoll(broadcast) {
const POLL_MS = process.env.DASHBOARD_OPENCODE_POLL_MS
? Number(process.env.DASHBOARD_OPENCODE_POLL_MS)
: 15_000;
if (!Number.isFinite(POLL_MS) || POLL_MS <= 0) return;
require("./backoff-poll").startBackoffPoll(
() =>
ingestOpencodeUsage({ broadcast }).then((r) => {
if (r.sessions > 0 && pricingSync.syncEnabled()) void pricingSync.syncPricing(db);
return r.files + r.sessions + lastPending > 0;
}),
{ baseMs: POLL_MS }
);
}
module.exports = {
getOpencodeDir,
ingestOpencodeUsage,
transcriptMessages,
subagentSessions,
startOpencodeUsagePoll,
};
+15 -7
View File
@@ -295,16 +295,18 @@ function buildCodexArgv({
/**
* Any catalog tool without a hand-written launcher: `[bin, ...args, ...resume?,
* ...bypass?, ...prompt]` (`[flag, model]` right after the fixed args when the
* ...bypass?, ...prompt]` (`...resumeById, <id>` in place of `resume` for a
* named session; `[flag, model]` right after the fixed args when the
* catalog declares a model flag). Every piece but the prompt and the
* validated model comes from the catalog
* file, and the prompt is one argv element — or, for `after-start`, nothing
* here: spawnRun pastes it into the pane once the CLI is up.
*/
function buildCatalogArgv(entry, bin, { model, resumeLast, bypass, initialPrompt }) {
function buildCatalogArgv(entry, bin, { model, resumeSessionId, resumeLast, bypass, initialPrompt }) {
const argv = [bin, ...entry.args];
if (model && entry.model) argv.push(entry.model.flag, model);
if (resumeLast && entry.resume) argv.push(...entry.resume);
if (resumeSessionId && entry.resumeById) argv.push(...entry.resumeById, resumeSessionId);
else if (resumeLast && entry.resume) argv.push(...entry.resume);
if (bypass && entry.bypass) argv.push(...entry.bypass);
const p = entry.prompt;
if (initialPrompt && p.mode === "flag") argv.push(p.flag, promptArg(initialPrompt));
@@ -437,15 +439,21 @@ function spawnRun(args) {
}
if (
resumeSessionId != null &&
(typeof resumeSessionId !== "string" || !/^[A-Za-z0-9-]{8,}$/.test(resumeSessionId))
(typeof resumeSessionId !== "string" || !/^[A-Za-z0-9_-]{8,}$/.test(resumeSessionId))
) {
throw makeErr("EBADSESSION", "resumeSessionId is not a valid session id");
}
// Only the hand-written launchers can resume a NAMED session; a catalog
// tool would silently start fresh under a "resume".
if (resumeSessionId && !entry.launcher) {
// Only the hand-written launchers and a catalog tool that declares
// `resumeById` can resume a NAMED session; any other would silently start
// fresh under a "resume".
if (resumeSessionId && !entry.launcher && !entry.resumeById) {
throw makeErr("EUNSUPPORTED", `${tool} cannot resume a session by id`);
}
// A catalog tool takes the id as an argv element of its own: never one that
// could read as a flag.
if (resumeSessionId && !entry.launcher && !/^[A-Za-z0-9]/.test(resumeSessionId)) {
throw makeErr("EBADSESSION", "resumeSessionId is not a valid session id");
}
if (worktree === true && entry.launcher !== "claude") {
throw makeErr("EUNSUPPORTED", `${tool} cannot start a session in its own worktree`);
}