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:
@@ -9,8 +9,9 @@
|
||||
* first, and a session some tab already holds opens THAT tab instead of a
|
||||
* 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
|
||||
* it is live the composer is off. Only Claude and Codex can resume a session by id —
|
||||
* any other tool's session offers a new session from the workspace home
|
||||
* it is live the composer is off. Only Claude, Codex and a catalog tool with
|
||||
* `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
|
||||
* (`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
|
||||
@@ -32,6 +33,7 @@ import { toast } from "../../lib/toast";
|
||||
import i18n from "../../i18n";
|
||||
import { liveRunFor } from "./WorkspaceSessionList";
|
||||
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
|
||||
* 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). */
|
||||
// Before the catalog loads: the two hand-written launchers resume by id.
|
||||
const RESUMABLE = new Set(["claude", "codex"]);
|
||||
|
||||
export function SessionReplay({
|
||||
@@ -65,6 +68,7 @@ export function SessionReplay({
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
const { byId } = useAiTools();
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
@@ -88,7 +92,7 @@ export function SessionReplay({
|
||||
}, [sessionId]);
|
||||
|
||||
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 text = promptWithImages(prompt, images);
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file SessionResumeButton.tsx
|
||||
* @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
|
||||
* transcript into context. Fork resumes it into a NEW session id
|
||||
* (`forkSession: true` → `claude --resume <id> --fork-session`), leaving the
|
||||
@@ -26,6 +27,7 @@ import { ConfirmModal } from "../ConfirmModal";
|
||||
import { startInFreeTabDetailed } from "./startSession";
|
||||
import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
|
||||
/** A path without its trailing separators — `/` or a Windows `\`. */
|
||||
const strip = (p: string) => p.replace(/[\\/]+$/, "");
|
||||
@@ -54,6 +56,7 @@ export function SessionResumeButton({
|
||||
const navigate = useNavigate();
|
||||
const { lanes } = useLanes();
|
||||
const { canEditLane } = usePermissions();
|
||||
const { byId } = useAiTools();
|
||||
const [runs, setRuns] = useState<RunHandle[] | null>(null);
|
||||
const [confirming, setConfirming] = useState<"resume" | "fork" | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -70,12 +73,13 @@ export function SessionResumeButton({
|
||||
};
|
||||
}, [session.id]);
|
||||
|
||||
// Claude only: a Codex session id is not what
|
||||
// `codex resume` takes for every kind of thread.
|
||||
// Claude, or a catalog tool that resumes by id (no launcher of its own): a
|
||||
// Codex session id is not what `codex resume` takes for every kind of thread.
|
||||
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.
|
||||
const lane =
|
||||
tool !== "claude" || !session.cwd
|
||||
(tool !== "claude" && !catalogResume) || !session.cwd
|
||||
? null
|
||||
: (lanes.find((l) => l.id === session.lane_id && l.tool === tool) ??
|
||||
owningLane(session.cwd, tool, lanes));
|
||||
@@ -140,7 +144,7 @@ export function SessionResumeButton({
|
||||
{t("sessions.resume")}
|
||||
</button>
|
||||
)}
|
||||
{resumable && (
|
||||
{resumable && tool === "claude" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming("fork")}
|
||||
|
||||
@@ -4429,6 +4429,8 @@ export interface AiTool {
|
||||
prompt: { mode: "argv" | "flag" | "after-start" } | null;
|
||||
/** Offers "Resume last session". */
|
||||
resume: boolean;
|
||||
/** Can resume a NAMED session (Claude, Codex, a catalog `resumeById`). */
|
||||
resumeById?: boolean;
|
||||
/** Offers the per-run "Bypass permissions" switch. */
|
||||
bypass: boolean;
|
||||
/** Can use an AI Proxy profile (Claude, Codex); others are native only. */
|
||||
@@ -5212,7 +5214,7 @@ export interface RunStartArgs {
|
||||
cwd?: string;
|
||||
model?: string;
|
||||
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;
|
||||
effort?: EffortLevel;
|
||||
/** Sent as `claude`'s first positional message once the pane boots; omit
|
||||
|
||||
@@ -732,7 +732,10 @@ export interface Session {
|
||||
/** The AI coding CLI that produced the session: `"claude"` (the default —
|
||||
* Claude Code's hooks and imported transcripts), `"codex"` (Codex's rollout
|
||||
* 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;
|
||||
/** The workspace (lane id) that owns the session — the one whose pane held
|
||||
* it; null for a session no workspace started.
|
||||
|
||||
@@ -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)
|
||||
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_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_AGY_USAGE= # 0 never runs `agy --print /usage` for the Usage panel's Antigravity quota (default on; running agy may update it)
|
||||
|
||||
|
||||
+1
-1
@@ -1830,7 +1830,7 @@ agenthub stage <stage> [--evidence "..."] # Report the current stage
|
||||
|
||||
## 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:
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see
|
||||
[`docs/LANES.md`](LANES.md#custom-pipeline-templates)); until then
|
||||
|
||||
@@ -638,6 +638,33 @@ An `antigravity` workspace carries no pipeline either.
|
||||
`DASHBOARD_CURSOR_POLL_MS` ms (default 15000, `0` disables) into `sessions`
|
||||
rows (`tool = 'cursor'`) — no `token_usage`, the transcripts carry none —
|
||||
and `cursor_transcripts` holds the mtime read for each file.
|
||||
- **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
|
||||
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
|
||||
|
||||
@@ -190,16 +190,16 @@ describe("install / update / icons", () => {
|
||||
it("starts an install job and reports it", async () => {
|
||||
jobs.__reset();
|
||||
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.body.job.tool, "copilot");
|
||||
assert.equal(r.body.job.tool, "codex");
|
||||
const done = await settle();
|
||||
assert.equal(done.status, "ok");
|
||||
assert.deepEqual(spawned.at(-1), [
|
||||
path.join(BIN, "npm"),
|
||||
"install",
|
||||
"-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");
|
||||
assert.equal(notNpm.status, 409);
|
||||
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",
|
||||
});
|
||||
assert.equal(cross.status, 403);
|
||||
@@ -225,7 +225,7 @@ describe("install / update / icons", () => {
|
||||
it("answers EBUSY while a job runs", async () => {
|
||||
jobs.__reset({ timeoutMs: 50 });
|
||||
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");
|
||||
assert.equal(busy.status, 409);
|
||||
assert.equal(busy.body.error.code, "EBUSY");
|
||||
|
||||
@@ -194,6 +194,7 @@ describe("enable / default", () => {
|
||||
launcher: null,
|
||||
prompt: { mode: "flag" },
|
||||
resume: false,
|
||||
resumeById: false,
|
||||
bypass: false,
|
||||
proxyCapable: false,
|
||||
modelFlag: false,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -733,7 +733,7 @@ describe("pty-run", () => {
|
||||
let savedPath;
|
||||
before(() => {
|
||||
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.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", () => {
|
||||
for (const tool of ["claude", "codex", "antigravity"]) {
|
||||
assert.throws(
|
||||
|
||||
@@ -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
|
||||
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)
|
||||
|
||||
Two optional fields, honoured only in built-in files and in the copy of the
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,9 @@
|
||||
"resume": [
|
||||
"--continue"
|
||||
],
|
||||
"resumeById": [
|
||||
"--resume"
|
||||
],
|
||||
"bypass": [
|
||||
"--yolo"
|
||||
],
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
"resume": [
|
||||
"--continue"
|
||||
],
|
||||
"resumeById": [
|
||||
"--resume"
|
||||
],
|
||||
"bypass": [
|
||||
"--permission-mode",
|
||||
"bypassPermissions"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -7,12 +7,35 @@
|
||||
"prompt": {
|
||||
"mode": "argv"
|
||||
},
|
||||
"model": {
|
||||
"flag": "--model"
|
||||
},
|
||||
"resume": [
|
||||
"--continue"
|
||||
],
|
||||
"resumeById": [
|
||||
"--resume"
|
||||
],
|
||||
"bypass": [
|
||||
"--auto-approve"
|
||||
],
|
||||
"homepageUrl": "https://omp.sh",
|
||||
"faviconDomain": "omp.sh",
|
||||
"install": {
|
||||
"kind": "script",
|
||||
"url": "https://omp.sh/install"
|
||||
},
|
||||
"update": {
|
||||
"kind": "script"
|
||||
}
|
||||
},
|
||||
"config": [
|
||||
{
|
||||
"path": "~/.omp/agent/config.yml",
|
||||
"format": "text"
|
||||
},
|
||||
{
|
||||
"path": "~/.omp/agent/models.yml",
|
||||
"format": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
"model": {
|
||||
"flag": "--model"
|
||||
},
|
||||
"resume": [
|
||||
"--continue"
|
||||
],
|
||||
"resumeById": [
|
||||
"--session"
|
||||
],
|
||||
"homepageUrl": "https://opencode.ai/docs/cli/",
|
||||
"install": {
|
||||
"kind": "npm",
|
||||
|
||||
@@ -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
|
||||
// (server/lib/cursor-usage.js) — the same "deleted stays deleted" rule.
|
||||
db.exec(`
|
||||
|
||||
@@ -766,6 +766,18 @@ function startBackgroundServices() {
|
||||
} catch (err) {
|
||||
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
|
||||
// content names this build; point it here (the command in the tool's
|
||||
// hooks.json, which Codex trusts, stays the same).
|
||||
|
||||
@@ -88,7 +88,7 @@ function validate(doc, source) {
|
||||
if (typeof doc.label !== "string" || !doc.label.trim()) return null;
|
||||
if (!isStrArray(doc.detect, CMD_RE) || !doc.detect.length) return null;
|
||||
if (doc.requires !== undefined && !isStrArray(doc.requires, CMD_RE)) return null;
|
||||
for (const k of ["args", "resume", "bypass"]) {
|
||||
for (const k of ["args", "resume", "resumeById", "bypass"]) {
|
||||
if (doc[k] !== undefined && !isStrArray(doc[k])) return null;
|
||||
}
|
||||
if (doc.bypassEnv !== undefined) {
|
||||
@@ -145,6 +145,9 @@ function validate(doc, source) {
|
||||
faviconDomain: typeof doc.faviconDomain === "string" ? doc.faviconDomain : null,
|
||||
prompt,
|
||||
resume: doc.resume || null,
|
||||
// `resumeById`: the flag(s) a NAMED session's id follows, as one more argv
|
||||
// element (`omp --resume <id>`) — the id is validated at spawn time.
|
||||
resumeById: doc.resumeById || null,
|
||||
bypass: doc.bypass || null,
|
||||
bypassEnv: doc.bypassEnv || null,
|
||||
launcher,
|
||||
@@ -416,6 +419,8 @@ function view() {
|
||||
launcher: t.launcher,
|
||||
prompt: t.prompt ? { mode: t.prompt.mode } : null,
|
||||
resume: !!t.resume,
|
||||
// claude/codex resume a named session through their own argv builders.
|
||||
resumeById: Boolean(t.resumeById || t.launcher),
|
||||
bypass: !!(t.bypass || t.bypassEnv),
|
||||
proxyCapable: PROXY_CAPABLE.has(t.id),
|
||||
// claude/codex take a model through their own argv builders.
|
||||
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -295,16 +295,18 @@ function buildCodexArgv({
|
||||
|
||||
/**
|
||||
* Any catalog tool without a hand-written launcher: `[bin, ...args, ...resume?,
|
||||
* ...bypass?, ...prompt]` (`[flag, model]` right after the fixed args when the
|
||||
* ...bypass?, ...prompt]` (`...resumeById, <id>` in place of `resume` for a
|
||||
* named session; `[flag, model]` right after the fixed args when the
|
||||
* catalog declares a model flag). Every piece but the prompt and the
|
||||
* validated model comes from the catalog
|
||||
* file, and the prompt is one argv element — or, for `after-start`, nothing
|
||||
* here: spawnRun pastes it into the pane once the CLI is up.
|
||||
*/
|
||||
function buildCatalogArgv(entry, bin, { model, resumeLast, bypass, initialPrompt }) {
|
||||
function buildCatalogArgv(entry, bin, { model, resumeSessionId, resumeLast, bypass, initialPrompt }) {
|
||||
const argv = [bin, ...entry.args];
|
||||
if (model && entry.model) argv.push(entry.model.flag, model);
|
||||
if (resumeLast && entry.resume) argv.push(...entry.resume);
|
||||
if (resumeSessionId && entry.resumeById) argv.push(...entry.resumeById, resumeSessionId);
|
||||
else if (resumeLast && entry.resume) argv.push(...entry.resume);
|
||||
if (bypass && entry.bypass) argv.push(...entry.bypass);
|
||||
const p = entry.prompt;
|
||||
if (initialPrompt && p.mode === "flag") argv.push(p.flag, promptArg(initialPrompt));
|
||||
@@ -437,15 +439,21 @@ function spawnRun(args) {
|
||||
}
|
||||
if (
|
||||
resumeSessionId != null &&
|
||||
(typeof resumeSessionId !== "string" || !/^[A-Za-z0-9-]{8,}$/.test(resumeSessionId))
|
||||
(typeof resumeSessionId !== "string" || !/^[A-Za-z0-9_-]{8,}$/.test(resumeSessionId))
|
||||
) {
|
||||
throw makeErr("EBADSESSION", "resumeSessionId is not a valid session id");
|
||||
}
|
||||
// Only the hand-written launchers can resume a NAMED session; a catalog
|
||||
// tool would silently start fresh under a "resume".
|
||||
if (resumeSessionId && !entry.launcher) {
|
||||
// Only the hand-written launchers and a catalog tool that declares
|
||||
// `resumeById` can resume a NAMED session; any other would silently start
|
||||
// fresh under a "resume".
|
||||
if (resumeSessionId && !entry.launcher && !entry.resumeById) {
|
||||
throw makeErr("EUNSUPPORTED", `${tool} cannot resume a session by id`);
|
||||
}
|
||||
// A catalog tool takes the id as an argv element of its own: never one that
|
||||
// could read as a flag.
|
||||
if (resumeSessionId && !entry.launcher && !/^[A-Za-z0-9]/.test(resumeSessionId)) {
|
||||
throw makeErr("EBADSESSION", "resumeSessionId is not a valid session id");
|
||||
}
|
||||
if (worktree === true && entry.launcher !== "claude") {
|
||||
throw makeErr("EUNSUPPORTED", `${tool} cannot start a session in its own worktree`);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,10 @@ const schemas = {
|
||||
properties: { mode: { type: "string", enum: ["argv", "flag", "after-start"] } },
|
||||
},
|
||||
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" },
|
||||
proxyCapable: { type: "boolean", description: "Can use an AI Proxy profile" },
|
||||
install: {
|
||||
|
||||
@@ -504,7 +504,7 @@ const schemas = {
|
||||
resumeSessionId: {
|
||||
type: "string",
|
||||
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",
|
||||
},
|
||||
effort: {
|
||||
|
||||
+34
-3
@@ -1458,7 +1458,33 @@ function antigravityHookInput(hookType, data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const HOOK_TOOLS = { codex: codexHookInput, antigravity: antigravityHookInput };
|
||||
/**
|
||||
* A Grok Build (`grok`) hook. Grok runs the hooks in `~/.claude/settings.json`
|
||||
* itself (its Claude compatibility, on by default), so AgentHub's own Claude
|
||||
* hook reaches here with no `tool` — told apart by the camelCase keys only
|
||||
* Grok sends beside Claude's snake_case aliases (`hookEventName`,
|
||||
* `workspaceRoot`). Its `transcript_path` is Grok's file, never a Claude
|
||||
* transcript, so it is dropped before anything could parse it.
|
||||
*/
|
||||
const isGrokPayload = (data) =>
|
||||
!!data &&
|
||||
typeof data === "object" &&
|
||||
typeof data.hookEventName === "string" &&
|
||||
typeof data.workspaceRoot === "string";
|
||||
|
||||
function grokHookInput(hookType, data) {
|
||||
if (typeof data.session_id !== "string" || !data.session_id) return null;
|
||||
const d = { ...data };
|
||||
delete d.transcript_path;
|
||||
delete d.agent_transcript_path;
|
||||
return { hookType, data: d };
|
||||
}
|
||||
|
||||
const HOOK_TOOLS = {
|
||||
codex: codexHookInput,
|
||||
antigravity: antigravityHookInput,
|
||||
grok: grokHookInput,
|
||||
};
|
||||
|
||||
/**
|
||||
* Record which session a dashboard pane holds. The pane's CLI inherits
|
||||
@@ -1524,9 +1550,14 @@ function ingestHook(body, pushSource = null, peer = { kind: "host" }) {
|
||||
}
|
||||
const ignored = { status: 200, body: { ok: true, ignored: true } };
|
||||
// Which CLI sent it: `codex` from Codex's hooks (codex-hooks.js),
|
||||
// `antigravity` from agy's (antigravity-hooks.js), anything else is Claude
|
||||
// `antigravity` from agy's (antigravity-hooks.js), `grok` for Grok running
|
||||
// the Claude hooks (by its payload's own keys), anything else is Claude
|
||||
// Code — an allowlist, never a value stored as sent.
|
||||
const tool = Object.hasOwn(HOOK_TOOLS, body.tool) ? body.tool : "claude";
|
||||
const tool = Object.hasOwn(HOOK_TOOLS, body.tool)
|
||||
? body.tool
|
||||
: isGrokPayload(data)
|
||||
? "grok"
|
||||
: "claude";
|
||||
if (tool !== "claude") {
|
||||
if (!data || typeof data !== "object") return ignored;
|
||||
require("../lib/hook-auth").dropForeignPaths(peer, data);
|
||||
|
||||
+170
-6
@@ -840,6 +840,45 @@ router.get("/:id/transcripts", async (req, res) => {
|
||||
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Session not found" } });
|
||||
}
|
||||
|
||||
// An omp session's journals are known by path (omp-usage.js): its own,
|
||||
// then one per subagent, each tied to the agent row made from it.
|
||||
if (session.tool === "omp") {
|
||||
const omp = require("../lib/omp-usage");
|
||||
const list = [];
|
||||
if (omp.journalPath(req.params.id)) {
|
||||
list.push({ id: "main", name: "Main Agent", type: "main", has_transcript: true, db_agent_id: null });
|
||||
}
|
||||
for (const j of omp.subagentJournals(req.params.id)) {
|
||||
list.push({
|
||||
id: j.agent,
|
||||
name: j.name,
|
||||
type: "subagent",
|
||||
subagent_type: "task",
|
||||
has_transcript: true,
|
||||
db_agent_id: stmts.getAgent.get(j.rowId) ? j.rowId : null,
|
||||
});
|
||||
}
|
||||
return res.json({ transcripts: list });
|
||||
}
|
||||
// OpenCode: a subagent is a child session of the store, tied to the agent
|
||||
// row its poller made.
|
||||
if (session.tool === "opencode") {
|
||||
const list = [
|
||||
{ id: "main", name: "Main Agent", type: "main", has_transcript: true, db_agent_id: null },
|
||||
];
|
||||
for (const sub of require("../lib/opencode-usage").subagentSessions(req.params.id)) {
|
||||
list.push({
|
||||
id: sub.id,
|
||||
name: sub.name,
|
||||
type: "subagent",
|
||||
subagent_type: "task",
|
||||
has_transcript: true,
|
||||
db_agent_id: stmts.getAgent.get(sub.rowId) ? sub.rowId : null,
|
||||
});
|
||||
}
|
||||
return res.json({ transcripts: list });
|
||||
}
|
||||
|
||||
const result = [];
|
||||
|
||||
// Query database agent list for db_agent_id association
|
||||
@@ -1071,7 +1110,25 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
// A Codex session's conversation is its root thread's rollout journal
|
||||
// (`codex_rollouts`, recorded by codex-usage.js), never a Claude transcript.
|
||||
const isCodex = session.tool === "codex";
|
||||
if (isCodex) {
|
||||
// An omp session's (or subagent's) journal, recorded by omp-usage.js.
|
||||
const isOmp = session.tool === "omp";
|
||||
// An OpenCode conversation lives in its SQLite store, not a JSONL file: read
|
||||
// whole (one session's rows), then paged like the files below.
|
||||
if (session.tool === "opencode") {
|
||||
let all = null;
|
||||
try {
|
||||
all = require("../lib/opencode-usage").transcriptMessages(
|
||||
req.params.id,
|
||||
isSubagentFile ? agentId : null
|
||||
);
|
||||
} catch {
|
||||
all = null;
|
||||
}
|
||||
return res.json(pageMessages(all || [], { limit, afterLine, beforeLine, offset }));
|
||||
}
|
||||
if (isOmp) {
|
||||
jsonlPath = require("../lib/omp-usage").journalPath(req.params.id, agentId || "");
|
||||
} else if (isCodex) {
|
||||
jsonlPath = db
|
||||
.prepare("SELECT path FROM codex_rollouts WHERE session_id = ? AND thread_id = ?")
|
||||
.get(req.params.id, req.params.id)?.path;
|
||||
@@ -1105,7 +1162,14 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
|
||||
const renders = isCodex
|
||||
? (e) => e.type === "response_item"
|
||||
: (e) => TRANSCRIPT_RENDER_TYPES.has(e.type);
|
||||
: isOmp
|
||||
? (e) => e.type === "message"
|
||||
: (e) => TRANSCRIPT_RENDER_TYPES.has(e.type);
|
||||
const toMessage = isCodex
|
||||
? codexMessage
|
||||
: isOmp
|
||||
? (e, n) => ompMessage(e, n, isSubagentFile)
|
||||
: (e, n) => parseMessage(e, n);
|
||||
|
||||
// Dedupe state for synthetic rename markers: custom-title lines can repeat
|
||||
// with the same value across a transcript, so only emit when the title
|
||||
@@ -1282,7 +1346,7 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
}
|
||||
if (!renders(entry)) continue;
|
||||
|
||||
const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum);
|
||||
const message = toMessage(entry, lineNum);
|
||||
if (!message) continue;
|
||||
total++;
|
||||
messages.push(message);
|
||||
@@ -1313,7 +1377,7 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
break;
|
||||
}
|
||||
|
||||
const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum);
|
||||
const message = toMessage(entry, lineNum);
|
||||
if (!message) continue;
|
||||
total++;
|
||||
messages.push(message);
|
||||
@@ -1337,7 +1401,7 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
}
|
||||
if (!renders(entry)) continue;
|
||||
|
||||
const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum);
|
||||
const message = toMessage(entry, lineNum);
|
||||
if (!message) continue;
|
||||
total++;
|
||||
|
||||
@@ -1364,7 +1428,7 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
}
|
||||
if (!renders(entry)) continue;
|
||||
|
||||
const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum);
|
||||
const message = toMessage(entry, lineNum);
|
||||
if (!message) continue;
|
||||
total++;
|
||||
messages.push(message);
|
||||
@@ -1473,6 +1537,106 @@ function codexMessage(entry, num) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One omp journal `message` line → a transcript message in the Claude shape
|
||||
* the client renders, or null. A user line omp wrote for itself (no
|
||||
* `attribution: "user"`) is the system's — in a subagent's journal, the first
|
||||
* one is the orchestrator's task.
|
||||
*/
|
||||
function ompMessage(entry, num, isSubagent = false) {
|
||||
const m = entry.message || {};
|
||||
const at = entry.timestamp || null;
|
||||
const msg = (type, sender, content) => ({ type, sender, timestamp: at, content, line: num });
|
||||
const parts = Array.isArray(m.content)
|
||||
? m.content
|
||||
: typeof m.content === "string"
|
||||
? [{ type: "text", text: m.content }]
|
||||
: [];
|
||||
switch (m.role) {
|
||||
case "user": {
|
||||
const content = parts
|
||||
.filter((c) => c && c.type === "text" && typeof c.text === "string" && c.text.trim())
|
||||
.map((c) => ({ type: "text", text: truncate(c.text, TEXT_MAX) }));
|
||||
if (!content.length) return null;
|
||||
const sender = m.attribution === "user" ? "user" : isSubagent ? "orchestrator" : "system";
|
||||
return msg("user", sender, content);
|
||||
}
|
||||
case "assistant": {
|
||||
const content = [];
|
||||
for (const c of parts) {
|
||||
if (!c) continue;
|
||||
if (c.type === "text" && typeof c.text === "string" && c.text.trim()) {
|
||||
content.push({ type: "text", text: truncate(c.text, TEXT_MAX) });
|
||||
} else if (c.type === "thinking" && typeof c.thinking === "string" && c.thinking.trim()) {
|
||||
content.push({ type: "thinking", text: truncate(c.thinking, 10240) });
|
||||
} else if (c.type === "toolCall") {
|
||||
content.push({
|
||||
type: "tool_use",
|
||||
name: c.name || "unknown",
|
||||
id: c.id || null,
|
||||
input: truncateObj(c.arguments && typeof c.arguments === "object" ? c.arguments : {}, 10240),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!content.length) return null;
|
||||
const out = msg("assistant", "assistant", content);
|
||||
if (m.model) out.model = m.model;
|
||||
return out;
|
||||
}
|
||||
case "toolResult": {
|
||||
const text = parts
|
||||
.map((c) => (c && c.type === "text" && typeof c.text === "string" ? c.text : ""))
|
||||
.join("");
|
||||
return msg("user", "tool", [
|
||||
{
|
||||
type: "tool_result",
|
||||
id: m.toolCallId || null,
|
||||
output: truncate(text, 10240),
|
||||
is_error: m.isError === true,
|
||||
},
|
||||
]);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The transcript route's paging over a conversation already in memory (an
|
||||
* OpenCode store): message N is "line" N, so `after`/`before`/`offset` and the
|
||||
* default latest-`limit` window answer exactly as they do over a file.
|
||||
*/
|
||||
function pageMessages(all, { limit, afterLine, beforeLine, offset }) {
|
||||
const lined = all.map((m, i) => ({ ...m, line: i + 1 }));
|
||||
let picked;
|
||||
let hasMore;
|
||||
if (afterLine !== null) {
|
||||
const rest = lined.filter((m) => m.line > afterLine);
|
||||
picked = rest.slice(0, limit);
|
||||
hasMore = rest.length > limit;
|
||||
} else if (beforeLine !== null) {
|
||||
const older = lined.filter((m) => m.line < beforeLine);
|
||||
picked = older.slice(-limit);
|
||||
hasMore = older.length > limit;
|
||||
} else if (offset > 0) {
|
||||
picked = lined.slice(offset, offset + limit);
|
||||
hasMore = lined.length > offset + limit;
|
||||
} else {
|
||||
picked = lined.slice(-limit);
|
||||
hasMore = lined.length > limit;
|
||||
}
|
||||
const lastLine = picked.length ? picked[picked.length - 1].line : 0;
|
||||
const firstLine = picked.length ? picked[0].line : 0;
|
||||
for (const m of picked) delete m.line;
|
||||
return {
|
||||
messages: picked,
|
||||
total: lined.length,
|
||||
has_more: hasMore,
|
||||
last_line: lastLine,
|
||||
first_line: firstLine,
|
||||
};
|
||||
}
|
||||
|
||||
/** The +/- line counts of a Write/Edit/MultiEdit result (`toolUseResult`):
|
||||
* its `structuredPatch` hunks' `+`/`-` lines, or every line of a file it
|
||||
* created. Null for any other result. */
|
||||
|
||||
Reference in New Issue
Block a user