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
+5 -5
View File
File diff suppressed because one or more lines are too long
@@ -9,8 +9,9 @@
* first, and a session some tab already holds opens THAT tab instead of a * first, and a session some tab already holds opens THAT tab instead of a
* second `--resume` (the server refuses one too, `ESESSIONLIVE`) — the typed * second `--resume` (the server refuses one too, `ESESSIONLIVE`) — the typed
* message is pasted there (never sent: Enter is the user's), with a toast; while * message is pasted there (never sent: Enter is the user's), with a toast; while
* it is live the composer is off. Only Claude and Codex can resume a session by id — * it is live the composer is off. Only Claude, Codex and a catalog tool with
* any other tool's session offers a new session from the workspace home * `resumeById` (omp) can resume a session by id — any other tool's session
* offers a new session from the workspace home
* instead. The header carries the transcript page's Resume/Fork * instead. The header carries the transcript page's Resume/Fork
* (`SessionResumeButton`, behind its warning). The session's tool and folder * (`SessionResumeButton`, behind its warning). The session's tool and folder
* come from its row (`GET /api/sessions/:id`), so a resume runs on the tool the * come from its row (`GET /api/sessions/:id`), so a resume runs on the tool the
@@ -32,6 +33,7 @@ import { toast } from "../../lib/toast";
import i18n from "../../i18n"; import i18n from "../../i18n";
import { liveRunFor } from "./WorkspaceSessionList"; import { liveRunFor } from "./WorkspaceSessionList";
import { SessionResumeButton } from "./SessionResumeButton"; import { SessionResumeButton } from "./SessionResumeButton";
import { useAiTools } from "../../hooks/useAiTools";
/** A message for a session that a tab already holds goes into THAT tab as a /** A message for a session that a tab already holds goes into THAT tab as a
* paste, once its terminal is on screen (it mounts after the navigation); * paste, once its terminal is on screen (it mounts after the navigation);
@@ -47,6 +49,7 @@ function pasteWhenShown(runId: string, text: string) {
} }
/** The tools whose launcher resumes a session BY ID (server/lib/pty-run.js). */ /** The tools whose launcher resumes a session BY ID (server/lib/pty-run.js). */
// Before the catalog loads: the two hand-written launchers resume by id.
const RESUMABLE = new Set(["claude", "codex"]); const RESUMABLE = new Set(["claude", "codex"]);
export function SessionReplay({ export function SessionReplay({
@@ -65,6 +68,7 @@ export function SessionReplay({
}) { }) {
const { t } = useTranslation("lanes"); const { t } = useTranslation("lanes");
const navigate = useNavigate(); const navigate = useNavigate();
const { byId } = useAiTools();
const [session, setSession] = useState<Session | null>(null); const [session, setSession] = useState<Session | null>(null);
const [prompt, setPrompt] = useState(""); const [prompt, setPrompt] = useState("");
const [images, setImages] = useState<PromptImage[]>([]); const [images, setImages] = useState<PromptImage[]>([]);
@@ -88,7 +92,7 @@ export function SessionReplay({
}, [sessionId]); }, [sessionId]);
const tool = session?.tool || "claude"; const tool = session?.tool || "claude";
const resumable = RESUMABLE.has(tool); const resumable = byId(tool)?.resumeById ?? RESUMABLE.has(tool);
const livePane = liveRunFor(lane, liveRuns, sessionId); const livePane = liveRunFor(lane, liveRuns, sessionId);
const text = promptWithImages(prompt, images); const text = promptWithImages(prompt, images);
@@ -1,7 +1,8 @@
/** /**
* @file SessionResumeButton.tsx * @file SessionResumeButton.tsx
* @description The transcript page's explicit Resume and Fork for a Claude * @description The transcript page's explicit Resume and Fork for a Claude
* session inside a workspace. Opening a session never resumes it; this is the * session inside a workspace (Resume only for a catalog tool that resumes by
* id — omp's `--resume <id>`). Opening a session never resumes it; this is the
* one place that does, behind a warning, because a resume re-reads the whole * one place that does, behind a warning, because a resume re-reads the whole
* transcript into context. Fork resumes it into a NEW session id * transcript into context. Fork resumes it into a NEW session id
* (`forkSession: true` → `claude --resume <id> --fork-session`), leaving the * (`forkSession: true` → `claude --resume <id> --fork-session`), leaving the
@@ -26,6 +27,7 @@ import { ConfirmModal } from "../ConfirmModal";
import { startInFreeTabDetailed } from "./startSession"; import { startInFreeTabDetailed } from "./startSession";
import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList"; import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList";
import { usePermissions } from "../AuthGate"; import { usePermissions } from "../AuthGate";
import { useAiTools } from "../../hooks/useAiTools";
/** A path without its trailing separators — `/` or a Windows `\`. */ /** A path without its trailing separators — `/` or a Windows `\`. */
const strip = (p: string) => p.replace(/[\\/]+$/, ""); const strip = (p: string) => p.replace(/[\\/]+$/, "");
@@ -54,6 +56,7 @@ export function SessionResumeButton({
const navigate = useNavigate(); const navigate = useNavigate();
const { lanes } = useLanes(); const { lanes } = useLanes();
const { canEditLane } = usePermissions(); const { canEditLane } = usePermissions();
const { byId } = useAiTools();
const [runs, setRuns] = useState<RunHandle[] | null>(null); const [runs, setRuns] = useState<RunHandle[] | null>(null);
const [confirming, setConfirming] = useState<"resume" | "fork" | null>(null); const [confirming, setConfirming] = useState<"resume" | "fork" | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -70,12 +73,13 @@ export function SessionResumeButton({
}; };
}, [session.id]); }, [session.id]);
// Claude only: a Codex session id is not what // Claude, or a catalog tool that resumes by id (no launcher of its own): a
// `codex resume` takes for every kind of thread. // Codex session id is not what `codex resume` takes for every kind of thread.
const tool = session.tool || "claude"; const tool = session.tool || "claude";
const catalogResume = !!byId(tool)?.resumeById && !byId(tool)?.launcher;
// The workspace that owns it, else the one whose folder holds it. // The workspace that owns it, else the one whose folder holds it.
const lane = const lane =
tool !== "claude" || !session.cwd (tool !== "claude" && !catalogResume) || !session.cwd
? null ? null
: (lanes.find((l) => l.id === session.lane_id && l.tool === tool) ?? : (lanes.find((l) => l.id === session.lane_id && l.tool === tool) ??
owningLane(session.cwd, tool, lanes)); owningLane(session.cwd, tool, lanes));
@@ -140,7 +144,7 @@ export function SessionResumeButton({
{t("sessions.resume")} {t("sessions.resume")}
</button> </button>
)} )}
{resumable && ( {resumable && tool === "claude" && (
<button <button
type="button" type="button"
onClick={() => setConfirming("fork")} onClick={() => setConfirming("fork")}
+3 -1
View File
@@ -4429,6 +4429,8 @@ export interface AiTool {
prompt: { mode: "argv" | "flag" | "after-start" } | null; prompt: { mode: "argv" | "flag" | "after-start" } | null;
/** Offers "Resume last session". */ /** Offers "Resume last session". */
resume: boolean; resume: boolean;
/** Can resume a NAMED session (Claude, Codex, a catalog `resumeById`). */
resumeById?: boolean;
/** Offers the per-run "Bypass permissions" switch. */ /** Offers the per-run "Bypass permissions" switch. */
bypass: boolean; bypass: boolean;
/** Can use an AI Proxy profile (Claude, Codex); others are native only. */ /** Can use an AI Proxy profile (Claude, Codex); others are native only. */
@@ -5212,7 +5214,7 @@ export interface RunStartArgs {
cwd?: string; cwd?: string;
model?: string; model?: string;
permissionMode?: PermissionMode; permissionMode?: PermissionMode;
/** Resume an existing Claude Code session id (`--resume`) instead of starting fresh. */ /** Resume an existing session id (Claude/Codex, or a catalog `resumeById` tool) instead of starting fresh. */
resumeSessionId?: string; resumeSessionId?: string;
effort?: EffortLevel; effort?: EffortLevel;
/** Sent as `claude`'s first positional message once the pane boots; omit /** Sent as `claude`'s first positional message once the pane boots; omit
+4 -1
View File
@@ -732,7 +732,10 @@ export interface Session {
/** The AI coding CLI that produced the session: `"claude"` (the default — /** The AI coding CLI that produced the session: `"claude"` (the default —
* Claude Code's hooks and imported transcripts), `"codex"` (Codex's rollout * Claude Code's hooks and imported transcripts), `"codex"` (Codex's rollout
* journals and hooks), `"antigravity"` (agy's conversation files and * journals and hooks), `"antigravity"` (agy's conversation files and
* hooks) or `"cursor"` (Cursor's agent transcripts, no tokens). Attach/resume offer `claude` rows only. Maps to `sessions.tool`. */ * hooks), `"cursor"` (Cursor's agent transcripts, no tokens), `"omp"`,
* `"opencode"` (their own session stores, with tokens and subagents) or
* `"grok"` (Grok running the Claude hooks, no tokens). Maps to
* `sessions.tool`. */
tool?: string; tool?: string;
/** The workspace (lane id) that owns the session — the one whose pane held /** The workspace (lane id) that owns the session — the one whose pane held
* it; null for a session no workspace started. * it; null for a session no workspace started.
+2
View File
@@ -259,6 +259,8 @@ CODEX_HOME= # where Codex keeps sessions/ (default ~
DASHBOARD_ANTIGRAVITY_POLL_MS=15000 # agy conversation files → Antigravity sessions/tokens (60 s while idle) DASHBOARD_ANTIGRAVITY_POLL_MS=15000 # agy conversation files → Antigravity sessions/tokens (60 s while idle)
AGENTHUB_GEMINI_DIR= # where agy keeps its data (default ~/.gemini) AGENTHUB_GEMINI_DIR= # where agy keeps its data (default ~/.gemini)
DASHBOARD_CURSOR_POLL_MS=15000 # Cursor agent transcripts → Cursor sessions (no tokens; 60 s while idle) DASHBOARD_CURSOR_POLL_MS=15000 # Cursor agent transcripts → Cursor sessions (no tokens; 60 s while idle)
DASHBOARD_OMP_POLL_MS=15000 # omp session journals → Oh My Pi sessions/tokens/subagents (60 s while idle)
DASHBOARD_OPENCODE_POLL_MS=15000 # opencode.db → OpenCode sessions/tokens/subagents (60 s while idle)
AGENTHUB_CURSOR_DIR= # where Cursor keeps its data (default ~/.cursor) AGENTHUB_CURSOR_DIR= # where Cursor keeps its data (default ~/.cursor)
AGENTHUB_AGY_USAGE= # 0 never runs `agy --print /usage` for the Usage panel's Antigravity quota (default on; running agy may update it) AGENTHUB_AGY_USAGE= # 0 never runs `agy --print /usage` for the Usage panel's Antigravity quota (default on; running agy may update it)
+1 -1
View File
@@ -1830,7 +1830,7 @@ agenthub stage <stage> [--evidence "..."] # Report the current stage
## Workspaces on any AI tool ## Workspaces on any AI tool
A workspace can run any AI coding CLI in the AI Tools catalog (`server/data/ai-tools/*.json` — nine built in: Claude Code, Codex, Antigravity, GitHub Copilot CLI, OpenCode, Cursor CLI (`cursor-agent`), Grok Build (`grok`), Kimi Code (`kimi`) and Oh My Pi —, plus any `*.json` in `DASHBOARD_AI_TOOLS_DIR`, which replaces a built-in file with the same `id` but may not set `launcher`). Settings → Agents → **AI tools** (`/settings/agents`; the old `/settings#ai-tools` redirects there) lists what is installed (found on PATH; **Refresh** re-reads the login shell's PATH first) with an enable switch, **Set default** and **Update** (for every tool whose catalog file names an updater — no version lookup, the updater itself reports "up to date"), and, folded behind a disclosure, what is available to install with a homepage link. Each installed, enabled tool's own settings (`/settings/agents/<id>`) are tabs on that page — **All tools** · Claude Code · Codex · … — rather than items in the left nav, and a click on a tool's row (or its name) opens its tab. The Add-workspace modal shows the chosen tool as one button (the default until changed); clicking it lists every installed, enabled tool as buttons to pick from; `agenthub workspace add --tool <id>` does the same from a terminal. The run form's **Model** picker lists the workspace tool's own models (`GET /api/ai-tools/:id/models`; Claude, Codex, and any catalog tool whose file declares `model: { flag }` — the id is validated and passed as `<flag> <id>`), plus **Custom…**, and starts on the tool's default model when one is set. A workspace can run any AI coding CLI in the AI Tools catalog (`server/data/ai-tools/*.json` — seven built in: Claude Code, Codex, Antigravity, OpenCode, Cursor CLI (`cursor-agent`), Grok Build (`grok`) and Oh My Pi —, plus any `*.json` in `DASHBOARD_AI_TOOLS_DIR`, which replaces a built-in file with the same `id` but may not set `launcher`). Settings → Agents → **AI tools** (`/settings/agents`; the old `/settings#ai-tools` redirects there) lists what is installed (found on PATH; **Refresh** re-reads the login shell's PATH first) with an enable switch, **Set default** and **Update** (for every tool whose catalog file names an updater — no version lookup, the updater itself reports "up to date"), and, folded behind a disclosure, what is available to install with a homepage link. Each installed, enabled tool's own settings (`/settings/agents/<id>`) are tabs on that page — **All tools** · Claude Code · Codex · … — rather than items in the left nav, and a click on a tool's row (or its name) opens its tab. The Add-workspace modal shows the chosen tool as one button (the default until changed); clicking it lists every installed, enabled tool as buttons to pick from; `agenthub workspace add --tool <id>` does the same from a terminal. The run form's **Model** picker lists the workspace tool's own models (`GET /api/ai-tools/:id/models`; Claude, Codex, and any catalog tool whose file declares `model: { flag }` — the id is validated and passed as `<flag> <id>`), plus **Custom…**, and starts on the tool's default model when one is set.
A catalog file: A catalog file:
+16
View File
@@ -302,6 +302,22 @@ default 15000) and appear in Sessions with a **Cursor CLI** badge. Cursor writes
no token counts or model ids there, so a Cursor session shows no tokens and no no token counts or model ids there, so a Cursor session shows no tokens and no
cost. cost.
**Oh My Pi (`omp`)** sessions are read from omp's session journals
(`~/.omp/agent/sessions/*/<timestamp>_<id>.jsonl`, every `DASHBOARD_OMP_POLL_MS`
ms, default 15000) — each member's own `~/.omp` too on a multi-user server — and
appear in Sessions with an **Oh My Pi (OMP)** badge, with their tokens and cost
per model and one agent per subagent the `task` tool spawned (its journal sits in
the folder named like the session's). An omp session can be resumed by id from
its workspace (`omp --resume <id>`), like a Claude or Codex one.
**OpenCode** sessions are read from its SQLite store
(`~/.local/share/opencode/opencode.db`, every `DASHBOARD_OPENCODE_POLL_MS` ms),
with their tokens and cost per model and one agent per subagent, and are
resumable by id (`opencode --session <id>`). **Grok Build** runs the Claude hooks in
`~/.claude/settings.json` itself, so its sessions arrive through AgentHub's own
hooks, recorded as Grok sessions (live status, no tokens); they resume with
`grok --resume <id>`. Cursor sessions resume by id too (`--resume <id>`).
**Pipelines are dormant.** AgentHub ships no pipeline template, so a workspace has no **Pipelines are dormant.** AgentHub ships no pipeline template, so a workspace has no
stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see
[`docs/LANES.md`](LANES.md#custom-pipeline-templates)); until then [`docs/LANES.md`](LANES.md#custom-pipeline-templates)); until then
+27
View File
@@ -638,6 +638,33 @@ An `antigravity` workspace carries no pipeline either.
`DASHBOARD_CURSOR_POLL_MS` ms (default 15000, `0` disables) into `sessions` `DASHBOARD_CURSOR_POLL_MS` ms (default 15000, `0` disables) into `sessions`
rows (`tool = 'cursor'`) — no `token_usage`, the transcripts carry none — 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.
- **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
(default 15000, `0` disables), each from the offset the last pass stopped at,
into `sessions` (`tool = 'omp'`), `token_usage` (the session's own and its
subagents' turns, per model) and `agents` (one `omp-<session>-<key>` subagent
row per subagent journal). `omp_sessions` holds each journal's offset and
what it said so far; `GET /api/sessions/:id/transcript(s)` reads an omp
session's journals from there. Under S3 each member's `~/.omp` is read only
through `openSafeAsync`.
- **OpenCode sessions:** `server/lib/opencode-usage.js` reads OpenCode's
SQLite store (`~/.local/share/opencode/opencode.db`, under `XDG_DATA_HOME`
when set) every `DASHBOARD_OPENCODE_POLL_MS` ms (default 15000, `0`
disables), when its file or WAL changed: each root `session` row becomes a
`sessions` row (`tool = 'opencode'`), its tree's assistant `message` tokens
per `modelID` become `token_usage` (reasoning counted as output), and each
child session (the `task` tool's) an `opencode-<id>` subagent row.
`opencode_stores` holds each store's mtime and `opencode_sessions` each root
session's newest `time_updated`; the transcript routes read the conversation
(messages + parts) from the store itself. A member's store and WAL are copied
through the guard before they are opened.
- **Grok sessions:** Grok runs the hooks in `~/.claude/settings.json` (its
Claude compatibility), so AgentHub's own hook reaches `POST /api/hooks/event`
with no `tool`; `routes/hooks.js` tells it apart by the camelCase keys only
Grok sends (`hookEventName` + `workspaceRoot`), records it as `tool = 'grok'`
and drops its `transcript_path` (Grok's file, never parsed as a Claude
transcript). No tokens: Grok's session files are not read.
- **Quiet sessions:** agy fires no exit hook, so a session quiet for ten - **Quiet sessions:** agy fires no exit hook, so a session quiet for ten
minutes (with hook events) or two (without) is settled to `completed`, its minutes (with hook events) or two (without) is settled to `completed`, its
agents too, and sets its workspace idle. That workspace must be one the hooks left agents too, and sets its workspace idle. That workspace must be one the hooks left
+5 -5
View File
@@ -190,16 +190,16 @@ describe("install / update / icons", () => {
it("starts an install job and reports it", async () => { it("starts an install job and reports it", async () => {
jobs.__reset(); jobs.__reset();
assert.equal((await request("GET", "/api/ai-tools/job")).body, null); assert.equal((await request("GET", "/api/ai-tools/job")).body, null);
const r = await request("POST", "/api/ai-tools/copilot/install"); const r = await request("POST", "/api/ai-tools/codex/install");
assert.equal(r.status, 202); assert.equal(r.status, 202);
assert.equal(r.body.job.tool, "copilot"); assert.equal(r.body.job.tool, "codex");
const done = await settle(); const done = await settle();
assert.equal(done.status, "ok"); assert.equal(done.status, "ok");
assert.deepEqual(spawned.at(-1), [ assert.deepEqual(spawned.at(-1), [
path.join(BIN, "npm"), path.join(BIN, "npm"),
"install", "install",
"-g", "-g",
"@github/copilot@latest", "@openai/codex@latest",
]); ]);
}); });
@@ -216,7 +216,7 @@ describe("install / update / icons", () => {
const notNpm = await request("POST", "/api/ai-tools/opencode/update"); const notNpm = await request("POST", "/api/ai-tools/opencode/update");
assert.equal(notNpm.status, 409); assert.equal(notNpm.status, 409);
assert.equal(notNpm.body.error.code, "ENOTNPM"); assert.equal(notNpm.body.error.code, "ENOTNPM");
const cross = await request("POST", "/api/ai-tools/copilot/install", undefined, { const cross = await request("POST", "/api/ai-tools/codex/install", undefined, {
Origin: "https://evil.test", Origin: "https://evil.test",
}); });
assert.equal(cross.status, 403); assert.equal(cross.status, 403);
@@ -225,7 +225,7 @@ describe("install / update / icons", () => {
it("answers EBUSY while a job runs", async () => { it("answers EBUSY while a job runs", async () => {
jobs.__reset({ timeoutMs: 50 }); jobs.__reset({ timeoutMs: 50 });
hang = true; hang = true;
assert.equal((await request("POST", "/api/ai-tools/copilot/install")).status, 202); assert.equal((await request("POST", "/api/ai-tools/codex/install")).status, 202);
const busy = await request("POST", "/api/ai-tools/amp/install"); const busy = await request("POST", "/api/ai-tools/amp/install");
assert.equal(busy.status, 409); assert.equal(busy.status, 409);
assert.equal(busy.body.error.code, "EBUSY"); assert.equal(busy.body.error.code, "EBUSY");
+1
View File
@@ -194,6 +194,7 @@ describe("enable / default", () => {
launcher: null, launcher: null,
prompt: { mode: "flag" }, prompt: { mode: "flag" },
resume: false, resume: false,
resumeById: false,
bypass: false, bypass: false,
proxyCapable: false, proxyCapable: false,
modelFlag: false, modelFlag: false,
+88
View File
@@ -0,0 +1,88 @@
/**
* @file Tests POST /api/hooks/event for a hook Grok Build fired through the
* Claude hooks in `~/.claude/settings.json` (Grok's Claude compatibility): it
* arrives with no `tool`, is told apart by the camelCase keys only Grok sends
* (`hookEventName`, `workspaceRoot`), and lands as a `tool = 'grok'` session
* on the grok workspace — never the claude one sharing the directory — with
* its `transcript_path` dropped. A Claude hook is left Claude's. The DB lives
* in a temp dir.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const os = require("node:os");
const fs = require("node:fs");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-grok-hook-route-"));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const lanes = require("../lib/lanes");
let server;
let BASE;
const CWD = path.join(fs.realpathSync(ROOT), "repo");
fs.mkdirSync(CWD, { recursive: true });
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
server.close();
fs.rmSync(ROOT, { recursive: true, force: true });
});
const post = (body) =>
fetch(`${BASE}/api/hooks/event`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}).then((r) => r.json());
const grokData = (id, extra = {}) => ({
hookEventName: "session_start",
hook_event_name: "SessionStart",
sessionId: id,
session_id: id,
cwd: CWD,
workspaceRoot: CWD,
transcript_path: path.join(ROOT, "grok", "sessions", id, "chat_history.jsonl"),
permissionMode: "default",
...extra,
});
describe("Grok hooks through the Claude settings", () => {
it("records a grok session, on the grok workspace, with no transcript path", async () => {
const claudeLane = lanes.createLane({ title: "c", cwd: CWD, tool: "claude" });
const grokLane = lanes.createLane({ title: "g", cwd: CWD, tool: "grok" });
const id = "0199f0aa-1111-7222-8333-444455556666";
await post({ hook_type: "SessionStart", data: grokData(id) });
await post({
hook_type: "UserPromptSubmit",
data: grokData(id, { hookEventName: "user_prompt_submit", prompt: "fix the bug" }),
});
const s = db.prepare("SELECT * FROM sessions WHERE id = ?").get(id);
assert.equal(s.tool, "grok");
assert.equal(s.transcript_path, null);
const events = db.prepare("SELECT data FROM events WHERE session_id = ?").all(id);
assert.ok(events.length >= 1);
for (const e of events) assert.equal(JSON.parse(e.data).transcript_path, undefined);
assert.equal(lanes.getLane(claudeLane.id).session_id, null);
assert.equal(lanes.getLane(grokLane.id).session_id, id);
});
it("leaves a Claude hook Claude's", async () => {
const id = "0199f0aa-1111-7222-8333-777788889999";
await post({
hook_type: "SessionStart",
data: { hook_event_name: "SessionStart", session_id: id, cwd: CWD },
});
assert.equal(db.prepare("SELECT tool FROM sessions WHERE id = ?").get(id).tool, "claude");
});
});
+233
View File
@@ -0,0 +1,233 @@
/**
* @file Tests server/lib/omp-usage.js and the omp branch of the transcript
* routes — Oh My Pi sessions from omp's session journals: a session created
* as `tool = 'omp'` with its title, cwd, model and per-model tokens (its
* subagents' included), a subagent row per subagent journal, a journal read on
* from where it stopped, `session_exit` settling it to completed, a session
* the user deleted staying deleted, a non-omp row with the same id never
* touched, and the journal mapped to the Claude transcript shape. The omp dir
* and the DB are temp dirs; the journals are synthetic, in the shape omp
* 18.12 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-omp-usage-"));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
process.env.AGENTHUB_OMP_DIR = path.join(ROOT, "omp");
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const { db } = require("../db");
const omp = require("../lib/omp-usage");
const { createApp, startServer } = require("../index");
const ID = "01a15366-27e2-734a-9247-04d5c65c1636";
const STAMP = "2026-10-19T09-02-46-242Z";
const CWD = path.join(fs.realpathSync(ROOT), "work");
fs.mkdirSync(CWD, { recursive: true });
const DIR = path.join(process.env.AGENTHUB_OMP_DIR, "agent", "sessions", "-work");
const MAIN = path.join(DIR, `${STAMP}_${ID}.jsonl`);
const SUB = path.join(DIR, `${STAMP}_${ID}`, "Pinger.jsonl");
let server;
let BASE;
after(() => {
server?.close();
fs.rmSync(ROOT, { recursive: true, force: true });
});
const line = (o) => JSON.stringify(o) + "\n";
const title = (t) =>
line({ type: "title", v: 1, title: t, updatedAt: "2026-10-19T09:02:46.242Z", pad: " ".repeat(40) });
const header = (id = ID) =>
line({ type: "session", version: 3, id, timestamp: "2026-10-19T09:02:46.242Z", cwd: CWD });
const user = (text, attribution = "user") =>
line({
type: "message",
id: "u1",
timestamp: "2026-10-19T09:02:46.400Z",
message: { role: "user", content: [{ type: "text", text }], attribution },
});
const assistant = (content, usage, model = "claude-sonnet-4-5") =>
line({
type: "message",
id: "a1",
timestamp: "2026-10-19T09:02:47.000Z",
message: { role: "assistant", content, provider: "anthropic", model, usage },
});
const usage = (input, output, cacheRead = 0, cacheWrite = 0) => ({
input,
output,
cacheRead,
cacheWrite,
totalTokens: input + output + cacheRead + cacheWrite,
});
const exit = line({ type: "custom", customType: "session_exit", data: { reason: "dispose" } });
const sessionRow = () => db.prepare("SELECT * FROM sessions WHERE id = ?").get(ID);
const tokens = () =>
db
.prepare(
"SELECT model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens FROM token_usage WHERE session_id = ? ORDER BY model"
)
.all(ID);
before(async () => {
fs.mkdirSync(path.dirname(SUB), { recursive: true });
fs.writeFileSync(
MAIN,
title("") +
header() +
line({ type: "model_change", model: "anthropic/claude-sonnet-4-5" }) +
user("Fix the login bug\nin auth.ts") +
assistant(
[
{ type: "thinking", thinking: "Look first" },
{ type: "toolCall", id: "t1", name: "task", arguments: { tasks: [{ task: "ping" }] } },
],
usage(100, 20, 1000, 50)
) +
line({
type: "message",
timestamp: "2026-10-19T09:02:48.000Z",
message: {
role: "toolResult",
toolCallId: "t1",
toolName: "task",
content: [{ type: "text", text: "Spawned agent `Pinger`." }],
},
})
);
fs.writeFileSync(
SUB,
header("01a15366-0000-7000-8000-000000000001") +
user("Complete assignment: ping", "agent") +
assistant([{ type: "text", text: "pong" }], usage(10, 5), "claude-haiku-4-5")
);
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
describe("omp usage ingest", () => {
it("creates the session with its prompt, cwd, model and every journal's tokens", async () => {
const r = await omp.ingestOmpUsage();
assert.equal(r.files, 2);
const s = sessionRow();
assert.equal(s.tool, "omp");
assert.equal(s.name, "Fix the login bug");
assert.equal(s.cwd, CWD);
assert.equal(s.model, "claude-sonnet-4-5");
assert.equal(s.status, "active"); // written just now, no session_exit
assert.deepEqual(tokens(), [
{
model: "claude-haiku-4-5",
input_tokens: 10,
output_tokens: 5,
cache_read_tokens: 0,
cache_write_tokens: 0,
},
{
model: "claude-sonnet-4-5",
input_tokens: 100,
output_tokens: 20,
cache_read_tokens: 1000,
cache_write_tokens: 50,
},
]);
const agents = db.prepare("SELECT * FROM agents WHERE session_id = ?").all(ID);
assert.equal(agents.length, 1);
assert.equal(agents[0].id, `omp-${ID}-Pinger`);
assert.equal(agents[0].name, "Pinger");
assert.equal(agents[0].type, "subagent");
assert.equal(agents[0].task, "Complete assignment: ping");
});
it("leaves an unchanged journal unread", async () => {
const r = await omp.ingestOmpUsage();
assert.equal(r.files, 0);
});
it("reads only what was appended, takes the title, and settles on session_exit", async () => {
fs.appendFileSync(MAIN, assistant([{ type: "text", text: "Done" }], usage(5, 7)) + exit);
// The title slot is rewritten in place, same width.
const text = fs.readFileSync(MAIN, "utf8").split("\n");
const slot = { type: "title", v: 1, title: "Login fix", updatedAt: "x", pad: "" };
slot.pad = " ".repeat(Math.max(0, text[0].length - JSON.stringify(slot).length));
text[0] = JSON.stringify(slot);
fs.writeFileSync(MAIN, text.join("\n"));
const r = await omp.ingestOmpUsage();
assert.equal(r.files, 1);
const s = sessionRow();
assert.equal(s.name, "Login fix");
assert.equal(s.status, "completed");
assert.ok(s.ended_at);
const sonnet = tokens().find((t) => t.model === "claude-sonnet-4-5");
assert.equal(sonnet.input_tokens, 105);
assert.equal(sonnet.output_tokens, 27);
const agent = db.prepare("SELECT status FROM agents WHERE id = ?").get(`omp-${ID}-Pinger`);
assert.equal(agent.status, "completed");
});
it("maps the journal to the transcript shape, and lists the subagent's", async () => {
const list = await (await fetch(`${BASE}/api/sessions/${ID}/transcripts`)).json();
assert.deepEqual(
list.transcripts.map((t) => [t.id, t.type, t.db_agent_id]),
[
["main", "main", null],
["Pinger", "subagent", `omp-${ID}-Pinger`],
]
);
const body = await (await fetch(`${BASE}/api/sessions/${ID}/transcript`)).json();
assert.deepEqual(
body.messages.map((m) => [m.type, m.sender, m.content.map((c) => c.type).join("+")]),
[
["user", "user", "text"],
["assistant", "assistant", "thinking+tool_use"],
["user", "tool", "tool_result"],
["assistant", "assistant", "text"],
]
);
assert.deepEqual(body.messages[1].content[1], {
type: "tool_use",
name: "task",
id: "t1",
input: { tasks: [{ task: "ping" }] },
});
const sub = await (
await fetch(`${BASE}/api/sessions/${ID}/transcript?agent_id=Pinger`)
).json();
assert.deepEqual(
sub.messages.map((m) => [m.sender, m.content[0].text]),
[
["orchestrator", "Complete assignment: ping"],
["assistant", "pong"],
]
);
});
it("keeps a deleted session deleted until omp writes again", async () => {
db.prepare("DELETE FROM sessions WHERE id = ?").run(ID);
await omp.ingestOmpUsage();
assert.equal(sessionRow(), undefined);
fs.appendFileSync(MAIN, user("one more"));
await omp.ingestOmpUsage();
assert.equal(sessionRow()?.tool, "omp");
});
it("never touches another tool's session with the same id", async () => {
const other = "01a15366-27e2-734a-9247-04d5c65c9999";
db.prepare("INSERT INTO sessions (id, name, status, tool) VALUES (?, 'mine', 'completed', 'codex')").run(
other
);
fs.writeFileSync(path.join(DIR, `${STAMP}_${other}.jsonl`), title("") + header(other) + user("x"));
await omp.ingestOmpUsage();
const s = db.prepare("SELECT * FROM sessions WHERE id = ?").get(other);
assert.equal(s.tool, "codex");
assert.equal(s.name, "mine");
});
});
+188
View File
@@ -0,0 +1,188 @@
/**
* @file Tests server/lib/opencode-usage.js and the OpenCode branch of the
* transcript routes — OpenCode sessions from its SQLite store: a root session
* created as `tool = 'opencode'` with its title, cwd, model and per-model
* tokens (its subagent session's included, reasoning counted as output), a
* subagent row per child session, an unchanged store never re-read, a
* session the user deleted staying deleted until it is updated, and the
* conversation (text, reasoning, tool call + result) in the Claude
* transcript shape. The store is a temp file with the tables and columns
* opencode 1.19 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-opencode-usage-"));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
process.env.AGENTHUB_OPENCODE_DIR = path.join(ROOT, "opencode");
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const { db, Database } = require("../db");
const oc = require("../lib/opencode-usage");
const { createApp, startServer } = require("../index");
const ID = "ses_eac8393b4ffeEz26VGbeyq8sir";
const CHILD = "ses_eac8393b4fffChildSession01";
const CWD = path.join(fs.realpathSync(ROOT), "work");
const STORE = path.join(process.env.AGENTHUB_OPENCODE_DIR, "opencode.db");
const T0 = Date.parse("2026-10-19T09:27:05.000Z");
let server;
let BASE;
after(() => {
server?.close();
fs.rmSync(ROOT, { recursive: true, force: true });
});
function withStore(fn) {
const conn = new Database(STORE);
try {
fn(conn);
} finally {
conn.close();
}
}
const assistant = (model, tokens) => JSON.stringify({ role: "assistant", modelID: model, tokens });
const tokens = (input, output, reasoning = 0, read = 0, write = 0) => ({
input,
output,
reasoning,
cache: { read, write },
});
before(async () => {
fs.mkdirSync(path.dirname(STORE), { recursive: true });
withStore((c) => {
c.exec(`
CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT,
slug TEXT NOT NULL, directory TEXT NOT NULL, title TEXT NOT NULL, version TEXT NOT NULL,
model TEXT, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, time_archived INTEGER);
CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL, data TEXT NOT NULL);
CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT NOT NULL, session_id TEXT NOT NULL,
time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, data TEXT NOT NULL);
`);
const s = c.prepare(
"INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, model, time_created, time_updated) VALUES (?, 'global', ?, 'x', ?, ?, '1.19.3', ?, ?, ?)"
);
s.run(ID, null, CWD, "Fix the login bug", '{"id":"claude-sonnet-4-5","providerID":"anthropic"}', T0, T0 + 5000);
s.run(CHILD, ID, CWD, "Explore auth (@explore subagent)", null, T0 + 1000, T0 + 3000);
const m = c.prepare("INSERT INTO message VALUES (?, ?, ?, ?, ?)");
m.run("msg_1", ID, T0 + 10, T0 + 10, JSON.stringify({ role: "user" }));
m.run("msg_2", ID, T0 + 20, T0 + 20, assistant("claude-sonnet-4-5", tokens(100, 20, 5, 1000, 50)));
m.run("msg_3", CHILD, T0 + 1100, T0 + 1100, JSON.stringify({ role: "user" }));
m.run("msg_4", CHILD, T0 + 1200, T0 + 1200, assistant("claude-haiku-4-5", tokens(10, 4)));
const p = c.prepare("INSERT INTO part VALUES (?, ?, ?, ?, ?, ?)");
p.run("prt_1", "msg_1", ID, T0 + 11, T0 + 11, JSON.stringify({ type: "text", text: "Fix the login bug" }));
p.run("prt_2", "msg_2", ID, T0 + 21, T0 + 21, JSON.stringify({ type: "reasoning", text: "Look first" }));
p.run(
"prt_3",
"msg_2",
ID,
T0 + 22,
T0 + 22,
JSON.stringify({
type: "tool",
tool: "read",
callID: "call_1",
state: { status: "completed", input: { filePath: "auth.ts" }, output: "export {}" },
})
);
p.run("prt_4", "msg_2", ID, T0 + 23, T0 + 23, JSON.stringify({ type: "step-start" }));
p.run("prt_5", "msg_3", CHILD, T0 + 1101, T0 + 1101, JSON.stringify({ type: "text", text: "Explore auth" }));
p.run("prt_6", "msg_4", CHILD, T0 + 1201, T0 + 1201, JSON.stringify({ type: "text", text: "Found it" }));
});
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
const sessionRow = () => db.prepare("SELECT * FROM sessions WHERE id = ?").get(ID);
describe("opencode usage ingest", () => {
it("creates the root session with its title, cwd, model and its tree's tokens", async () => {
const r = await oc.ingestOpencodeUsage({ now: T0 + 600_000 });
assert.equal(r.files, 1);
const s = sessionRow();
assert.equal(s.tool, "opencode");
assert.equal(s.name, "Fix the login bug");
assert.equal(s.cwd, CWD);
assert.equal(s.model, "claude-sonnet-4-5");
assert.equal(s.status, "completed");
assert.equal(db.prepare("SELECT 1 FROM sessions WHERE id = ?").get(CHILD), undefined);
const usage = db
.prepare(
"SELECT model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens FROM token_usage WHERE session_id = ? ORDER BY model"
)
.all(ID);
assert.deepEqual(usage, [
{ model: "claude-haiku-4-5", input_tokens: 10, output_tokens: 4, cache_read_tokens: 0, cache_write_tokens: 0 },
{ model: "claude-sonnet-4-5", input_tokens: 100, output_tokens: 25, cache_read_tokens: 1000, cache_write_tokens: 50 },
]);
const agent = db.prepare("SELECT * FROM agents WHERE id = ?").get(`opencode-${CHILD}`);
assert.equal(agent.session_id, ID);
assert.equal(agent.type, "subagent");
assert.equal(agent.name, "Explore auth (@explore subagent)");
assert.equal(agent.status, "completed");
});
it("leaves an unchanged store unread", async () => {
const r = await oc.ingestOpencodeUsage({ now: T0 + 600_000 });
assert.equal(r.files, 0);
});
it("maps the store to the transcript shape, and lists the subagent's", async () => {
const list = await (await fetch(`${BASE}/api/sessions/${ID}/transcripts`)).json();
assert.deepEqual(
list.transcripts.map((t) => [t.id, t.type, t.db_agent_id]),
[
["main", "main", null],
[CHILD, "subagent", `opencode-${CHILD}`],
]
);
const body = await (await fetch(`${BASE}/api/sessions/${ID}/transcript`)).json();
assert.deepEqual(
body.messages.map((m) => [m.type, m.sender, m.content.map((c) => c.type).join("+")]),
[
["user", "user", "text"],
["assistant", "assistant", "thinking+tool_use"],
["user", "tool", "tool_result"],
]
);
assert.equal(body.messages[2].content[0].output, "export {}");
assert.equal(body.last_line, 3);
const tail = await (await fetch(`${BASE}/api/sessions/${ID}/transcript?after=2`)).json();
assert.equal(tail.messages.length, 1);
const sub = await (
await fetch(`${BASE}/api/sessions/${ID}/transcript?agent_id=${CHILD}`)
).json();
assert.deepEqual(
sub.messages.map((m) => [m.sender, m.content[0].text]),
[
["orchestrator", "Explore auth"],
["assistant", "Found it"],
]
);
});
it("keeps a deleted session deleted until OpenCode updates it", async () => {
db.prepare("DELETE FROM sessions WHERE id = ?").run(ID);
const bump = (t) => {
withStore((c) => c.prepare("UPDATE session SET title = title WHERE id = ?").run(ID));
const at = new Date(t);
fs.utimesSync(STORE, at, at);
};
bump(Date.now() + 5000);
await oc.ingestOpencodeUsage({ now: T0 + 600_000 });
assert.equal(sessionRow(), undefined);
withStore((c) => c.prepare("UPDATE session SET time_updated = ? WHERE id = ?").run(T0 + 9000, ID));
fs.utimesSync(STORE, new Date(Date.now() + 10_000), new Date(Date.now() + 10_000));
await oc.ingestOpencodeUsage({ now: T0 + 600_000 });
assert.equal(sessionRow()?.tool, "opencode");
});
});
+30 -1
View File
@@ -733,7 +733,7 @@ describe("pty-run", () => {
let savedPath; let savedPath;
before(() => { before(() => {
fs.mkdirSync(BIN, { recursive: true }); fs.mkdirSync(BIN, { recursive: true });
for (const name of ["agy", "grok", "goose", "command-code"]) { for (const name of ["agy", "grok", "goose", "command-code", "omp"]) {
fs.writeFileSync(path.join(BIN, name), "#!/bin/sh\n"); fs.writeFileSync(path.join(BIN, name), "#!/bin/sh\n");
fs.chmodSync(path.join(BIN, name), 0o755); fs.chmodSync(path.join(BIN, name), 0o755);
} }
@@ -799,6 +799,35 @@ describe("pty-run", () => {
); );
}); });
// omp declares `resumeById`: the id follows its flag as one argv element,
// in place of the resume-last args, and never one that reads as a flag.
it("resumes a named session for a catalog tool that declares resumeById", () => {
const call = captureNewSession(() =>
pty.spawnRun({
laneId: 17,
cwd: "/tmp/repo",
tool: "omp",
model: "opus",
resumeSessionId: "01a15366-27e2-734a-9247-04d5c65c1636",
resumeLast: true,
initialPrompt: "go on",
})
);
assert.deepEqual(paneArgv(call), [
at("omp"),
"--model",
"opus",
"--resume",
"01a15366-27e2-734a-9247-04d5c65c1636",
"go on",
]);
assert.throws(
() =>
pty.spawnRun({ laneId: 18, cwd: "/tmp/repo", tool: "omp", resumeSessionId: "--abcdefgh" }),
{ code: "EBADSESSION" }
);
});
it("refuses a flag-shaped model id for any tool, before tmux is asked", () => { it("refuses a flag-shaped model id for any tool, before tmux is asked", () => {
for (const tool of ["claude", "codex", "antigravity"]) { for (const tool of ["claude", "codex", "antigravity"]) {
assert.throws( assert.throws(
+9
View File
@@ -6,6 +6,15 @@ One JSON file per AI coding CLI AgentHub can run in a lane. Loaded by
`DASHBOARD_AI_TOOLS_DIR` with the same `id` replaces the built-in one (it may `DASHBOARD_AI_TOOLS_DIR` with the same `id` replaces the built-in one (it may
not set `launcher`). not set `launcher`).
## Resume by id
`resumeById` (string array, e.g. `["--resume"]`): the flag(s) a NAMED session's
id follows, as one more argv element — `pty-run.js` emits them in place of
`resume` when a start carries `resumeSessionId`, and only for an id that starts
with a letter or digit. A tool without it (and without a `launcher`) refuses a
resume by id (`EUNSUPPORTED`) rather than start fresh under a "resume".
Built-in: omp, Grok and Cursor (`--resume`), OpenCode (`--session`). A session id may carry `_` (OpenCode's `ses_…`).
## Install / update (A2) ## Install / update (A2)
Two optional fields, honoured only in built-in files and in the copy of the Two optional fields, honoured only in built-in files and in the copy of the
-22
View File
@@ -1,22 +0,0 @@
{
"id": "copilot",
"label": "GitHub Copilot",
"detect": [
"copilot"
],
"prompt": {
"mode": "flag",
"flag": "-i"
},
"bypass": [
"--yolo"
],
"homepageUrl": "https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli",
"install": {
"kind": "npm",
"package": "@github/copilot"
},
"update": {
"kind": "npm"
}
}
+3
View File
@@ -13,6 +13,9 @@
"resume": [ "resume": [
"--continue" "--continue"
], ],
"resumeById": [
"--resume"
],
"bypass": [ "bypass": [
"--yolo" "--yolo"
], ],
+3
View File
@@ -14,6 +14,9 @@
"resume": [ "resume": [
"--continue" "--continue"
], ],
"resumeById": [
"--resume"
],
"bypass": [ "bypass": [
"--permission-mode", "--permission-mode",
"bypassPermissions" "bypassPermissions"
-40
View File
@@ -1,40 +0,0 @@
{
"id": "kimi",
"label": "Kimi Code",
"detect": [
"kimi"
],
"prompt": {
"mode": "after-start"
},
"model": {
"flag": "--model"
},
"resume": [
"--continue"
],
"bypass": [
"--auto"
],
"homepageUrl": "https://www.kimi.com/code/docs/",
"faviconDomain": "kimi.com",
"install": {
"kind": "script",
"url": "https://code.kimi.com/kimi-code/install.sh"
},
"update": {
"kind": "self",
"args": [
"upgrade",
"-y"
]
},
"config": [
{
"path": "~/.kimi-code/config.toml"
},
{
"path": "~/.kimi-code/tui.toml"
}
]
}
+24 -1
View File
@@ -7,12 +7,35 @@
"prompt": { "prompt": {
"mode": "argv" "mode": "argv"
}, },
"model": {
"flag": "--model"
},
"resume": [
"--continue"
],
"resumeById": [
"--resume"
],
"bypass": [
"--auto-approve"
],
"homepageUrl": "https://omp.sh", "homepageUrl": "https://omp.sh",
"faviconDomain": "omp.sh",
"install": { "install": {
"kind": "script", "kind": "script",
"url": "https://omp.sh/install" "url": "https://omp.sh/install"
}, },
"update": { "update": {
"kind": "script" "kind": "script"
} },
"config": [
{
"path": "~/.omp/agent/config.yml",
"format": "text"
},
{
"path": "~/.omp/agent/models.yml",
"format": "text"
}
]
} }
+6
View File
@@ -11,6 +11,12 @@
"model": { "model": {
"flag": "--model" "flag": "--model"
}, },
"resume": [
"--continue"
],
"resumeById": [
"--session"
],
"homepageUrl": "https://opencode.ai/docs/cli/", "homepageUrl": "https://opencode.ai/docs/cli/",
"install": { "install": {
"kind": "npm", "kind": "npm",
+35
View File
@@ -1429,6 +1429,41 @@ db.exec(`
); );
`); `);
// Which Oh My Pi session journals have been read, how far, and what they said
// so far (server/lib/omp-usage.js) — journals only grow, so each is read on
// from `offset`; the same "deleted stays deleted" rule.
db.exec(`
CREATE TABLE IF NOT EXISTS omp_sessions (
path TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
agent TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL DEFAULT 0,
mtime_ms INTEGER NOT NULL DEFAULT 0,
offset INTEGER NOT NULL DEFAULT 0,
state TEXT NOT NULL DEFAULT '{}',
title TEXT,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_omp_sessions_session ON omp_sessions(session_id);
`);
// Which OpenCode stores have been read at what mtime, and per root session the
// newest `time_updated` written (server/lib/opencode-usage.js) — the same
// "deleted stays deleted" rule.
db.exec(`
CREATE TABLE IF NOT EXISTS opencode_stores (
path TEXT PRIMARY KEY,
mtime_ms INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE IF NOT EXISTS opencode_sessions (
session_id TEXT PRIMARY KEY,
store TEXT NOT NULL,
time_updated INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
`);
// Which Cursor agent transcripts have been read, at what mtime // Which Cursor agent transcripts have been read, at what mtime
// (server/lib/cursor-usage.js) — the same "deleted stays deleted" rule. // (server/lib/cursor-usage.js) — the same "deleted stays deleted" rule.
db.exec(` db.exec(`
+12
View File
@@ -766,6 +766,18 @@ function startBackgroundServices() {
} catch (err) { } catch (err) {
console.warn("cursor usage poll failed to start:", err.message); console.warn("cursor usage poll failed to start:", err.message);
} }
// Oh My Pi and OpenCode sessions, subagents and token usage, from each CLI's
// own session store (omp-usage.js, opencode-usage.js).
for (const [name, mod, start] of [
["omp", "./lib/omp-usage", "startOmpUsagePoll"],
["opencode", "./lib/opencode-usage", "startOpencodeUsagePoll"],
]) {
try {
require(mod)[start](broadcast);
} catch (err) {
console.warn(`${name} usage poll failed to start:`, err.message);
}
}
// Codex/Antigravity hooks the user installed forward through a script whose // Codex/Antigravity hooks the user installed forward through a script whose
// content names this build; point it here (the command in the tool's // content names this build; point it here (the command in the tool's
// hooks.json, which Codex trusts, stays the same). // hooks.json, which Codex trusts, stays the same).
+6 -1
View File
@@ -88,7 +88,7 @@ function validate(doc, source) {
if (typeof doc.label !== "string" || !doc.label.trim()) return null; if (typeof doc.label !== "string" || !doc.label.trim()) return null;
if (!isStrArray(doc.detect, CMD_RE) || !doc.detect.length) return null; if (!isStrArray(doc.detect, CMD_RE) || !doc.detect.length) return null;
if (doc.requires !== undefined && !isStrArray(doc.requires, CMD_RE)) 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[k] !== undefined && !isStrArray(doc[k])) return null;
} }
if (doc.bypassEnv !== undefined) { if (doc.bypassEnv !== undefined) {
@@ -145,6 +145,9 @@ function validate(doc, source) {
faviconDomain: typeof doc.faviconDomain === "string" ? doc.faviconDomain : null, faviconDomain: typeof doc.faviconDomain === "string" ? doc.faviconDomain : null,
prompt, prompt,
resume: doc.resume || null, 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, bypass: doc.bypass || null,
bypassEnv: doc.bypassEnv || null, bypassEnv: doc.bypassEnv || null,
launcher, launcher,
@@ -416,6 +419,8 @@ function view() {
launcher: t.launcher, launcher: t.launcher,
prompt: t.prompt ? { mode: t.prompt.mode } : null, prompt: t.prompt ? { mode: t.prompt.mode } : null,
resume: !!t.resume, 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), bypass: !!(t.bypass || t.bypassEnv),
proxyCapable: PROXY_CAPABLE.has(t.id), proxyCapable: PROXY_CAPABLE.has(t.id),
// claude/codex take a model through their own argv builders. // 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?, * 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 * catalog declares a model flag). Every piece but the prompt and the
* validated model comes from the catalog * validated model comes from the catalog
* file, and the prompt is one argv element — or, for `after-start`, nothing * 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. * 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]; const argv = [bin, ...entry.args];
if (model && entry.model) argv.push(entry.model.flag, model); 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); if (bypass && entry.bypass) argv.push(...entry.bypass);
const p = entry.prompt; const p = entry.prompt;
if (initialPrompt && p.mode === "flag") argv.push(p.flag, promptArg(initialPrompt)); if (initialPrompt && p.mode === "flag") argv.push(p.flag, promptArg(initialPrompt));
@@ -437,15 +439,21 @@ function spawnRun(args) {
} }
if ( if (
resumeSessionId != null && 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"); throw makeErr("EBADSESSION", "resumeSessionId is not a valid session id");
} }
// Only the hand-written launchers can resume a NAMED session; a catalog // Only the hand-written launchers and a catalog tool that declares
// tool would silently start fresh under a "resume". // `resumeById` can resume a NAMED session; any other would silently start
if (resumeSessionId && !entry.launcher) { // fresh under a "resume".
if (resumeSessionId && !entry.launcher && !entry.resumeById) {
throw makeErr("EUNSUPPORTED", `${tool} cannot resume a session by id`); 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") { if (worktree === true && entry.launcher !== "claude") {
throw makeErr("EUNSUPPORTED", `${tool} cannot start a session in its own worktree`); throw makeErr("EUNSUPPORTED", `${tool} cannot start a session in its own worktree`);
} }
+4
View File
@@ -38,6 +38,10 @@ const schemas = {
properties: { mode: { type: "string", enum: ["argv", "flag", "after-start"] } }, properties: { mode: { type: "string", enum: ["argv", "flag", "after-start"] } },
}, },
resume: { type: "boolean", description: "Offers Resume last session" }, resume: { type: "boolean", description: "Offers Resume last session" },
resumeById: {
type: "boolean",
description: "Can resume a named session (Claude, Codex, a catalog `resumeById` such as omp)",
},
bypass: { type: "boolean", description: "Offers the per-run Bypass permissions switch" }, bypass: { type: "boolean", description: "Offers the per-run Bypass permissions switch" },
proxyCapable: { type: "boolean", description: "Can use an AI Proxy profile" }, proxyCapable: { type: "boolean", description: "Can use an AI Proxy profile" },
install: { install: {
+1 -1
View File
@@ -504,7 +504,7 @@ const schemas = {
resumeSessionId: { resumeSessionId: {
type: "string", type: "string",
description: description:
"Optional Claude session id to resume via `--resume`. Must match /^[A-Za-z0-9-]{8,}$/ and requires conversation mode, else EBADSESSION / EBADMODE.", "Optional Claude session id to resume via `--resume`. Must match /^[A-Za-z0-9_-]{8,}$/ and requires conversation mode, else EBADSESSION / EBADMODE.",
example: "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", example: "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
}, },
effort: { effort: {
+34 -3
View File
@@ -1458,7 +1458,33 @@ function antigravityHookInput(hookType, data) {
return null; 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 * 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 } }; const ignored = { status: 200, body: { ok: true, ignored: true } };
// Which CLI sent it: `codex` from Codex's hooks (codex-hooks.js), // 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. // 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 (tool !== "claude") {
if (!data || typeof data !== "object") return ignored; if (!data || typeof data !== "object") return ignored;
require("../lib/hook-auth").dropForeignPaths(peer, data); 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" } }); 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 = []; const result = [];
// Query database agent list for db_agent_id association // 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 // A Codex session's conversation is its root thread's rollout journal
// (`codex_rollouts`, recorded by codex-usage.js), never a Claude transcript. // (`codex_rollouts`, recorded by codex-usage.js), never a Claude transcript.
const isCodex = session.tool === "codex"; 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 jsonlPath = db
.prepare("SELECT path FROM codex_rollouts WHERE session_id = ? AND thread_id = ?") .prepare("SELECT path FROM codex_rollouts WHERE session_id = ? AND thread_id = ?")
.get(req.params.id, req.params.id)?.path; .get(req.params.id, req.params.id)?.path;
@@ -1105,7 +1162,14 @@ router.get("/:id/transcript", async (req, res) => {
const renders = isCodex const renders = isCodex
? (e) => e.type === "response_item" ? (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 // Dedupe state for synthetic rename markers: custom-title lines can repeat
// with the same value across a transcript, so only emit when the title // 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; if (!renders(entry)) continue;
const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum); const message = toMessage(entry, lineNum);
if (!message) continue; if (!message) continue;
total++; total++;
messages.push(message); messages.push(message);
@@ -1313,7 +1377,7 @@ router.get("/:id/transcript", async (req, res) => {
break; break;
} }
const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum); const message = toMessage(entry, lineNum);
if (!message) continue; if (!message) continue;
total++; total++;
messages.push(message); messages.push(message);
@@ -1337,7 +1401,7 @@ router.get("/:id/transcript", async (req, res) => {
} }
if (!renders(entry)) continue; if (!renders(entry)) continue;
const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum); const message = toMessage(entry, lineNum);
if (!message) continue; if (!message) continue;
total++; total++;
@@ -1364,7 +1428,7 @@ router.get("/:id/transcript", async (req, res) => {
} }
if (!renders(entry)) continue; if (!renders(entry)) continue;
const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum); const message = toMessage(entry, lineNum);
if (!message) continue; if (!message) continue;
total++; total++;
messages.push(message); 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`): /** The +/- line counts of a Write/Edit/MultiEdit result (`toolUseResult`):
* its `structuredPatch` hunks' `+`/`-` lines, or every line of a file it * its `structuredPatch` hunks' `+`/`-` lines, or every line of a file it
* created. Null for any other result. */ * created. Null for any other result. */