diff --git a/CLAUDE.md b/CLAUDE.md index d17523d6..b7f8ddbe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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:` (bare `/` 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 `/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 `/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=` 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, ` 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 `'/agenthub-hook.sh' ` 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 `'/agenthub-hook.sh' ` 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 `/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 `/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/`, 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.) diff --git a/client/public/og-image.png b/client/public/og-image.png index 079aaf36..d84c9789 100644 Binary files a/client/public/og-image.png and b/client/public/og-image.png differ diff --git a/client/public/og-image.svg b/client/public/og-image.svg index 406dff95..9dc958d7 100644 --- a/client/public/og-image.svg +++ b/client/public/og-image.svg @@ -49,7 +49,7 @@ Every AI coding agent Claude Code, Codex, Antigravity, - Cursor, Kimi, Grok and more + Cursor, omp and more diff --git a/client/src/components/StatusBar.tsx b/client/src/components/StatusBar.tsx index 86bd0a9e..774299af 100644 --- a/client/src/components/StatusBar.tsx +++ b/client/src/components/StatusBar.tsx @@ -50,9 +50,7 @@ const QUOTA_TOOL_LABEL: Record = { codex: "Codex", antigravity: "Antigravity", cursor: "Cursor", - grok: "Grok", kimi: "Kimi", - opencode: "OpenCode Go", }; function localDate(d = new Date()): string { diff --git a/client/src/components/lanes/__tests__/AddLaneModal.test.tsx b/client/src/components/lanes/__tests__/AddLaneModal.test.tsx index 50ee43d9..055d43a3 100644 --- a/client/src/components/lanes/__tests__/AddLaneModal.test.tsx +++ b/client/src/components/lanes/__tests__/AddLaneModal.test.tsx @@ -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 }), ], }; diff --git a/client/src/components/settings/__tests__/AiToolsSettings.test.tsx b/client/src/components/settings/__tests__/AiToolsSettings.test.tsx index 8c2bc275..e2dadaf5 100644 --- a/client/src/components/settings/__tests__/AiToolsSettings.test.tsx +++ b/client/src/components/settings/__tests__/AiToolsSettings.test.tsx @@ -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( - - ); + const { container, rerender } = render(); fireEvent.error(container.querySelector("img")!); - expect(screen.getByText("O")).toBeInTheDocument(); + expect(screen.getByText("A")).toBeInTheDocument(); rerender(); expect(container.querySelector('img[src="/api/ai-tools/cursor/icon"]')).toBeTruthy(); }); diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts index 04682e93..fa2cf7f3 100644 --- a/client/src/lib/types.ts +++ b/client/src/lib/types.ts @@ -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<{ diff --git a/docs/API.md b/docs/API.md index 988f17a7..172c32b0 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2578,7 +2578,7 @@ curl "http://localhost:4820/api/pricing/cost?tz_offset=-420&from=2026-09-01&to=2 GET /api/pricing/quota[?force=1] ``` -How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex"|"antigravity"|"cursor"|"grok"|"kimi"|"opencode", active, label?, account?, plan?, expiresAt?, windows: [{ label, usedPercent, resetsAt, group? }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. Each Claude token's owner is also asked (`GET /api/oauth/profile`): the live row carries the label and email of the account its token belongs to (`.claude.json` can name another), and a saved profile whose token belongs to a different account answers `error: "EWRONGACCOUNT"` with no windows, never that other account's usage. **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). **Antigravity** runs the catalog-detected `agy` as `agy --print /usage --output-format json` — the CLI's own `/usage` command, no model call (`num_turns` 0, no conversation) — in a private empty temp directory with a 60 s timeout, only while the tool is enabled in AI Tools and `agy --version` is ≥ 1.1.11 (an older agy may bill `/usage` as a model request); cached like Claude, a failed run shows the last figure (`asOf`) or `error: "EUPSTREAM"`. One window per pool bucket (`5h`, `wk`), `group` naming the pool ("Gemini Models", "Claude and GPT models"); the report names no account and no plan. Running agy may let it update itself; `AGENTHUB_AGY_USAGE=0` never runs it (no entry). **Cursor** reads the Cursor CLI's (`cursor-agent`) own login, `$XDG_CONFIG_HOME/cursor/auth.json` (default `~/.config`), and sends it ONLY to cursor.com's undocumented `GET /api/usage-summary` as the `WorkosCursorSessionToken=::` cookie (user id from `~/.cursor/cli-config.json` `authInfo`), while its JWT is valid and never refreshed — expired or a 401 is `error: "ETOKENEXPIRED"`; cached like Claude; no file (macOS keeps it in the keychain), no entry. One `mo` window: `individualUsage.plan.totalPercentUsed`, resetting at `billingCycleEnd` (an Enterprise seat without `plan` shows its plan name and no window). **Grok Build** reads the CLI's `auth.x.ai` session in `$GROK_HOME/auth.json` (default `~/.grok`) and sends it only to `cli-chat-proxy.grok.com`'s `GET /v1/billing?format=credits` (`wk` = `creditUsagePercent`, `mo` = `used / monthlyLimit`). **Kimi Code** reads `$KIMI_CODE_HOME/credentials/kimi-code.json` (default `~/.kimi-code`) and sends it only to `api.kimi.com`'s `GET /coding/v1/usages` (`5h`/`wk`/`mo` from `limit_5h`/`limit_7d`/`limit_month_total`). Both are used only while valid and never refreshed (`error: "ETOKENEXPIRED"`; a Kimi refresh would rotate the CLI's refresh token and sign it out); no file, no entry. **OpenCode Go** (`id: "opencode"`, `plan: "Go"`) reads opencode's own `opencode-go` API key — `auth.json`, else the `credential` table of `opencode.db`, under `$XDG_DATA_HOME/opencode` (default `~/.local/share`) — and sends it only to `opencode.ai`'s `GET /zen/go/v1/usage` (`5h`/`wk`/`mo` meters); an opencode with no Go key has no entry. These readers follow Orca's (MIT). All three cached like Claude. `plan` is the subscription's name as the tool reports it — Claude's `subscriptionType`, Codex's `chatgpt_plan_type` id-token claim, Cursor's `membershipType`, Grok's `subscriptionTier` — shown as a badge beside the account. `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (JWT claims read unverified; no token leaves the server but Cursor's own, to cursor.com). Labels: `5h`, `wk`, `mo`, else `d`/`h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time. **Server mode with S3** (one Linux user per account): a developer or analyst gets ONLY their own account's rows, read from their Linux home at the tools' default paths (`~/.claude`, `~/.codex`, `~/.config/cursor` + `~/.cursor`, `~/.grok`, `~/.kimi-code`, `~/.local/share/opencode`) — never the host's — and only from files that account owns (open + `fstat` owner check, a FIFO refused; a member's `opencode.db` is read from a private copy). No saved Claude profiles and no Antigravity row for them (agy's `/usage` needs the self-updating binary, so it stays the host's); an account that is not provisioned gets `tools: []`. An admin gets the host's, as before. +How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex"|"antigravity"|"cursor"|"kimi", active, label?, account?, plan?, expiresAt?, windows: [{ label, usedPercent, resetsAt, group? }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. Each Claude token's owner is also asked (`GET /api/oauth/profile`): the live row carries the label and email of the account its token belongs to (`.claude.json` can name another), and a saved profile whose token belongs to a different account answers `error: "EWRONGACCOUNT"` with no windows, never that other account's usage. **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). **Antigravity** runs the catalog-detected `agy` as `agy --print /usage --output-format json` — the CLI's own `/usage` command, no model call (`num_turns` 0, no conversation) — in a private empty temp directory with a 60 s timeout, only while the tool is enabled in AI Tools and `agy --version` is ≥ 1.1.11 (an older agy may bill `/usage` as a model request); cached like Claude, a failed run shows the last figure (`asOf`) or `error: "EUPSTREAM"`. One window per pool bucket (`5h`, `wk`), `group` naming the pool ("Gemini Models", "Claude and GPT models"); the report names no account and no plan. Running agy may let it update itself; `AGENTHUB_AGY_USAGE=0` never runs it (no entry). **Cursor** reads the Cursor CLI's (`cursor-agent`) own login, `$XDG_CONFIG_HOME/cursor/auth.json` (default `~/.config`), and sends it ONLY to cursor.com's undocumented `GET /api/usage-summary` as the `WorkosCursorSessionToken=::` cookie (user id from `~/.cursor/cli-config.json` `authInfo`), while its JWT is valid and never refreshed — expired or a 401 is `error: "ETOKENEXPIRED"`; cached like Claude; no file (macOS keeps it in the keychain), no entry. One `mo` window: `individualUsage.plan.totalPercentUsed`, resetting at `billingCycleEnd` (an Enterprise seat without `plan` shows its plan name and no window). **Kimi Code** reads `$KIMI_CODE_HOME/credentials/kimi-code.json` (default `~/.kimi-code`) and sends it only to `api.kimi.com`'s `GET /coding/v1/usages` (`5h`/`wk`/`mo` from `limit_5h`/`limit_7d`/`limit_month_total`). It is used only while valid and never refreshed (`error: "ETOKENEXPIRED"`; a Kimi refresh would rotate the CLI's refresh token and sign it out); no file, no entry. This reader follows Orca's (MIT), cached like Claude. `plan` is the subscription's name as the tool reports it — Claude's `subscriptionType`, Codex's `chatgpt_plan_type` id-token claim, Cursor's `membershipType` — shown as a badge beside the account. `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (JWT claims read unverified; no token leaves the server but Cursor's own, to cursor.com). Labels: `5h`, `wk`, `mo`, else `d`/`h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time. **Server mode with S3** (one Linux user per account): a developer or analyst gets ONLY their own account's rows, read from their Linux home at the tools' default paths (`~/.claude`, `~/.codex`, `~/.config/cursor` + `~/.cursor`, `~/.kimi-code`) — never the host's — and only from files that account owns (open + `fstat` owner check, a FIFO refused). No saved Claude profiles and no Antigravity row for them (agy's `/usage` needs the self-updating binary, so it stays the host's); an account that is not provisioned gets `tools: []`. An admin gets the host's, as before. #### Analytics ```http diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 160a3cd9..231faeb3 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -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) diff --git a/docs/LANES.md b/docs/LANES.md index d685a2af..9be8c9e3 100644 --- a/docs/LANES.md +++ b/docs/LANES.md @@ -1838,7 +1838,7 @@ agenthub 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/`) 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 ` 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 ` `), 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/`) 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 ` 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 ` `), plus **Custom…**, and starts on the tool's default model when one is set. A catalog file: diff --git a/docs/OVERVIEW.md b/docs/OVERVIEW.md index 5a73f62b..374e91dd 100644 --- a/docs/OVERVIEW.md +++ b/docs/OVERVIEW.md @@ -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 `), 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 `). **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 `. Cursor sessions resume by id too (`--resume `). +Cursor sessions resume by id too (`--resume `), 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 diff --git a/scripts/test-server.js b/scripts/test-server.js index 21595bcb..bba69a3f 100644 --- a/scripts/test-server.js +++ b/scripts/test-server.js @@ -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 diff --git a/server/README.md b/server/README.md index bff9a614..ff1be4eb 100644 --- a/server/README.md +++ b/server/README.md @@ -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-` 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 diff --git a/server/__tests__/ai-tool-config.test.js b/server/__tests__/ai-tool-config.test.js index 9f73b3bd..67552d52 100644 --- a/server/__tests__/ai-tool-config.test.js +++ b/server/__tests__/ai-tool-config.test.js @@ -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"); diff --git a/server/__tests__/ai-tools-route.test.js b/server/__tests__/ai-tools-route.test.js index 97204fe4..1f7ac95f 100644 --- a/server/__tests__/ai-tools-route.test.js +++ b/server/__tests__/ai-tools-route.test.js @@ -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 () => { diff --git a/server/__tests__/fixtures/ai-tools/grok.json b/server/__tests__/fixtures/ai-tools/septool.json similarity index 62% rename from server/__tests__/fixtures/ai-tools/grok.json rename to server/__tests__/fixtures/ai-tools/septool.json index 00d4258d..8582541a 100644 --- a/server/__tests__/fixtures/ai-tools/grok.json +++ b/server/__tests__/fixtures/ai-tools/septool.json @@ -1,7 +1,7 @@ { - "id": "grok", - "label": "Grok", - "detect": ["grok"], + "id": "septool", + "label": "Separator tool", + "detect": ["septool"], "prompt": { "mode": "argv", "separator": "--" diff --git a/server/__tests__/grok-hook-route.test.js b/server/__tests__/grok-hook-route.test.js index ac691f13..b9352dda 100644 --- a/server/__tests__/grok-hook-route.test.js +++ b/server/__tests__/grok-hook-route.test.js @@ -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ĩ */ @@ -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 () => { diff --git a/server/__tests__/opencode-usage.test.js b/server/__tests__/opencode-usage.test.js deleted file mode 100644 index f508855e..00000000 --- a/server/__tests__/opencode-usage.test.js +++ /dev/null @@ -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ĩ - */ - -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"); - }); -}); diff --git a/server/__tests__/pty-run.test.js b/server/__tests__/pty-run.test.js index 8c7b482f..c1f37d94 100644 --- a/server/__tests__/pty-run.test.js +++ b/server/__tests__/pty-run.test.js @@ -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"]); }); diff --git a/server/__tests__/quota.test.js b/server/__tests__/quota.test.js index 3c9fee58..a65b68f9 100644 --- a/server/__tests__/quota.test.js +++ b/server/__tests__/quota.test.js @@ -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ĩ */ @@ -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(); - } -}); diff --git a/server/__tests__/server-s3-quota.test.js b/server/__tests__/server-s3-quota.test.js index 34c03966..88fdb7c9 100644 --- a/server/__tests__/server-s3-quota.test.js +++ b/server/__tests__/server-s3-quota.test.js @@ -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. diff --git a/server/__tests__/server-s3-tools.test.js b/server/__tests__/server-s3-tools.test.js index ac0b6044..e65a3ba1 100644 --- a/server/__tests__/server-s3-tools.test.js +++ b/server/__tests__/server-s3-tools.test.js @@ -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( diff --git a/server/data/ai-tools/README.md b/server/data/ai-tools/README.md index 43879496..f677ea98 100644 --- a/server/data/ai-tools/README.md +++ b/server/data/ai-tools/README.md @@ -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) diff --git a/server/data/ai-tools/grok.json b/server/data/ai-tools/grok.json deleted file mode 100644 index b638b9f1..00000000 --- a/server/data/ai-tools/grok.json +++ /dev/null @@ -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" - } - ] -} diff --git a/server/data/ai-tools/opencode.json b/server/data/ai-tools/opencode.json deleted file mode 100644 index 4af1d185..00000000 --- a/server/data/ai-tools/opencode.json +++ /dev/null @@ -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" - } - ] -} diff --git a/server/db.js b/server/db.js index d872246f..f4ae2106 100644 --- a/server/db.js +++ b/server/db.js @@ -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(` diff --git a/server/index.js b/server/index.js index dc423f58..acfc813a 100644 --- a/server/index.js +++ b/server/index.js @@ -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 diff --git a/server/lib/opencode-usage.js b/server/lib/opencode-usage.js deleted file mode 100644 index 16722281..00000000 --- a/server/lib/opencode-usage.js +++ /dev/null @@ -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ĩ - */ - -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, -}; diff --git a/server/lib/quota.js b/server/lib/quota.js index 2bec829b..4788c2a3 100644 --- a/server/lib/quota.js +++ b/server/lib/quota.js @@ -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() }; } diff --git a/server/routes/hooks.js b/server/routes/hooks.js index f2ba7272..622a8685 100644 --- a/server/routes/hooks.js +++ b/server/routes/hooks.js @@ -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); diff --git a/server/routes/sessions.js b/server/routes/sessions.js index 0993c3c1..4bbe803f 100644 --- a/server/routes/sessions.js +++ b/server/routes/sessions.js @@ -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.