feat(ai-tools): drop OpenCode and Grok Build
- opencode.json / grok.json leave the built-in catalog; opencode-usage.js, its poller, transcript branch and table creation go with it (existing tables and session rows stay as history). - A Grok payload reaching the Claude hook route (Grok runs the Claude hooks itself) is dropped instead of recorded as tool = 'grok'. - quota.js no longer reads Grok or OpenCode Go logins. - Redone on v2.8.22 from c6b2892 (feat/grok-usage, never merged).
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
# Claude Code Working Guide
|
||||
|
||||
_Last synced: v2.8.23 (2026-10-23) — sidebar workspace groups; a parent folder's inner repos in Changes (opt-in); a session's sub-agents count in its tokens._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||
_Last synced: v2.8.23 (2026-10-26) — OpenCode and Grok Build left the catalog (`opencode-usage.js` gone, a Grok payload on the Claude hook route is dropped)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||
|
||||
## Project mission
|
||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
||||
- Preserve real-time behavior (hooks -> API -> SQLite -> WebSocket -> UI).
|
||||
|
||||
## Repo map
|
||||
- `server/`: Express API, hook ingestion, SQLite access, websocket broadcast (includes release-based update detection — `lib/update-check.js` reads the repo's own releases API and `routes/updates.js` exposes status/check/apply, refusing to self-update a `~/.claude/plugins/cache/` install, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks — and `lib/codex-ingest.js`, which mirrors a `codex` lane's running/idle from the mtime of Codex's own rollout journals (skipping a lane whose hooks are current), `lib/codex-usage.js`, which reads the same journals into `sessions` (`tool = 'codex'`) and `token_usage`, and `lib/codex-hooks.js`, which installs AgentHub's opt-in hooks into Codex's `hooks.json`, and their Antigravity counterparts `lib/antigravity-usage.js` (agy's conversation files → `tool = 'antigravity'` sessions and tokens, and its step log as their conversation view) and `lib/antigravity-hooks.js` (the named hook `agenthub` in `~/.gemini/config/hooks.json`), and `lib/cursor-usage.js` (Cursor's agent transcripts → `tool = 'cursor'` sessions and their conversation view, no tokens), and `lib/omp-usage.js`, `lib/opencode-usage.js` (omp's journals, OpenCode's `opencode.db` → `tool = 'omp'|'opencode'` sessions, tokens and subagent agents)).
|
||||
- `server/`: Express API, hook ingestion, SQLite access, websocket broadcast (includes release-based update detection — `lib/update-check.js` reads the repo's own releases API and `routes/updates.js` exposes status/check/apply, refusing to self-update a `~/.claude/plugins/cache/` install, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks — and `lib/codex-ingest.js`, which mirrors a `codex` lane's running/idle from the mtime of Codex's own rollout journals (skipping a lane whose hooks are current), `lib/codex-usage.js`, which reads the same journals into `sessions` (`tool = 'codex'`) and `token_usage`, and `lib/codex-hooks.js`, which installs AgentHub's opt-in hooks into Codex's `hooks.json`, and their Antigravity counterparts `lib/antigravity-usage.js` (agy's conversation files → `tool = 'antigravity'` sessions and tokens, and its step log as their conversation view) and `lib/antigravity-hooks.js` (the named hook `agenthub` in `~/.gemini/config/hooks.json`), and `lib/cursor-usage.js` (Cursor's agent transcripts → `tool = 'cursor'` sessions and their conversation view, no tokens), and `lib/omp-usage.js` (omp's journals → `tool = 'omp'` sessions, tokens and subagent agents)).
|
||||
- `client/`: React + Vite UI.
|
||||
- `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`, keyed on the forge's **newest release** rather than a commit count. `POST /api/updates/apply` has two modes and picks by install kind, never from the request: a fast-forwardable **checkout** merges the release tag `--ff-only`, rebuilds, and self-restarts via `server/lib/self-restart.js` — through our own unit when this process runs in one (`systemctl restart --no-block`, launchd's KeepAlive; a detached replacement beside a `Restart=always` unit crash-loops it), else the detached `scripts/restart-helper.js`; a **plugin-cache** install is handed to `server/lib/plugin-update.js` + the detached `scripts/plugin-update-helper.js`, which runs `claude plugin marketplace update` → `claude plugin update -y` → `plugin-bootstrap.js --force` from the NEW cache dir. Both write the HTTP response before anything stops the process, and every plugin-chain outcome is recorded to `plugin-update.json` in the runtime dir — outside the cache, so it survives the swap and the next server reports it as `last_plugin_update`. While it runs, the step it is on is `plugin-update-progress.json` → `plugin_update_progress` (the update modal's step line and the chip it leaves when hidden). Neither mode can run `/reload-plugins`: it is REPL-scoped to the session that invokes it, so an open Claude Code session still reloads by hand for new skills/commands. Elsewhere users run the printed manual command, surfaced in the UI and by `agenthub update-check`. `scripts/cc-update-all-helper.js` is the same detached pattern for the config panel's **Update all** button — marketplaces → every plugin → AgentHub LAST → one reload — journalling each step to `cc-update-all.json` in the runtime dir.)
|
||||
- `skills/`: this repo's skills. Because the repo root **is** the `agenthub` plugin root, this is also the plugin's skills directory — every skill here ships to anyone who installs the plugin, addressed as `/agenthub:<name>` (bare `/<name>` works as an alias). Put a skill here only if it should reach plugin users. The plugin ships no subagents and no pipeline skill, and depends on no other plugin (Superpowers included) — do not add an auto-install of one back into the bootstrap.
|
||||
@@ -48,10 +48,10 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which
|
||||
- **A clone runs as its folder's account with THAT account's own identity, and only the public key ever leaves the server.** `POST /api/lanes/clone` takes a URL only through `validateRemoteUrl` (the `publish` allowlist) and a parent held to the user's own folder like New folder; `server/lib/git-identity.js` runs `git clone` as the folder's account (S3: the member) and writes the identity into the CLONE's config (`core.sshCommand` / `credential.helper=store --file=`), never the user's global config. A member's SSH key and HTTPS store live in their home under `~/.config/agenthub/`, written only through `write-as` (secrets on stdin, never sudo argv/env), the host's under `<CLAUDE_HOME>/agenthub-ssh/` + the shared store below; `/api/me/git` returns only `publicKey`/`hasToken`. The git bar's fetch/pull/push in a member's folder uses that same store (`memberHttpsConfig`; a typed login goes in through `write-as` stdin for the one call), and a store that cannot be read is never rewritten (`ESTOREUNREADABLE`); the private key is only `lstat`ed, never read. The commit author is the ACCOUNT's `git config --global` (`PUT /api/me/git/author`). Do not add a route that returns a private key or a token, or one that writes a git identity outside the clone's own config or the account's own `~/.gitconfig`.
|
||||
- **AgentHub's git credentials are its OWN store, shared by every user of the install, and git is given ONLY that store when it holds a login for the host.** `server/lib/git-credentials.js` keeps one record per `protocol + host` under `<CLAUDE_HOME>/agenthub-git-credentials/` (0700/0600) and no route ever returns a password (`hasToken` only). `gitConfigFor` runs a network action with `-c credential.helper= -c credential.helper=<ours>` whenever AgentHub holds a login for a repo remote's host or the request carries one — the machine's helpers are left out on purpose: a stale token there would win over the typed one, and git's post-success `store` would copy a use-once token into the OS user's helper. The secret travels in the helper's env or the store file, never argv. A typed credential is accepted only for one of the repo's OWN remote hosts (`EBADHOST`), so a login is never sent to a server it was not typed for, and it is saved only after git SUCCEEDED and `remember` is set. Do not add a route that returns a stored password or a path that forwards one to a host other than its own.
|
||||
- **A lane's `tool` is its DEFAULT, immutable; a session's tool is the session's choice — and only an AI Tools catalog id ever comes from a request.** `lanes.tool` (default `claude`) is any id `server/lib/ai-tools.js` knows; `createLane` refuses an unknown one (`EBADTOOL`) and one the user disabled (`EDISABLED` — only CREATION asks, an existing lane on a tool disabled since keeps working), and it is deliberately absent from `PATCHABLE`. A NEW session names its own with `tool` on `POST /api/lanes/:id/start` (the home's **AI** select, sent only when it differs from the lane's): `validateTool` again (`EBADTOOL`/`EDISABLED`, 400), and the id only picks a catalog file — the command, args and env still come from that file. A resume keeps its session's own tool (`sessions.tool`), never the request's. `POST /api/run` still takes the lane's tool. A lane whose tool left the catalog answers `ENOTOOL` at start. The sidebar's tool icon is per SESSION (`WorkspaceSessionList`), not per workspace row. A pane has no run form: `RunSetup` is gone from `LaneConsolePane`, and the workspace home's prompt box (`WorkspaceHome.tsx`: AI and ONE Model chip — `ModelMenu` in `RunSetup.tsx`, the model list plus Effort, a proxy's own models on a proxy — inside the box; AI Proxy, Permission and Local / New worktree in a row under it, like Claude Desktop) is the one place a session starts with a prompt (the session view's tab bar `+`, `ConsoleTabs.tsx`, starts a bare CLI or a plain shell — `shell: true`, nothing from the request but the slot; the home's AI menu has the same **Open terminal**). `claude`/`codex` keep their hand-written argv builders (`launcher` in their catalog file — a `DASHBOARD_AI_TOOLS_DIR` file may NOT claim one); every other tool is started by its detected absolute path as `[path, ...args, ...resume?, ...bypass?, ...prompt]` (`...resumeById, <id>` in place of `resume` for a named session, only when its catalog file declares `resumeById`), every piece but the prompt from its catalog file, the prompt ONE argv element or, for an `after-start` tool, a tmux paste buffer (`tmux.pasteText`) after `delayMs`. Codex-only fields (`sandbox`, `approval`) are validated against the fixed allowlists in `server/lib/pty-run.js`. **Bypass args are emitted ONLY when the run request carries `bypass: true`** — the home's red per-run switch, which starts from the account's own default (Settings → AI tools → **Session defaults**, `client/src/lib/sessionDefaults.ts`: Claude's default mode may be `bypassPermissions`, Codex's bypass may default on — set only behind a confirm, never by the first-run wizard, never for the pet chat; the server never remembers it) — and they come from the tool's catalog file (Codex: `--dangerously-bypass-approvals-and-sandbox`, which then replaces `-s`/`-a`); Claude has none (it has the `bypassPermissions` mode), and `--dangerously-bypass-hook-trust` is never emitted. Every pane gets the dashboard's hydrated `PATH` through tmux `-e`. Only `claude`/`codex` are `proxyCapable`; `resolveProxy` accepts only `native` for any other tool, else `EUNSUPPORTED`. `GET /api/run/binary?tool=` resolves through the catalog — the query string never names a command. Do not add a route that takes a command, args or env for a tool from a request.
|
||||
- **Antigravity hooks are observational only, and never answer a permission.** `server/lib/antigravity-hooks.js` writes only the named hook `agenthub` in `~/.gemini/config/hooks.json`, only on a click, and leaves every other name alone. It installs PreInvocation, PostToolUse and Stop and NEVER PreToolUse: agy reads a PreToolUse answer as a permission decision and DENIES the tool on `{}` (verified on 1.2.13), `allow` would override the user's own permission rules and `ask` would force a prompt — there is no neutral answer. The forwarder must keep printing `{}` and nothing else. The file is global (the Antigravity IDE loads it too), so the route accepts a hook only when its `transcriptPath` lies under `~/.gemini/antigravity-cli/`, and a hook's `tool` is an allowlist (`codex`, `antigravity`, `grok` — also recognised by its payload's camelCase `hookEventName` + `workspaceRoot`, since Grok runs the Claude hooks — else `claude`). AgentHub never reads the IDE's data.
|
||||
- **Codex hooks run only because the user trusted them in Codex, and AgentHub never decides that for them.** `server/lib/codex-hooks.js` writes `$CODEX_HOME/hooks.json` only on the user's click, keeps every entry that is not AgentHub's, and never writes Codex's trust (`[hooks.state]` in `config.toml` — read only, to report); nothing may pass `--dangerously-bypass-hook-trust`. The hook command is the STABLE `'<CODEX_HOME>/agenthub-hook.sh' <Event>` because Codex keys trust on the definition: change what the script runs (`refreshScript`), never the command, the timeout or the event list without expecting every user to re-trust. A hook's `tool` is an allowlist (`codex`, `antigravity`, `grok` — also recognised by its payload's camelCase `hookEventName` + `workspaceRoot`, since Grok runs the Claude hooks — else `claude`), and a Codex hook's `transcript_path` is never stored or parsed as a Claude transcript — only `threadOf` reads it, and only a `rollout-*.jsonl` under `$CODEX_HOME/sessions`.
|
||||
- **Antigravity hooks are observational only, and never answer a permission.** `server/lib/antigravity-hooks.js` writes only the named hook `agenthub` in `~/.gemini/config/hooks.json`, only on a click, and leaves every other name alone. It installs PreInvocation, PostToolUse and Stop and NEVER PreToolUse: agy reads a PreToolUse answer as a permission decision and DENIES the tool on `{}` (verified on 1.2.13), `allow` would override the user's own permission rules and `ask` would force a prompt — there is no neutral answer. The forwarder must keep printing `{}` and nothing else. The file is global (the Antigravity IDE loads it too), so the route accepts a hook only when its `transcriptPath` lies under `~/.gemini/antigravity-cli/`, and a hook's `tool` is an allowlist (`codex`, `antigravity`, else `claude` — a Grok payload, recognised by its camelCase `hookEventName` + `workspaceRoot` since Grok runs the Claude hooks, is dropped). AgentHub never reads the IDE's data.
|
||||
- **Codex hooks run only because the user trusted them in Codex, and AgentHub never decides that for them.** `server/lib/codex-hooks.js` writes `$CODEX_HOME/hooks.json` only on the user's click, keeps every entry that is not AgentHub's, and never writes Codex's trust (`[hooks.state]` in `config.toml` — read only, to report); nothing may pass `--dangerously-bypass-hook-trust`. The hook command is the STABLE `'<CODEX_HOME>/agenthub-hook.sh' <Event>` because Codex keys trust on the definition: change what the script runs (`refreshScript`), never the command, the timeout or the event list without expecting every user to re-trust. A hook's `tool` is an allowlist (`codex`, `antigravity`, else `claude`; a Grok payload is dropped), and a Codex hook's `transcript_path` is never stored or parsed as a Claude transcript — only `threadOf` reads it, and only a `rollout-*.jsonl` under `$CODEX_HOME/sessions`.
|
||||
- **An AI Tools install runs only what a RELEASE catalog file names.** `install`/`update` (like `launcher`) are accepted in built-in files and in the synced copy of the newest release's catalog, never in `DASHBOARD_AI_TOOLS_DIR`, and no route takes a package, URL or argv from a request. Jobs are argv-only (`ai-tool-jobs.js`), stdin closed, one at a time; npm is resolved on the login shell's OWN PATH first (`shellPath()`), because `hydrateShellPath` appends, and the pinned Node's npm would install where the user's PATH never looks; a global prefix the account cannot write (a system-wide Node, root's `/usr/local`) makes the install `--prefix ~/.local` instead, never sudo; an npm update is refused (`ENOTNPM`) for a tool whose binary is under neither `npm prefix -g` nor `~/.local/lib/node_modules` (a standalone Codex, say) — it would install a shadowed second copy. A script URL must be https on the homepage's site and is re-checked after redirects — the one way off that site is the catalog entry's own `install.redirect`, an EXACT https URL (never a site wildcard). Icons are raster only — an SVG served from the dashboard's origin runs script when opened. Do not add an install source that a browser or a custom catalog file can name.
|
||||
- **A tool's config editor opens only what a RELEASE catalog file lists, and only an existing text file.** `config` is honoured in built-in and synced-release catalog files (never `DASHBOARD_AI_TOOLS_DIR`), each path `~/`-relative with no `..`, and no route takes a path from the request — `:index` picks from that list. `server/lib/ai-tool-config.js` refuses a missing file (it never creates one), a non-regular, binary or >1 MB file, and any whose basename — or its symlink target's — matches `CONFIG_SECRET_RE` (auth, credential, token, oauth, key files…; `ESECRET`); a save must match the opened `baseSha`, parse when JSON/JSONC, and back up the old text under `<data dir>/ai-tool-config-backups/` first. Do not add a create, a delete, a request-supplied path, or a catalog entry naming a login file. The guard is by NAME only: a listed settings file may still hold an API key (opencode `options.apiKey`, an MCP `env`), and the unguarded GET returns it whole, like the cc-config reads do — do not describe this route as secret-free.
|
||||
- **A tool's config editor opens only what a RELEASE catalog file lists, and only an existing text file.** `config` is honoured in built-in and synced-release catalog files (never `DASHBOARD_AI_TOOLS_DIR`), each path `~/`-relative with no `..`, and no route takes a path from the request — `:index` picks from that list. `server/lib/ai-tool-config.js` refuses a missing file (it never creates one), a non-regular, binary or >1 MB file, and any whose basename — or its symlink target's — matches `CONFIG_SECRET_RE` (auth, credential, token, oauth, key files…; `ESECRET`); a save must match the opened `baseSha`, parse when JSON/JSONC, and back up the old text under `<data dir>/ai-tool-config-backups/` first. Do not add a create, a delete, a request-supplied path, or a catalog entry naming a login file. The guard is by NAME only: a listed settings file may still hold an API key (a provider `apiKey`, an MCP `env`), and the unguarded GET returns it whole, like the cc-config reads do — do not describe this route as secret-free.
|
||||
- **A lane's card position is a viewer preference; its name is a lane fact.** The sidebar workspace list's drag/Alt+↑/↓ order lives in `localStorage` (`agenthub.workspace.laneOrder`, `client/src/lib/laneOrderStorage.ts`) and writes NOTHING server-side — `GET /api/lanes` and `agenthub lanes` stay in id order, so no `sort_order` column, no reorder route, no websocket message. Its groups (`client/src/lib/workspaceGroups.ts`) are the same kind of viewer arrangement — no lane column, nothing in the CLI — but kept per ACCOUNT as the synced preference `agenthub.workspace.groups` (`/api/me/prefs`, 64 KB), so they outlive the browser; deleting a group only ungroups. Renaming is the opposite: it is `PATCH /api/lanes/:id` with `{title}`, the one lane column a user now types into directly, so `updateLane` normalizes it (`normalizeTitle`: trim, 200-char cap, `null` ⇒ `""`, because clearing is legitimate — every render is `lane.title || lane.cwd`). The rule is the column-wide one, not a special case for the field the UI exposes: `bindable` rejects any non-scalar patch value with `EBADFIELD` (`links` excepted — it is stored as JSON), and the route answers 400 for that AND for the `SQLITE_CONSTRAINT` family, rather than handing better-sqlite3 a datatype throw and reporting 500 on plain bad input.
|
||||
- **Adopted lanes are not destroyable.** A lane with `kind === "adopted"` may never have its worktree reset or removed; "removing" one only drops the dashboard's own record of it. This is enforced in code (`assertDestroyable`'s first check) — do not add a path that bypasses it.
|
||||
- **Removing a lane deletes the lane's RECORD and nothing else — for every kind, managed included.** `POST /api/lanes/:id/remove` stops the run and the stack, clears AgentHub's own runtime bookkeeping under `LANES_ROOT/.state/lane<slot>/`, and deletes the row. It calls neither `removeWorktree` nor `removeLaneData`: the working tree, its branch and the lane's database are the USER's data, and a mis-click on a dashboard row may never cost a branch's unpushed code. Deleting those is the user's own `git worktree remove` / `git branch -d` / db drop. Consequently `remove` has no unpushed-commits force-gate (only `reset` does) and no preflight blocker stops it. Do not add a "also delete the worktree" flag back onto this route. (A session's own `claude --worktree` tree is a different thing with its own guarded route — see the session-worktree rule below; a lane's folder is never it.)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 411 KiB After Width: | Height: | Size: 465 KiB |
@@ -49,7 +49,7 @@
|
||||
<g transform="translate(20,22)"><rect width="48" height="48" rx="12" fill="#4cc3f5" fill-opacity="0.12"/><rect x="11" y="11" width="11" height="11" rx="3" fill="#4cc3f5"/><rect x="26" y="11" width="11" height="11" rx="3" fill="none" stroke="#4cc3f5" stroke-width="2.5"/><rect x="11" y="26" width="11" height="11" rx="3" fill="none" stroke="#4cc3f5" stroke-width="2.5"/><rect x="26" y="26" width="11" height="11" rx="3" fill="#4cc3f5"/></g>
|
||||
<text x="84" y="44" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="21" font-weight="700" fill="#f1f5ff">Every AI coding agent</text>
|
||||
<text x="84" y="72" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="16" fill="#9aa6c4">Claude Code, Codex, Antigravity,</text>
|
||||
<text x="84" y="94" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="16" fill="#9aa6c4">Cursor, Kimi, Grok and more</text>
|
||||
<text x="84" y="94" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="16" fill="#9aa6c4">Cursor, omp and more</text>
|
||||
</g>
|
||||
<g transform="translate(792,300)">
|
||||
<rect width="344" height="116" rx="16" fill="#ffffff" fill-opacity="0.045" stroke="#ffffff" stroke-opacity="0.10"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
@@ -50,9 +50,7 @@ const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = {
|
||||
codex: "Codex",
|
||||
antigravity: "Antigravity",
|
||||
cursor: "Cursor",
|
||||
grok: "Grok",
|
||||
kimi: "Kimi",
|
||||
opencode: "OpenCode Go",
|
||||
};
|
||||
|
||||
function localDate(d = new Date()): string {
|
||||
|
||||
@@ -69,7 +69,7 @@ const TOOLS: AiToolsView = {
|
||||
tool("codex", "Codex", { launcher: "codex", proxyCapable: true, prompt: null }),
|
||||
tool("gemini", "Gemini"),
|
||||
tool("aider", "Aider", { installed: false, path: null }),
|
||||
tool("grok", "Grok", { enabled: false }),
|
||||
tool("goose", "Goose", { enabled: false }),
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ const VIEW: AiToolsView = {
|
||||
tools: [
|
||||
tool("claude", "Claude Code"),
|
||||
tool("gemini", "Gemini"),
|
||||
tool("grok", "Grok", { enabled: false }),
|
||||
tool("goose", "Goose", { enabled: false }),
|
||||
tool("aider", "Aider", { installed: false, path: null }),
|
||||
],
|
||||
};
|
||||
@@ -155,9 +155,9 @@ describe("AiToolsSettings", () => {
|
||||
await user.click(within(gemini).getByText("Set default"));
|
||||
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { default: true });
|
||||
// A disabled tool cannot become the default.
|
||||
const grok = screen.getByTestId("ai-tool-grok");
|
||||
expect(within(grok).getByText("Set default")).toBeDisabled();
|
||||
expect(within(grok).getByRole("switch")).toHaveAttribute("aria-checked", "false");
|
||||
const goose = screen.getByTestId("ai-tool-goose");
|
||||
expect(within(goose).getByText("Set default")).toBeDisabled();
|
||||
expect(within(goose).getByRole("switch")).toHaveAttribute("aria-checked", "false");
|
||||
});
|
||||
|
||||
it("refreshes detection", async () => {
|
||||
@@ -179,7 +179,7 @@ describe("AiToolsSettings", () => {
|
||||
await user.click(screen.getByRole("menuitem", { name: "Disable" }));
|
||||
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { enabled: false });
|
||||
// A disabled tool cannot become the default from the menu either.
|
||||
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("ai-tool-grok") });
|
||||
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("ai-tool-goose") });
|
||||
expect(screen.getByRole("menuitem", { name: "Set default" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -301,11 +301,9 @@ describe("AiToolsSettings — install and update", () => {
|
||||
describe("AiToolIcon", () => {
|
||||
it("retries the icon when the same instance shows another tool", async () => {
|
||||
const { AiToolIcon } = await import("../../AiToolIcon");
|
||||
const { container, rerender } = render(
|
||||
<AiToolIcon tool={{ id: "opencode", label: "OpenCode" }} />
|
||||
);
|
||||
const { container, rerender } = render(<AiToolIcon tool={{ id: "aider", label: "Aider" }} />);
|
||||
fireEvent.error(container.querySelector("img")!);
|
||||
expect(screen.getByText("O")).toBeInTheDocument();
|
||||
expect(screen.getByText("A")).toBeInTheDocument();
|
||||
rerender(<AiToolIcon tool={{ id: "cursor", label: "Cursor CLI" }} />);
|
||||
expect(container.querySelector('img[src="/api/ai-tools/cursor/icon"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -732,10 +732,9 @@ 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), `"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`. */
|
||||
* hooks), `"cursor"` (Cursor's agent transcripts, no tokens) or `"omp"`
|
||||
* (its own session journals, with tokens and subagents). Rows an older build
|
||||
* wrote may still say `"opencode"` or `"grok"`. 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.
|
||||
@@ -1217,7 +1216,7 @@ export interface CostResult {
|
||||
|
||||
/** GET /api/pricing/quota: plan quota spent per native tool (server/lib/quota.js). */
|
||||
export interface ToolQuota {
|
||||
id: "claude" | "codex" | "antigravity" | "cursor" | "grok" | "kimi" | "opencode";
|
||||
id: "claude" | "codex" | "antigravity" | "cursor" | "kimi";
|
||||
/** One per plan window: `5h`, `wk`, `mo`, …; `usedPercent` is 0–100.
|
||||
* `group` names the model pool a window belongs to (Antigravity's). */
|
||||
windows: Array<{
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -260,7 +260,6 @@ DASHBOARD_ANTIGRAVITY_POLL_MS=15000 # agy conversation files → Antigravity
|
||||
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
@@ -1838,7 +1838,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` — 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 workspace can run any AI coding CLI in the AI Tools catalog (`server/data/ai-tools/*.json` — five built in: Claude Code, Codex, Antigravity, Cursor CLI (`cursor-agent`) 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:
|
||||
|
||||
|
||||
+4
-7
@@ -310,13 +310,10 @@ per model and one agent per subagent the `task` tool spawned (its journal sits i
|
||||
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>`).
|
||||
Cursor sessions resume by id too (`--resume <id>`), and open as a chat like
|
||||
omp ones. OpenCode and Grok Build are no longer AgentHub tools: a Grok Build
|
||||
running the Claude hooks in `~/.claude/settings.json` is ignored, and sessions an
|
||||
older build recorded for either stay in the history.
|
||||
|
||||
**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
|
||||
|
||||
@@ -51,13 +51,12 @@ const env = {
|
||||
// No suite may fetch LiteLLM's price list (reset-pricing syncs at once).
|
||||
AGENTHUB_PRICING_SYNC: "0",
|
||||
// Nor run the developer's real agy (it may update itself) or send their
|
||||
// real Cursor, Grok, Kimi or OpenCode login for the quota.
|
||||
// real Cursor or Kimi login for the quota.
|
||||
AGENTHUB_AGY_USAGE: "0",
|
||||
// Nor refresh (spend) a real Claude pool login: its refresh token is single use.
|
||||
DASHBOARD_POOL_REFRESH_MS: "0",
|
||||
XDG_CONFIG_HOME: tmuxDir,
|
||||
XDG_DATA_HOME: tmuxDir,
|
||||
GROK_HOME: tmuxDir,
|
||||
KIMI_CODE_HOME: tmuxDir,
|
||||
DASHBOARD_PIPELINES_DIR: path.join(dir, "fixtures", "pipelines"),
|
||||
// Never this machine's server-mode S3 install: its system unit would become
|
||||
|
||||
+6
-17
@@ -655,23 +655,12 @@ An `antigravity` workspace carries no pipeline either.
|
||||
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.
|
||||
- **Grok hooks:** Grok is not an AgentHub tool, but it runs the hooks in
|
||||
`~/.claude/settings.json` (its Claude compatibility), so AgentHub's own hook
|
||||
can still reach `POST /api/hooks/event` with no `tool`; `routes/hooks.js`
|
||||
tells it apart by the camelCase keys only Grok sends (`hookEventName` +
|
||||
`workspaceRoot`) and drops it, rather than record a Claude session whose
|
||||
`transcript_path` is Grok's file.
|
||||
- **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
|
||||
|
||||
@@ -45,7 +45,7 @@ before(async () => {
|
||||
builtin,
|
||||
tool("gem", [
|
||||
{ path: "~/.gem/settings.json" },
|
||||
{ path: "~/.gem/opencode.jsonc" },
|
||||
{ path: "~/.gem/tool.jsonc" },
|
||||
{ path: "~/.gem/GEMINI.md" },
|
||||
{ path: "~/.gem/missing.json" },
|
||||
])
|
||||
@@ -57,7 +57,7 @@ before(async () => {
|
||||
|
||||
fs.mkdirSync(path.join(home, ".gem"));
|
||||
fs.writeFileSync(path.join(home, ".gem/settings.json"), '{"theme":"dark"}\n', { mode: 0o600 });
|
||||
fs.writeFileSync(path.join(home, ".gem/opencode.jsonc"), '{\n // c\n "a": 1,\n}\n');
|
||||
fs.writeFileSync(path.join(home, ".gem/tool.jsonc"), '{\n // c\n "a": 1,\n}\n');
|
||||
fs.writeFileSync(path.join(home, ".gem/GEMINI.md"), "# rules\n");
|
||||
|
||||
const express = require("express");
|
||||
|
||||
@@ -26,8 +26,28 @@ for (const name of ["claude", "agy", "npm"]) {
|
||||
const origPath = process.env.PATH;
|
||||
process.env.PATH = BIN;
|
||||
|
||||
// An npm-installed tool, in the release-synced tier (only a release catalog may
|
||||
// name an installer): no built-in tool updates through npm.
|
||||
const REMOTE = path.join(ROOT, "remote-catalog");
|
||||
fs.mkdirSync(REMOTE);
|
||||
fs.writeFileSync(
|
||||
path.join(REMOTE, "npmtool.json"),
|
||||
JSON.stringify({
|
||||
id: "npmtool",
|
||||
label: "npm tool",
|
||||
detect: ["npmtool"],
|
||||
prompt: { mode: "flag", flag: "--prompt" },
|
||||
install: { kind: "npm", package: "npmtool" },
|
||||
update: { kind: "npm" },
|
||||
})
|
||||
);
|
||||
|
||||
const { createApp } = require("../index");
|
||||
require("../lib/ai-tools").refresh();
|
||||
require("../lib/ai-tools").__setCatalogDirs([
|
||||
path.join(__dirname, "..", "data", "ai-tools"),
|
||||
REMOTE,
|
||||
path.join(ROOT, "custom-catalog"),
|
||||
]);
|
||||
|
||||
let app;
|
||||
let base;
|
||||
@@ -59,7 +79,7 @@ describe("/api/ai-tools", () => {
|
||||
assert.equal(agy.path, path.join(BIN, "agy"));
|
||||
assert.equal(agy.resume, true);
|
||||
assert.equal(agy.bypass, true);
|
||||
assert.equal(body.tools.find((t) => t.id === "opencode").installed, false);
|
||||
assert.equal(body.tools.find((t) => t.id === "npmtool").installed, false);
|
||||
assert.equal("args" in agy, false);
|
||||
});
|
||||
|
||||
@@ -76,7 +96,7 @@ describe("/api/ai-tools", () => {
|
||||
|
||||
it("answers ENOTOOL / EBADDEFAULT / EBADFIELD", async () => {
|
||||
assert.equal((await request("PUT", "/api/ai-tools/nope", { enabled: false })).status, 404);
|
||||
const bad = await request("PUT", "/api/ai-tools/opencode", { default: true });
|
||||
const bad = await request("PUT", "/api/ai-tools/npmtool", { default: true });
|
||||
assert.equal(bad.status, 400);
|
||||
assert.equal(bad.body.error.code, "EBADDEFAULT");
|
||||
assert.equal((await request("PUT", "/api/ai-tools/claude", { enabled: "no" })).status, 400);
|
||||
@@ -112,10 +132,10 @@ describe("/api/ai-tools", () => {
|
||||
});
|
||||
|
||||
it("refresh re-detects", async () => {
|
||||
fs.writeFileSync(path.join(BIN, "opencode"), "#!/bin/sh\n");
|
||||
fs.chmodSync(path.join(BIN, "opencode"), 0o755);
|
||||
fs.writeFileSync(path.join(BIN, "npmtool"), "#!/bin/sh\n");
|
||||
fs.chmodSync(path.join(BIN, "npmtool"), 0o755);
|
||||
const r = await request("POST", "/api/ai-tools/refresh");
|
||||
assert.equal(r.body.tools.find((t) => t.id === "opencode").installed, true);
|
||||
assert.equal(r.body.tools.find((t) => t.id === "npmtool").installed, true);
|
||||
});
|
||||
|
||||
it("/api/run/binary resolves through the catalog", async () => {
|
||||
@@ -132,9 +152,9 @@ describe("lanes on catalog tools", () => {
|
||||
fs.mkdirSync(d, { recursive: true });
|
||||
return d;
|
||||
};
|
||||
let r = await request("POST", "/api/lanes", { cwd: dir("a"), title: "a", tool: "opencode" });
|
||||
let r = await request("POST", "/api/lanes", { cwd: dir("a"), title: "a", tool: "npmtool" });
|
||||
assert.equal(r.status, 201);
|
||||
assert.equal(r.body.lane.tool, "opencode");
|
||||
assert.equal(r.body.lane.tool, "npmtool");
|
||||
r = await request("POST", "/api/lanes", { cwd: dir("b"), title: "b", tool: "omp" });
|
||||
assert.equal(r.status, 201, "a tool that is not installed may still get a lane");
|
||||
r = await request("POST", "/api/lanes", { cwd: dir("c"), title: "c", tool: "nope" });
|
||||
@@ -212,8 +232,8 @@ describe("install / update / icons", () => {
|
||||
const notInstalled = await request("POST", "/api/ai-tools/codex/update");
|
||||
assert.equal(notInstalled.status, 409);
|
||||
assert.equal(notInstalled.body.error.code, "ENOTINSTALLED");
|
||||
// opencode was put on PATH by the refresh test, outside any npm prefix.
|
||||
const notNpm = await request("POST", "/api/ai-tools/opencode/update");
|
||||
// npmtool was put on PATH by the refresh test, outside any npm prefix.
|
||||
const notNpm = await request("POST", "/api/ai-tools/npmtool/update");
|
||||
assert.equal(notNpm.status, 409);
|
||||
assert.equal(notNpm.body.error.code, "ENOTNPM");
|
||||
const cross = await request("POST", "/api/ai-tools/codex/install", undefined, {
|
||||
@@ -241,7 +261,7 @@ describe("install / update / icons", () => {
|
||||
assert.deepEqual(spawned.at(-1), [path.join(BIN, "claude"), "update"]);
|
||||
const v = await request("GET", "/api/ai-tools/versions");
|
||||
assert.equal(v.status, 200);
|
||||
assert.equal(v.body.opencode.npmManaged, false);
|
||||
assert.equal(v.body.npmtool.npmManaged, false);
|
||||
});
|
||||
|
||||
it("update-all answers ENOTHING when nothing has an update", async () => {
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "grok",
|
||||
"label": "Grok",
|
||||
"detect": ["grok"],
|
||||
"id": "septool",
|
||||
"label": "Separator tool",
|
||||
"detect": ["septool"],
|
||||
"prompt": {
|
||||
"mode": "argv",
|
||||
"separator": "--"
|
||||
@@ -2,10 +2,10 @@
|
||||
* @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.
|
||||
* (`hookEventName`, `workspaceRoot`), and dropped — Grok is not an AgentHub
|
||||
* tool, and taken as Claude's it would bind the claude workspace and name a
|
||||
* Grok file as a Claude transcript. A Claude hook is left Claude's. The DB
|
||||
* lives in a temp dir.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -58,23 +58,18 @@ const grokData = (id, extra = {}) => ({
|
||||
});
|
||||
|
||||
describe("Grok hooks through the Claude settings", () => {
|
||||
it("records a grok session, on the grok workspace, with no transcript path", async () => {
|
||||
it("drops a Grok hook: no session, no event, no workspace bound", 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) });
|
||||
const r = await post({ hook_type: "SessionStart", data: grokData(id) });
|
||||
assert.equal(r.ignored, true);
|
||||
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(db.prepare("SELECT * FROM sessions WHERE id = ?").get(id), undefined);
|
||||
assert.equal(db.prepare("SELECT COUNT(*) n FROM events WHERE session_id = ?").get(id).n, 0);
|
||||
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 () => {
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
/**
|
||||
* @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", "omp"]) {
|
||||
for (const name of ["agy", "septool", "goose", "command-code", "omp"]) {
|
||||
fs.writeFileSync(path.join(BIN, name), "#!/bin/sh\n");
|
||||
fs.chmodSync(path.join(BIN, name), 0o755);
|
||||
}
|
||||
@@ -854,7 +854,7 @@ describe("pty-run", () => {
|
||||
);
|
||||
assert.deepEqual(argvFor("antigravity"), [at("agy"), "-i", " -x y"]);
|
||||
// Behind a `--` separator the prompt is already a positional, kept as typed.
|
||||
assert.deepEqual(argvFor("grok"), [at("grok"), "--", "-x y"]);
|
||||
assert.deepEqual(argvFor("septool"), [at("septool"), "--", "-x y"]);
|
||||
assert.deepEqual(argvFor("command-code"), [at("command-code"), "--trust", " -x y"]);
|
||||
assert.deepEqual(argvFor("antigravity", { bypass: false }), [at("agy"), "-i", " -x y"]);
|
||||
});
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
* with the usage of a token that belongs to a different account; each
|
||||
* tool's plan name; the Cursor CLI's login asked of cursor.com only while
|
||||
* valid; Antigravity's `agy --print /usage` pools, never on an old agy; and
|
||||
* the Grok, Kimi Code and OpenCode Go logins, each sent only to its own host
|
||||
* while valid, never refreshed.
|
||||
* the Kimi Code login, sent only to its own host while valid, never
|
||||
* refreshed.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -22,8 +22,8 @@ process.env.XDG_CONFIG_HOME = require("node:fs").mkdtempSync(
|
||||
);
|
||||
// Nor run the machine's real agy: only the Antigravity test's fake one runs.
|
||||
process.env.AGENTHUB_AGY_USAGE = "0";
|
||||
// Nor its real Grok, Kimi or OpenCode logins.
|
||||
for (const k of ["GROK_HOME", "KIMI_CODE_HOME", "XDG_DATA_HOME"]) {
|
||||
// Nor its real Kimi login.
|
||||
for (const k of ["KIMI_CODE_HOME"]) {
|
||||
process.env[k] = require("node:fs").mkdtempSync(
|
||||
require("node:path").join(require("node:os").tmpdir(), "quota-home-")
|
||||
);
|
||||
@@ -416,68 +416,6 @@ function stubFetch(answers) {
|
||||
return { asked, restore: () => (global.fetch = prev) };
|
||||
}
|
||||
|
||||
test("Grok: the CLI's own auth.x.ai session asks cli-chat-proxy for credits + plan, never past expiry", async () => {
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { quotas, __resetCache } = require("../lib/quota");
|
||||
const auth = path.join(process.env.GROK_HOME, "auth.json");
|
||||
const f = stubFetch({
|
||||
"https://cli-chat-proxy.grok.com/v1/billing?format=credits": [
|
||||
200,
|
||||
{
|
||||
config: {
|
||||
creditUsagePercent: 12,
|
||||
currentPeriod: { end: "2099-01-08T00:00:00Z" },
|
||||
subscriptionTier: "SuperGrok",
|
||||
monthlyLimit: { val: "200" },
|
||||
used: { val: "50" },
|
||||
billingPeriodEnd: "2099-02-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const grok = async () => (await quotas()).tools.find((t) => t.id === "grok");
|
||||
try {
|
||||
__resetCache();
|
||||
assert.strictEqual(await grok(), undefined);
|
||||
const later = new Date(Date.now() + 3600_000).toISOString();
|
||||
fs.writeFileSync(
|
||||
auth,
|
||||
JSON.stringify({
|
||||
"https://other.example": { key: "tok-other", expires_at: later },
|
||||
"https://auth.x.ai": { key: "tok-x", user_id: "u1", email: "g@x.ai", expires_at: later },
|
||||
})
|
||||
);
|
||||
__resetCache();
|
||||
assert.deepStrictEqual(await grok(), {
|
||||
id: "grok",
|
||||
active: true,
|
||||
account: "g@x.ai",
|
||||
plan: "SuperGrok",
|
||||
expiresAt: later,
|
||||
windows: [
|
||||
{ label: "wk", usedPercent: 12, resetsAt: "2099-01-08T00:00:00.000Z" },
|
||||
{ label: "mo", usedPercent: 25, resetsAt: "2099-02-01T00:00:00.000Z" },
|
||||
],
|
||||
});
|
||||
assert.deepStrictEqual(
|
||||
f.asked.filter((a) => a.includes("grok.com")),
|
||||
["https://cli-chat-proxy.grok.com/v1/billing?format=credits Bearer tok-x"]
|
||||
);
|
||||
fs.writeFileSync(
|
||||
auth,
|
||||
JSON.stringify({ "https://auth.x.ai": { key: "tok-x", expires_at: "2000-01-01T00:00:00Z" } })
|
||||
);
|
||||
__resetCache();
|
||||
assert.strictEqual((await grok()).error, "ETOKENEXPIRED");
|
||||
assert.strictEqual(f.asked.filter((a) => a.includes("grok.com")).length, 1);
|
||||
} finally {
|
||||
f.restore();
|
||||
fs.rmSync(auth, { force: true });
|
||||
__resetCache();
|
||||
}
|
||||
});
|
||||
|
||||
test("Kimi Code: the CLI's own token asks api.kimi.com/coding for 5h, week and month", async () => {
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
@@ -532,55 +470,3 @@ test("Kimi Code: the CLI's own token asks api.kimi.com/coding for 5h, week and m
|
||||
__resetCache();
|
||||
}
|
||||
});
|
||||
|
||||
test("OpenCode Go: opencode's own `opencode-go` key asks opencode.ai for its meters", async () => {
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { quotas, __resetCache } = require("../lib/quota");
|
||||
const dir = path.join(process.env.XDG_DATA_HOME, "opencode");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const file = path.join(dir, "auth.json");
|
||||
const f = stubFetch({
|
||||
"https://opencode.ai/zen/go/v1/usage": [
|
||||
200,
|
||||
{
|
||||
usage: {
|
||||
rolling: { percent: 10, resetsAt: "2099-01-01T05:00:00Z" },
|
||||
weekly: { percent: 55.5, resetsAt: "2099-01-07T00:00:00Z" },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const go = async () => (await quotas()).tools.find((t) => t.id === "opencode");
|
||||
try {
|
||||
__resetCache();
|
||||
// A login with no Go key (Copilot through opencode, say) is no row.
|
||||
fs.writeFileSync(file, JSON.stringify({ "github-copilot": { type: "oauth", access: "a" } }));
|
||||
assert.strictEqual(await go(), undefined);
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
"opencode-go": { type: "api", key: "sk-go" },
|
||||
other: { type: "api", key: "x" },
|
||||
})
|
||||
);
|
||||
__resetCache();
|
||||
assert.deepStrictEqual(await go(), {
|
||||
id: "opencode",
|
||||
active: true,
|
||||
plan: "Go",
|
||||
windows: [
|
||||
{ label: "5h", usedPercent: 10, resetsAt: "2099-01-01T05:00:00.000Z" },
|
||||
{ label: "wk", usedPercent: 55.5, resetsAt: "2099-01-07T00:00:00.000Z" },
|
||||
],
|
||||
});
|
||||
assert.deepStrictEqual(
|
||||
f.asked.filter((a) => a.includes("opencode")),
|
||||
["https://opencode.ai/zen/go/v1/usage Bearer sk-go"]
|
||||
);
|
||||
} finally {
|
||||
f.restore();
|
||||
fs.rmSync(file, { force: true });
|
||||
__resetCache();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @file server-s3-quota.test.js
|
||||
* @description S3 plan, P4: plan quota per member home. `quotas({home})`
|
||||
* reads Claude, Codex, Cursor, Grok, Kimi Code and OpenCode Go from that
|
||||
* reads Claude, Codex, Cursor and Kimi Code from that
|
||||
* Linux account's home only — never the host's logins, never agy — and only
|
||||
* files the member owns (review fix F3: a FIFO or another account's file is
|
||||
* not read). `GET /api/pricing/quota` gives a developer or an analyst their
|
||||
@@ -32,16 +32,13 @@ process.env.CLAUDE_HOME = path.join(HOST, ".claude");
|
||||
process.env.CODEX_HOME = path.join(HOST, ".codex");
|
||||
process.env.XDG_CONFIG_HOME = path.join(HOST, ".config");
|
||||
process.env.AGENTHUB_CURSOR_DIR = path.join(HOST, ".cursor");
|
||||
process.env.GROK_HOME = path.join(HOST, ".grok");
|
||||
process.env.KIMI_CODE_HOME = path.join(HOST, ".kimi-code");
|
||||
process.env.XDG_DATA_HOME = path.join(HOST, ".local", "share");
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const auth = require("../lib/auth");
|
||||
const linux = require("../lib/linux-accounts");
|
||||
const runAs = require("../lib/run-as");
|
||||
const { quotas, __resetCache } = require("../lib/quota");
|
||||
const { Database } = require("../db");
|
||||
|
||||
const LATER = Date.now() + 3600_000;
|
||||
const write = (file, body) => {
|
||||
@@ -76,28 +73,12 @@ function seedHome(home, who, { codexPercent }) {
|
||||
write(path.join(home, ".cursor", "cli-config.json"), {
|
||||
authInfo: { email: `${who}@cursor`, userId: "u1" },
|
||||
});
|
||||
write(path.join(home, ".grok", "auth.json"), {
|
||||
"https://auth.x.ai": { key: `${who}-grok`, expires_at: new Date(LATER).toISOString() },
|
||||
});
|
||||
write(path.join(home, ".kimi-code", "credentials", "kimi-code.json"), {
|
||||
access_token: `${who}-kimi`,
|
||||
expires_at: Math.floor(LATER / 1000),
|
||||
});
|
||||
}
|
||||
|
||||
/** OpenCode 2 keeps its key in `opencode.db` (WAL), not `auth.json`. */
|
||||
function seedOpenCodeDb(home, key) {
|
||||
const dir = path.join(home, ".local", "share", "opencode");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const db = new Database(path.join(dir, "opencode.db"));
|
||||
db.pragma?.("journal_mode = WAL");
|
||||
db.exec(
|
||||
"CREATE TABLE credential (integration_id TEXT, value TEXT, active INT, time_created INT)"
|
||||
);
|
||||
db.prepare("INSERT INTO credential VALUES ('opencode-go', ?, 1, 1)").run(JSON.stringify({ key }));
|
||||
db.close();
|
||||
}
|
||||
|
||||
let asked = [];
|
||||
const realFetch = global.fetch;
|
||||
global.fetch = async (url, opts = {}) => {
|
||||
@@ -125,11 +106,7 @@ const setUid = (uid) => {
|
||||
|
||||
before(async () => {
|
||||
seedHome(HOST, "host", { codexPercent: 77 });
|
||||
write(path.join(HOST, ".local", "share", "opencode", "auth.json"), {
|
||||
"opencode-go": { type: "api", key: "host-opencode" },
|
||||
});
|
||||
seedHome(MIA, "mia", { codexPercent: 21 });
|
||||
seedOpenCodeDb(MIA, "mia-opencode");
|
||||
setUid(process.getuid());
|
||||
fs.writeFileSync(
|
||||
process.env.AGENTHUB_SERVER_CONF,
|
||||
@@ -159,27 +136,16 @@ describe("quotas({home}) reads a member's own logins", () => {
|
||||
__resetCache();
|
||||
asked = [];
|
||||
const { tools } = await quotas({ home: MIA });
|
||||
assert.deepEqual(tools.map((t) => t.id).sort(), [
|
||||
"claude",
|
||||
"codex",
|
||||
"cursor",
|
||||
"grok",
|
||||
"kimi",
|
||||
"opencode",
|
||||
]);
|
||||
assert.deepEqual(tools.map((t) => t.id).sort(), ["claude", "codex", "cursor", "kimi"]);
|
||||
const codex = tools.find((t) => t.id === "codex");
|
||||
assert.equal(codex.account, "mia@codex");
|
||||
assert.equal(codex.windows[0].usedPercent, 21);
|
||||
assert.equal(tools.find((t) => t.id === "cursor").account, "mia@cursor");
|
||||
assert.ok(asked.length >= 6, asked.join(","));
|
||||
assert.ok(asked.length >= 4, asked.join(","));
|
||||
assert.ok(
|
||||
asked.every((a) => a.includes("mia-")),
|
||||
`only mia's tokens leave: ${asked.join(",")}`
|
||||
);
|
||||
assert.ok(
|
||||
asked.some((a) => a.includes("mia-opencode")),
|
||||
"OpenCode 2's db is read"
|
||||
);
|
||||
assert.ok(tools.every((t) => t.label === undefined || t.label === null));
|
||||
});
|
||||
|
||||
@@ -195,8 +161,10 @@ describe("quotas({home}) reads a member's own logins", () => {
|
||||
});
|
||||
|
||||
it("reads no file the member does not own, and never blocks on a FIFO", async () => {
|
||||
fs.mkdirSync(path.join(BOB, ".grok"), { recursive: true });
|
||||
require("node:child_process").execFileSync("mkfifo", [path.join(BOB, ".grok", "auth.json")]);
|
||||
fs.mkdirSync(path.join(BOB, ".kimi-code", "credentials"), { recursive: true });
|
||||
require("node:child_process").execFileSync("mkfifo", [
|
||||
path.join(BOB, ".kimi-code", "credentials", "kimi-code.json"),
|
||||
]);
|
||||
__resetCache();
|
||||
assert.deepEqual((await quotas({ home: BOB })).tools, []);
|
||||
// mia's files now belong to another uid: none is read.
|
||||
|
||||
@@ -37,11 +37,11 @@ describe("what a publish copies", () => {
|
||||
|
||||
it("a file in an npm package: the package folder (scoped too)", () => {
|
||||
const plain = write(
|
||||
path.join(TMP, "npm", "lib", "node_modules", "opencode-ai", "bin", "oc.js"),
|
||||
path.join(TMP, "npm", "lib", "node_modules", "some-cli", "bin", "oc.js"),
|
||||
"#!/usr/bin/env node\n"
|
||||
);
|
||||
assert.deepEqual(shared.planFor(plain), {
|
||||
src: path.join(TMP, "npm", "lib", "node_modules", "opencode-ai"),
|
||||
src: path.join(TMP, "npm", "lib", "node_modules", "some-cli"),
|
||||
entry: path.join("bin", "oc.js"),
|
||||
});
|
||||
const scoped = write(
|
||||
|
||||
@@ -13,7 +13,7 @@ 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`). omp always gets `--allow-home`: started in its account's home (a workspace there) it would otherwise move to a temp dir, and its session would name that folder, not the workspace's. A session id may carry `_` (OpenCode's `ses_…`).
|
||||
Built-in: omp and Cursor (`--resume`). omp always gets `--allow-home`: started in its account's home (a workspace there) it would otherwise move to a temp dir, and its session would name that folder, not the workspace's. A session id may carry `_`.
|
||||
|
||||
## Install / update (A2)
|
||||
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"id": "grok",
|
||||
"label": "Grok Build",
|
||||
"detect": [
|
||||
"grok"
|
||||
],
|
||||
"prompt": {
|
||||
"mode": "argv",
|
||||
"separator": "--"
|
||||
},
|
||||
"model": {
|
||||
"flag": "--model"
|
||||
},
|
||||
"resume": [
|
||||
"--continue"
|
||||
],
|
||||
"resumeById": [
|
||||
"--resume"
|
||||
],
|
||||
"bypass": [
|
||||
"--permission-mode",
|
||||
"bypassPermissions"
|
||||
],
|
||||
"homepageUrl": "https://x.ai/cli",
|
||||
"faviconDomain": "x.ai",
|
||||
"install": {
|
||||
"kind": "script",
|
||||
"url": "https://x.ai/cli/install.sh"
|
||||
},
|
||||
"update": {
|
||||
"kind": "self",
|
||||
"args": [
|
||||
"update"
|
||||
]
|
||||
},
|
||||
"config": [
|
||||
{
|
||||
"path": "~/.grok/config.toml"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"id": "opencode",
|
||||
"label": "OpenCode",
|
||||
"detect": [
|
||||
"opencode"
|
||||
],
|
||||
"prompt": {
|
||||
"mode": "flag",
|
||||
"flag": "--prompt"
|
||||
},
|
||||
"model": {
|
||||
"flag": "--model"
|
||||
},
|
||||
"resume": [
|
||||
"--continue"
|
||||
],
|
||||
"resumeById": [
|
||||
"--session"
|
||||
],
|
||||
"homepageUrl": "https://opencode.ai/docs/cli/",
|
||||
"install": {
|
||||
"kind": "npm",
|
||||
"package": "opencode-ai"
|
||||
},
|
||||
"update": {
|
||||
"kind": "npm"
|
||||
},
|
||||
"config": [
|
||||
{
|
||||
"path": "~/.config/opencode/opencode.json",
|
||||
"format": "jsonc"
|
||||
},
|
||||
{
|
||||
"path": "~/.config/opencode/opencode.jsonc"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1486,23 +1486,6 @@ db.exec(`
|
||||
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(`
|
||||
|
||||
+5
-11
@@ -793,17 +793,11 @@ 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);
|
||||
}
|
||||
// Oh My Pi sessions, subagents and token usage, from its own session journals.
|
||||
try {
|
||||
require("./lib/omp-usage").startOmpUsagePoll(broadcast);
|
||||
} catch (err) {
|
||||
console.warn("omp 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
|
||||
|
||||
@@ -1,504 +0,0 @@
|
||||
/**
|
||||
* @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,
|
||||
};
|
||||
+7
-143
@@ -34,21 +34,12 @@
|
||||
* names no account and no plan. Cached like Claude; a failed run shows
|
||||
* the last figure with its `asOf`. Running agy may let it update itself;
|
||||
* `AGENTHUB_AGY_USAGE=0` never runs it.
|
||||
* - **Grok Build**: the CLI's `auth.x.ai` session in `$GROK_HOME/auth.json`
|
||||
* (default `~/.grok`), sent only to `cli-chat-proxy.grok.com`'s
|
||||
* `GET /v1/billing?format=credits`: `wk` from `creditUsagePercent`, `mo`
|
||||
* from `used / monthlyLimit`, plan `subscriptionTier`.
|
||||
* - **Kimi Code**: `$KIMI_CODE_HOME/credentials/kimi-code.json` (default
|
||||
* `~/.kimi-code`), sent only to `api.kimi.com`'s `GET /coding/v1/usages`:
|
||||
* `5h`, `wk`, `mo` from `limit_5h`/`limit_7d`/`limit_month_total`.
|
||||
* - **OpenCode Go**: opencode's own `opencode-go` API key — its `auth.json`
|
||||
* (OpenCode 1) else its `opencode.db` `credential` table (OpenCode 2),
|
||||
* under `$XDG_DATA_HOME/opencode` (default `~/.local/share`) — sent only to
|
||||
* `opencode.ai`'s `GET /zen/go/v1/usage`: `5h`, `wk`, `mo` meters. No Go
|
||||
* key (another provider's login) means no row.
|
||||
* Grok's and Kimi's tokens are used only while valid and never refreshed
|
||||
* (Kimi rotates its refresh token: a refresh here would sign the CLI out).
|
||||
* These three are Orca's readers (github.com/stablyai/orca, MIT), trimmed.
|
||||
* Kimi's token is used only while valid and never refreshed (Kimi rotates
|
||||
* its refresh token: a refresh here would sign the CLI out). This reader is
|
||||
* Orca's (github.com/stablyai/orca, MIT), trimmed.
|
||||
*
|
||||
* Each tool also carries its signed-in `account` (an email), its `plan` (the
|
||||
* subscription's name as the tool reports it: Claude's `subscriptionType`,
|
||||
@@ -59,8 +50,7 @@
|
||||
*
|
||||
* Server mode with S3 (`quotas({home})`): a member's plan is read from THEIR
|
||||
* Linux account's home only — `.claude`, `.codex`, `.config/cursor` +
|
||||
* `.cursor`, `.grok`, `.kimi-code`, `.local/share/opencode`, never the
|
||||
* host's env overrides — through `run-as`'s owner-checked reads (F3). No
|
||||
* `.cursor`, `.kimi-code`, never the host's env overrides — through `run-as`'s owner-checked reads (F3). No
|
||||
* saved Claude profiles (the Profiles modal is the host's) and no agy: its
|
||||
* `/usage` needs the self-updating binary, so it stays host-only.
|
||||
*
|
||||
@@ -80,9 +70,7 @@ const { readActiveAuth, readProfile, listProfiles } = require("./cc-profiles");
|
||||
const { fetchUsage, fetchOwner } = require("./cc-usage");
|
||||
const { allRolloutFiles } = require("./codex-ingest");
|
||||
const { getCursorDir } = require("./cursor-usage");
|
||||
const { Database } = require("../db");
|
||||
const { readFileSafe, openSafe, ownerForRead } = require("./run-as");
|
||||
const { s3Enabled } = require("./linux-accounts");
|
||||
const { readFileSafe, openSafe } = require("./run-as");
|
||||
|
||||
const CLAUDE_TTL_MS = 5 * 60_000;
|
||||
const FORCE_MIN_MS = 30_000; // a Refresh click still cannot hammer the endpoint
|
||||
@@ -91,9 +79,7 @@ const TAIL_BYTES = 512 * 1024;
|
||||
const CODEX_FILES = 5;
|
||||
const CURSOR_USAGE_URL = "https://cursor.com/api/usage-summary";
|
||||
const TIMEOUT_MS = 10_000;
|
||||
const GROK_BILLING_URL = "https://cli-chat-proxy.grok.com/v1/billing?format=credits";
|
||||
const KIMI_USAGE_URL = "https://api.kimi.com/coding/v1/usages";
|
||||
const OPENCODE_GO_URL = "https://opencode.ai/zen/go/v1/usage";
|
||||
const AGY_TIMEOUT_MS = 60_000;
|
||||
const AGY_MIN_VERSION = [1, 1, 11];
|
||||
const AGY_WINDOW = { "5h": "5h", weekly: "wk" };
|
||||
@@ -364,9 +350,7 @@ function toolDirs(home) {
|
||||
codex: home ? path.join(home, ".codex") : getCodexHome(),
|
||||
cursorConfig: path.join(env("XDG_CONFIG_HOME") || path.join(h, ".config"), "cursor"),
|
||||
cursor: home ? path.join(home, ".cursor") : getCursorDir(),
|
||||
grok: env("GROK_HOME") || path.join(h, ".grok"),
|
||||
kimi: env("KIMI_CODE_HOME") || path.join(h, ".kimi-code"),
|
||||
opencode: path.join(env("XDG_DATA_HOME") || path.join(h, ".local", "share"), "opencode"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -502,53 +486,6 @@ async function bearerWindows(who, url, token, headers, toWindows) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Grok Build's `auth.x.ai` session and its billing view. */
|
||||
async function grokQuota(dirs = toolDirs()) {
|
||||
const sessions = readJson(path.join(dirs.grok, "auth.json"));
|
||||
if (!sessions || typeof sessions !== "object") return null;
|
||||
const xai = Object.entries(sessions)
|
||||
.filter(([issuer, s]) => /^https:\/\/auth\.x\.ai(::|$)/.test(issuer) && s?.key)
|
||||
.map(([, s]) => s);
|
||||
const live = (s) => !s.expires_at || fresh(Date.parse(s.expires_at));
|
||||
const s = xai.find(live) || xai[0];
|
||||
if (!s) return null;
|
||||
const who = {
|
||||
id: "grok",
|
||||
active: true,
|
||||
account: s.email || null,
|
||||
plan: null,
|
||||
expiresAt: s.expires_at ? isoOf(s.expires_at) : null,
|
||||
};
|
||||
if (!live(s)) return { ...who, windows: [], error: "ETOKENEXPIRED" };
|
||||
const headers = {
|
||||
"X-XAI-Token-Auth": "xai-grok-cli",
|
||||
...(s.user_id && { "x-userid": String(s.user_id) }),
|
||||
};
|
||||
return bearerWindows(who, GROK_BILLING_URL, s.key, headers, (body) => {
|
||||
const c = body?.config || body || {};
|
||||
const windows = [];
|
||||
// ponytail: a reply with no percent shows no week; Orca infers 0 from
|
||||
// the period fields — add that if a real account ever answers so.
|
||||
if (typeof c.creditUsagePercent === "number") {
|
||||
windows.push({
|
||||
label: "wk",
|
||||
usedPercent: c.creditUsagePercent,
|
||||
resetsAt: isoOf(c.currentPeriod?.end ?? c.billingPeriodEnd),
|
||||
});
|
||||
}
|
||||
const limit = Number(c.monthlyLimit?.val);
|
||||
const used = Number(c.used?.val);
|
||||
if (limit > 0 && Number.isFinite(used)) {
|
||||
windows.push({
|
||||
label: "mo",
|
||||
usedPercent: (used / limit) * 100,
|
||||
resetsAt: isoOf(c.billingPeriodEnd),
|
||||
});
|
||||
}
|
||||
return { plan: c.subscriptionTier || null, windows };
|
||||
});
|
||||
}
|
||||
|
||||
/** Kimi Code's OAuth token and its managed-plan usage. */
|
||||
async function kimiQuota(dirs = toolDirs()) {
|
||||
const cred = readJson(path.join(dirs.kimi, "credentials", "kimi-code.json"));
|
||||
@@ -571,69 +508,6 @@ async function kimiQuota(dirs = toolDirs()) {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* A member's `opencode.db`, copied (with its WAL) from owner-checked reads
|
||||
* into a private temp dir — SQLite opens by NAME, which the member could swap
|
||||
* between a check and the open. The host's own is opened in place.
|
||||
*/
|
||||
function openCodeDbPath(dir, tmp) {
|
||||
const db = path.join(dir, "opencode.db");
|
||||
if (!s3Enabled() || !ownerForRead(db)) return db;
|
||||
const copy = path.join(tmp(), "opencode.db");
|
||||
fs.writeFileSync(copy, readFileSafe(db, null, { max: 256 << 20 })); // ponytail: whole file in memory, capped
|
||||
try {
|
||||
fs.writeFileSync(`${copy}-wal`, readFileSafe(`${db}-wal`, null, { max: 256 << 20 }));
|
||||
} catch {
|
||||
/* no WAL: everything is in the main file */
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
/** opencode's `opencode-go` key: `auth.json` (v1), else `opencode.db` (v2). */
|
||||
function openCodeGoKey(dirs = toolDirs()) {
|
||||
const dir = dirs.opencode;
|
||||
const entry = readJson(path.join(dir, "auth.json"))?.["opencode-go"];
|
||||
if (entry?.type === "api" && typeof entry.key === "string" && entry.key) return entry.key;
|
||||
let conn;
|
||||
let tmpDir = null;
|
||||
const tmp = () => (tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-opencode-")));
|
||||
try {
|
||||
const file = openCodeDbPath(dir, tmp);
|
||||
// The private copy is writable so SQLite can replay its WAL.
|
||||
conn = new Database(file, { readonly: !tmpDir, fileMustExist: true });
|
||||
const rows = conn
|
||||
.prepare(
|
||||
"SELECT value FROM credential WHERE integration_id = 'opencode-go' ORDER BY active DESC, time_created DESC LIMIT 8"
|
||||
)
|
||||
.all();
|
||||
for (const r of rows) {
|
||||
const v = JSON.parse(r.value);
|
||||
if (typeof v?.key === "string" && v.key) return v.key;
|
||||
}
|
||||
} catch {
|
||||
/* no OpenCode 2 database, or no credential table (OpenCode 1) */
|
||||
} finally {
|
||||
conn?.close();
|
||||
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function openCodeGoQuota(dirs = toolDirs()) {
|
||||
const key = openCodeGoKey(dirs);
|
||||
if (!key) return null;
|
||||
const who = { id: "opencode", active: true, plan: "Go" };
|
||||
return bearerWindows(who, OPENCODE_GO_URL, key, {}, (body) => ({
|
||||
windows: [
|
||||
["5h", body?.usage?.rolling],
|
||||
["wk", body?.usage?.weekly],
|
||||
["mo", body?.usage?.monthly],
|
||||
]
|
||||
.filter(([, m]) => typeof m?.percent === "number")
|
||||
.map(([label, m]) => ({ label, usedPercent: m.percent, resetsAt: isoOf(m.resetsAt) })),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every tool's quota row. `home`: a member's Linux home (S3) — that account's
|
||||
* logins only, and no agy; omitted, the host's.
|
||||
@@ -641,23 +515,13 @@ async function openCodeGoQuota(dirs = toolDirs()) {
|
||||
async function quotas({ force = false, home = null } = {}) {
|
||||
const dirs = toolDirs(home);
|
||||
const key = (k) => (home ? `${home}|${k}` : k);
|
||||
const [claude, cursor, antigravity, grok, kimi, opencode] = await Promise.all([
|
||||
const [claude, cursor, antigravity, kimi] = await Promise.all([
|
||||
claudeQuotas(force, home),
|
||||
cached(key("cursor"), force, () => cursorQuota(dirs)),
|
||||
home ? null : cached("antigravity", force, antigravityQuota),
|
||||
cached(key("grok"), force, () => grokQuota(dirs)),
|
||||
cached(key("kimi"), force, () => kimiQuota(dirs)),
|
||||
cached(key("opencode"), force, () => openCodeGoQuota(dirs)),
|
||||
]);
|
||||
const tools = [
|
||||
...claude,
|
||||
codexQuota(dirs.codex),
|
||||
antigravity,
|
||||
cursor,
|
||||
grok,
|
||||
kimi,
|
||||
opencode,
|
||||
].filter(Boolean);
|
||||
const tools = [...claude, codexQuota(dirs.codex), antigravity, cursor, kimi].filter(Boolean);
|
||||
return { tools, checkedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
|
||||
+12
-23
@@ -1473,12 +1473,13 @@ function antigravityHookInput(hookType, data) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* A Grok Build hook. Grok is not an AgentHub tool, but it runs the hooks in
|
||||
* `~/.claude/settings.json` itself (its Claude compatibility, on by default),
|
||||
* so AgentHub's own Claude hook still reaches here from a Grok the user
|
||||
* installed, with no `tool` — told apart by the camelCase keys only Grok sends
|
||||
* beside Claude's snake_case aliases (`hookEventName`, `workspaceRoot`) and
|
||||
* dropped, or it would land as a Claude session whose `transcript_path` is
|
||||
* Grok's file.
|
||||
*/
|
||||
const isGrokPayload = (data) =>
|
||||
!!data &&
|
||||
@@ -1486,18 +1487,9 @@ const isGrokPayload = (data) =>
|
||||
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,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1564,14 +1556,11 @@ 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), `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
|
||||
: isGrokPayload(data)
|
||||
? "grok"
|
||||
: "claude";
|
||||
// `antigravity` from agy's (antigravity-hooks.js), anything else is Claude
|
||||
// Code — an allowlist, never a value stored as sent. Grok running the
|
||||
// Claude hooks is no AgentHub tool: dropped.
|
||||
const tool = Object.hasOwn(HOOK_TOOLS, body.tool) ? body.tool : "claude";
|
||||
if (tool === "claude" && isGrokPayload(data)) return ignored;
|
||||
if (tool !== "claude") {
|
||||
if (!data || typeof data !== "object") return ignored;
|
||||
require("../lib/hook-auth").dropForeignPaths(peer, data);
|
||||
|
||||
@@ -843,25 +843,6 @@ router.get("/:id/transcripts", async (req, res) => {
|
||||
: [];
|
||||
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
|
||||
@@ -1101,20 +1082,6 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
const isCursor = session.tool === "cursor";
|
||||
// An agy conversation's step log (`transcript_full.jsonl`), found by antigravity-usage.js.
|
||||
const isAgy = session.tool === "antigravity";
|
||||
// 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 (isCursor) {
|
||||
@@ -1592,42 +1559,6 @@ function agyMessage(entry, num) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
module.exports.sessionStats = sessionStats;
|
||||
// Exported for unit tests — sender attribution is correctness-critical.
|
||||
|
||||
Reference in New Issue
Block a user