Compare commits

...

8 Commits

Author SHA1 Message Date
nntrivi2001 c59e196e41 chore: v2.8.24 — sub-agent edits in Edited N files, OpenCode/Grok dropped, Artifacts list view and skill sync
Edited N files (#86): a turn's card counts the files its subagents wrote
or edited (read from their own transcripts, nested ones folded in), and a
turn longer than the chat's first page keeps the edits made before the
page cut. A non-Claude transcript page honours its limit again.

AI tools: OpenCode and Grok Build left the catalog (opencode-usage.js
gone; a Grok payload on the Claude hook route is dropped), and so did the
leftover Kimi Code quota reader.

Artifacts: a list view, skill packs and badges only by exception; a
shared skill follows its owner's folder, and an installer whose copy
differs is offered Update.
2026-10-26 09:46:45 +07:00
nntrivi2001 72aef2426c feat(artifacts): a shared skill follows its owner's folder; installers get Update
A skill in the Hub was a snapshot taken once: the owner changing
~/.claude/skills/<name> changed nothing there, and an installer had to
uninstall and install again to get a newer version.

- syncSkillSources (on GET /api/artifacts, at most every 15 s): a skill row
  taken from a folder — the owner's ~/.claude/skills/<name>, or the
  workspace's .claude/skills/<name> — is brought up to it. Only a folder
  whose names/sizes/mtimes changed is read again, with a pick's checks (no
  symlink, no secret, <= 5 MB, a member's files as theirs); a change is a
  NEW snapshot, the old one dropped, and an artifact_changed broadcast.
- artifacts.version (snapshot content hash) and source_hash (the folder's
  when last taken, additive columns): an upload or a Hub edit records the
  folder as it is then, so only a LATER folder change replaces it.
- A list row's updateAvailable: installed, and the viewer's copy differs
  from version. The page shows Update (a pack: Update N); install with
  {update: true} replaces the copies (old ones moved aside) and is not
  counted as another install.
2026-10-26 09:34:21 +07:00
nntrivi2001 244f999538 feat(artifacts): list view, skill packs, badges by exception
The page showed every artifact as a tall card with a blue Install and four
text actions, a Shared badge on nearly all of them and 12 cards per section,
so 26 artifacts filled ~1900px (5000px on a phone).

- List view by default (Grid kept, a per-browser choice): one row per
  artifact — kind icon, name, one line of description, owner and installs,
  ONE primary action, everything else in its ⋯ menu / right-click.
- Three or more skills of one owner sharing a name prefix (sg-*, vp-*) are
  one pack row: open it for its skills, or Install all / Install N more
  after one confirmation (a name already there is left alone).
- Badges only for the exception: Private (not shared), Hub only, Auto.
- No per-section paging: 30 entries, then Show more.
- Scope tabs under the title; category chips scroll on a phone.
- Another user's private row (seen by an admin) gets its own section
  instead of sitting under Shared by others.
- "1 installs" → "1 install".
2026-10-26 09:34:10 +07:00
nntrivi2001 127da516ac chore(quota): drop the Kimi Code quota reader (Kimi already left the catalog)
Redone on v2.8.22 from 3179d25 (feat/grok-usage, never merged).
2026-10-26 09:31:37 +07:00
nntrivi2001 c0832182f2 feat(ai-tools): drop OpenCode and Grok Build
- opencode.json / grok.json leave the built-in catalog; opencode-usage.js,
  its poller, transcript branch and table creation go with it (existing
  tables and session rows stay as history).
- A Grok payload reaching the Claude hook route (Grok runs the Claude hooks
  itself) is dropped instead of recorded as tool = 'grok'.
- quota.js no longer reads Grok or OpenCode Go logins.
- Redone on v2.8.22 from c6b2892 (feat/grok-usage, never merged).
2026-10-26 09:31:30 +07:00
nntrivi2001 9ea7758abb fix(transcript): a non-Claude page honours its limit again (#86)
`carry?.drop(messages.shift())` skips its argument when `carry` is null —
optional chaining short-circuits the whole call — so a Codex, omp, Cursor
or Antigravity page never shifted its sliding window and returned the
whole journal (codex-transcript.test.js: 8 messages for ?limit=2). Shift
first, then hand the dropped message to the carry.
2026-10-26 08:35:39 +07:00
nntrivi2001 f51a6a895e fix(chat): keep a long turn's Edited N files card whole across the page cut (#86)
The reported turn (/vp-auto, ~120 messages) showed "Edited 1 file": the
chat loads the newest 50 messages, and the turn's other Write
(rbac_routes_test.go, +176) was in the part before the page.

- turnCarry (agent-edits.js): every message the route's sliding window
  drops goes through it; it keeps the file changes since the last prompt it
  dropped (a dropped call whose result is on the page, and dropped
  subagents, included).
- A Claude page that starts mid-turn answers them as `turn_edits`; the
  chat keeps the one from the page that holds its oldest message and adds
  it to that turn's card.

Files a turn changed through Bash (python/sed writing a file) are still not
listed: nothing in the transcript says which files a command wrote.
2026-10-26 08:32:45 +07:00
nntrivi2001 5ccbaf5c23 fix(chat): count a subagent's edits in the turn's Edited N files card (#86)
A skill that hands its work to subagents (vp-auto) showed "Edited 1 file"
for a turn that changed 4: a subagent's Write/Edit calls are in its own
agent-<id>.jsonl, so the card only saw the parent's.

- server/lib/agent-edits.js reads a subagent's transcript (readLines, a
  member's only as theirs) for the files it wrote or edited with their +/-
  lines, nested subagents folded in (3 levels), cached by size + mtime.
- The transcript route puts them on each Claude Agent/Task result as
  `edits`; the card adds them to the turn's own edits. A file only a
  subagent wrote is listed, never an artifact card (its versions are
  rebuilt from this transcript's inputs, which a subagent's are not).
2026-10-26 08:32:45 +07:00
48 changed files with 1938 additions and 1757 deletions
+6 -6
View File
@@ -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.24 (2026-10-26) — OpenCode, Grok Build and the Kimi quota reader left; a shared skill follows its owner's folder (Update when an installed copy differs); a sub-agent's edits count in Edited N files._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
- Preserve real-time behavior (hooks -> API -> SQLite -> WebSocket -> UI).
## Repo map
- `server/`: Express API, hook ingestion, SQLite access, websocket broadcast (includes release-based update detection — `lib/update-check.js` reads the repo's own releases API and `routes/updates.js` exposes status/check/apply, refusing to self-update a `~/.claude/plugins/cache/` install, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks — and `lib/codex-ingest.js`, which mirrors a `codex` lane's running/idle from the mtime of Codex's own rollout journals (skipping a lane whose hooks are current), `lib/codex-usage.js`, which reads the same journals into `sessions` (`tool = 'codex'`) and `token_usage`, and `lib/codex-hooks.js`, which installs AgentHub's opt-in hooks into Codex's `hooks.json`, and their Antigravity counterparts `lib/antigravity-usage.js` (agy's conversation files → `tool = 'antigravity'` sessions and tokens, and its step log as their conversation view) and `lib/antigravity-hooks.js` (the named hook `agenthub` in `~/.gemini/config/hooks.json`), and `lib/cursor-usage.js` (Cursor's agent transcripts → `tool = 'cursor'` sessions and their conversation view, no tokens), and `lib/omp-usage.js`, `lib/opencode-usage.js` (omp's journals, OpenCode's `opencode.db` → `tool = 'omp'|'opencode'` sessions, tokens and subagent agents)).
- `server/`: Express API, hook ingestion, SQLite access, websocket broadcast (includes release-based update detection — `lib/update-check.js` reads the repo's own releases API and `routes/updates.js` exposes status/check/apply, refusing to self-update a `~/.claude/plugins/cache/` install, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks — and `lib/codex-ingest.js`, which mirrors a `codex` lane's running/idle from the mtime of Codex's own rollout journals (skipping a lane whose hooks are current), `lib/codex-usage.js`, which reads the same journals into `sessions` (`tool = 'codex'`) and `token_usage`, and `lib/codex-hooks.js`, which installs AgentHub's opt-in hooks into Codex's `hooks.json`, and their Antigravity counterparts `lib/antigravity-usage.js` (agy's conversation files → `tool = 'antigravity'` sessions and tokens, and its step log as their conversation view) and `lib/antigravity-hooks.js` (the named hook `agenthub` in `~/.gemini/config/hooks.json`), and `lib/cursor-usage.js` (Cursor's agent transcripts → `tool = 'cursor'` sessions and their conversation view, no tokens), and `lib/omp-usage.js` (omp's journals → `tool = 'omp'` sessions, tokens and subagent agents)).
- `client/`: React + Vite UI.
- `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`, keyed on the forge's **newest release** rather than a commit count. `POST /api/updates/apply` has two modes and picks by install kind, never from the request: a fast-forwardable **checkout** merges the release tag `--ff-only`, rebuilds, and self-restarts via `server/lib/self-restart.js` — through our own unit when this process runs in one (`systemctl restart --no-block`, launchd's KeepAlive; a detached replacement beside a `Restart=always` unit crash-loops it), else the detached `scripts/restart-helper.js`; a **plugin-cache** install is handed to `server/lib/plugin-update.js` + the detached `scripts/plugin-update-helper.js`, which runs `claude plugin marketplace update` → `claude plugin update -y` → `plugin-bootstrap.js --force` from the NEW cache dir. Both write the HTTP response before anything stops the process, and every plugin-chain outcome is recorded to `plugin-update.json` in the runtime dir — outside the cache, so it survives the swap and the next server reports it as `last_plugin_update`. While it runs, the step it is on is `plugin-update-progress.json` → `plugin_update_progress` (the update modal's step line and the chip it leaves when hidden). Neither mode can run `/reload-plugins`: it is REPL-scoped to the session that invokes it, so an open Claude Code session still reloads by hand for new skills/commands. Elsewhere users run the printed manual command, surfaced in the UI and by `agenthub update-check`. `scripts/cc-update-all-helper.js` is the same detached pattern for the config panel's **Update all** button — marketplaces → every plugin → AgentHub LAST → one reload — journalling each step to `cc-update-all.json` in the runtime dir.)
- `skills/`: this repo's skills. Because the repo root **is** the `agenthub` plugin root, this is also the plugin's skills directory — every skill here ships to anyone who installs the plugin, addressed as `/agenthub:<name>` (bare `/<name>` works as an alias). Put a skill here only if it should reach plugin users. The plugin ships no subagents and no pipeline skill, and depends on no other plugin (Superpowers included) — do not add an auto-install of one back into the bootstrap.
@@ -48,10 +48,10 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which
- **A clone runs as its folder's account with THAT account's own identity, and only the public key ever leaves the server.** `POST /api/lanes/clone` takes a URL only through `validateRemoteUrl` (the `publish` allowlist) and a parent held to the user's own folder like New folder; `server/lib/git-identity.js` runs `git clone` as the folder's account (S3: the member) and writes the identity into the CLONE's config (`core.sshCommand` / `credential.helper=store --file=`), never the user's global config. A member's SSH key and HTTPS store live in their home under `~/.config/agenthub/`, written only through `write-as` (secrets on stdin, never sudo argv/env), the host's under `<CLAUDE_HOME>/agenthub-ssh/` + the shared store below; `/api/me/git` returns only `publicKey`/`hasToken`. The git bar's fetch/pull/push in a member's folder uses that same store (`memberHttpsConfig`; a typed login goes in through `write-as` stdin for the one call), and a store that cannot be read is never rewritten (`ESTOREUNREADABLE`); the private key is only `lstat`ed, never read. The commit author is the ACCOUNT's `git config --global` (`PUT /api/me/git/author`). Do not add a route that returns a private key or a token, or one that writes a git identity outside the clone's own config or the account's own `~/.gitconfig`.
- **AgentHub's git credentials are its OWN store, shared by every user of the install, and git is given ONLY that store when it holds a login for the host.** `server/lib/git-credentials.js` keeps one record per `protocol + host` under `<CLAUDE_HOME>/agenthub-git-credentials/` (0700/0600) and no route ever returns a password (`hasToken` only). `gitConfigFor` runs a network action with `-c credential.helper= -c credential.helper=<ours>` whenever AgentHub holds a login for a repo remote's host or the request carries one — the machine's helpers are left out on purpose: a stale token there would win over the typed one, and git's post-success `store` would copy a use-once token into the OS user's helper. The secret travels in the helper's env or the store file, never argv. A typed credential is accepted only for one of the repo's OWN remote hosts (`EBADHOST`), so a login is never sent to a server it was not typed for, and it is saved only after git SUCCEEDED and `remember` is set. Do not add a route that returns a stored password or a path that forwards one to a host other than its own.
- **A lane's `tool` is its DEFAULT, immutable; a session's tool is the session's choice — and only an AI Tools catalog id ever comes from a request.** `lanes.tool` (default `claude`) is any id `server/lib/ai-tools.js` knows; `createLane` refuses an unknown one (`EBADTOOL`) and one the user disabled (`EDISABLED` — only CREATION asks, an existing lane on a tool disabled since keeps working), and it is deliberately absent from `PATCHABLE`. A NEW session names its own with `tool` on `POST /api/lanes/:id/start` (the home's **AI** select, sent only when it differs from the lane's): `validateTool` again (`EBADTOOL`/`EDISABLED`, 400), and the id only picks a catalog file — the command, args and env still come from that file. A resume keeps its session's own tool (`sessions.tool`), never the request's. `POST /api/run` still takes the lane's tool. A lane whose tool left the catalog answers `ENOTOOL` at start. The sidebar's tool icon is per SESSION (`WorkspaceSessionList`), not per workspace row. A pane has no run form: `RunSetup` is gone from `LaneConsolePane`, and the workspace home's prompt box (`WorkspaceHome.tsx`: AI and ONE Model chip — `ModelMenu` in `RunSetup.tsx`, the model list plus Effort, a proxy's own models on a proxy — inside the box; AI Proxy, Permission and Local / New worktree in a row under it, like Claude Desktop) is the one place a session starts with a prompt (the session view's tab bar `+`, `ConsoleTabs.tsx`, starts a bare CLI or a plain shell — `shell: true`, nothing from the request but the slot; the home's AI menu has the same **Open terminal**). `claude`/`codex` keep their hand-written argv builders (`launcher` in their catalog file — a `DASHBOARD_AI_TOOLS_DIR` file may NOT claim one); every other tool is started by its detected absolute path as `[path, ...args, ...resume?, ...bypass?, ...prompt]` (`...resumeById, <id>` in place of `resume` for a named session, only when its catalog file declares `resumeById`), every piece but the prompt from its catalog file, the prompt ONE argv element or, for an `after-start` tool, a tmux paste buffer (`tmux.pasteText`) after `delayMs`. Codex-only fields (`sandbox`, `approval`) are validated against the fixed allowlists in `server/lib/pty-run.js`. **Bypass args are emitted ONLY when the run request carries `bypass: true`** — the home's red per-run switch, which starts from the account's own default (Settings → AI tools → **Session defaults**, `client/src/lib/sessionDefaults.ts`: Claude's default mode may be `bypassPermissions`, Codex's bypass may default on — set only behind a confirm, never by the first-run wizard, never for the pet chat; the server never remembers it) — and they come from the tool's catalog file (Codex: `--dangerously-bypass-approvals-and-sandbox`, which then replaces `-s`/`-a`); Claude has none (it has the `bypassPermissions` mode), and `--dangerously-bypass-hook-trust` is never emitted. Every pane gets the dashboard's hydrated `PATH` through tmux `-e`. Only `claude`/`codex` are `proxyCapable`; `resolveProxy` accepts only `native` for any other tool, else `EUNSUPPORTED`. `GET /api/run/binary?tool=` resolves through the catalog — the query string never names a command. Do not add a route that takes a command, args or env for a tool from a request.
- **Antigravity hooks are observational only, and never answer a permission.** `server/lib/antigravity-hooks.js` writes only the named hook `agenthub` in `~/.gemini/config/hooks.json`, only on a click, and leaves every other name alone. It installs PreInvocation, PostToolUse and Stop and NEVER PreToolUse: agy reads a PreToolUse answer as a permission decision and DENIES the tool on `{}` (verified on 1.2.13), `allow` would override the user's own permission rules and `ask` would force a prompt — there is no neutral answer. The forwarder must keep printing `{}` and nothing else. The file is global (the Antigravity IDE loads it too), so the route accepts a hook only when its `transcriptPath` lies under `~/.gemini/antigravity-cli/`, and a hook's `tool` is an allowlist (`codex`, `antigravity`, `grok` — also recognised by its payload's camelCase `hookEventName` + `workspaceRoot`, since Grok runs the Claude hooks — else `claude`). AgentHub never reads the IDE's data.
- **Codex hooks run only because the user trusted them in Codex, and AgentHub never decides that for them.** `server/lib/codex-hooks.js` writes `$CODEX_HOME/hooks.json` only on the user's click, keeps every entry that is not AgentHub's, and never writes Codex's trust (`[hooks.state]` in `config.toml` — read only, to report); nothing may pass `--dangerously-bypass-hook-trust`. The hook command is the STABLE `'<CODEX_HOME>/agenthub-hook.sh' <Event>` because Codex keys trust on the definition: change what the script runs (`refreshScript`), never the command, the timeout or the event list without expecting every user to re-trust. A hook's `tool` is an allowlist (`codex`, `antigravity`, `grok` — also recognised by its payload's camelCase `hookEventName` + `workspaceRoot`, since Grok runs the Claude hooks — else `claude`), and a Codex hook's `transcript_path` is never stored or parsed as a Claude transcript — only `threadOf` reads it, and only a `rollout-*.jsonl` under `$CODEX_HOME/sessions`.
- **Antigravity hooks are observational only, and never answer a permission.** `server/lib/antigravity-hooks.js` writes only the named hook `agenthub` in `~/.gemini/config/hooks.json`, only on a click, and leaves every other name alone. It installs PreInvocation, PostToolUse and Stop and NEVER PreToolUse: agy reads a PreToolUse answer as a permission decision and DENIES the tool on `{}` (verified on 1.2.13), `allow` would override the user's own permission rules and `ask` would force a prompt — there is no neutral answer. The forwarder must keep printing `{}` and nothing else. The file is global (the Antigravity IDE loads it too), so the route accepts a hook only when its `transcriptPath` lies under `~/.gemini/antigravity-cli/`, and a hook's `tool` is an allowlist (`codex`, `antigravity`, else `claude` — a Grok payload, recognised by its camelCase `hookEventName` + `workspaceRoot` since Grok runs the Claude hooks, is dropped). AgentHub never reads the IDE's data.
- **Codex hooks run only because the user trusted them in Codex, and AgentHub never decides that for them.** `server/lib/codex-hooks.js` writes `$CODEX_HOME/hooks.json` only on the user's click, keeps every entry that is not AgentHub's, and never writes Codex's trust (`[hooks.state]` in `config.toml` — read only, to report); nothing may pass `--dangerously-bypass-hook-trust`. The hook command is the STABLE `'<CODEX_HOME>/agenthub-hook.sh' <Event>` because Codex keys trust on the definition: change what the script runs (`refreshScript`), never the command, the timeout or the event list without expecting every user to re-trust. A hook's `tool` is an allowlist (`codex`, `antigravity`, else `claude`; a Grok payload is dropped), and a Codex hook's `transcript_path` is never stored or parsed as a Claude transcript — only `threadOf` reads it, and only a `rollout-*.jsonl` under `$CODEX_HOME/sessions`.
- **An AI Tools install runs only what a RELEASE catalog file names.** `install`/`update` (like `launcher`) are accepted in built-in files and in the synced copy of the newest release's catalog, never in `DASHBOARD_AI_TOOLS_DIR`, and no route takes a package, URL or argv from a request. Jobs are argv-only (`ai-tool-jobs.js`), stdin closed, one at a time; npm is resolved on the login shell's OWN PATH first (`shellPath()`), because `hydrateShellPath` appends, and the pinned Node's npm would install where the user's PATH never looks; a global prefix the account cannot write (a system-wide Node, root's `/usr/local`) makes the install `--prefix ~/.local` instead, never sudo; an npm update is refused (`ENOTNPM`) for a tool whose binary is under neither `npm prefix -g` nor `~/.local/lib/node_modules` (a standalone Codex, say) — it would install a shadowed second copy. A script URL must be https on the homepage's site and is re-checked after redirects — the one way off that site is the catalog entry's own `install.redirect`, an EXACT https URL (never a site wildcard). Icons are raster only — an SVG served from the dashboard's origin runs script when opened. Do not add an install source that a browser or a custom catalog file can name.
- **A tool's config editor opens only what a RELEASE catalog file lists, and only an existing text file.** `config` is honoured in built-in and synced-release catalog files (never `DASHBOARD_AI_TOOLS_DIR`), each path `~/`-relative with no `..`, and no route takes a path from the request — `:index` picks from that list. `server/lib/ai-tool-config.js` refuses a missing file (it never creates one), a non-regular, binary or >1 MB file, and any whose basename — or its symlink target's — matches `CONFIG_SECRET_RE` (auth, credential, token, oauth, key files…; `ESECRET`); a save must match the opened `baseSha`, parse when JSON/JSONC, and back up the old text under `<data dir>/ai-tool-config-backups/` first. Do not add a create, a delete, a request-supplied path, or a catalog entry naming a login file. The guard is by NAME only: a listed settings file may still hold an API key (opencode `options.apiKey`, an MCP `env`), and the unguarded GET returns it whole, like the cc-config reads do — do not describe this route as secret-free.
- **A tool's config editor opens only what a RELEASE catalog file lists, and only an existing text file.** `config` is honoured in built-in and synced-release catalog files (never `DASHBOARD_AI_TOOLS_DIR`), each path `~/`-relative with no `..`, and no route takes a path from the request — `:index` picks from that list. `server/lib/ai-tool-config.js` refuses a missing file (it never creates one), a non-regular, binary or >1 MB file, and any whose basename — or its symlink target's — matches `CONFIG_SECRET_RE` (auth, credential, token, oauth, key files…; `ESECRET`); a save must match the opened `baseSha`, parse when JSON/JSONC, and back up the old text under `<data dir>/ai-tool-config-backups/` first. Do not add a create, a delete, a request-supplied path, or a catalog entry naming a login file. The guard is by NAME only: a listed settings file may still hold an API key (a provider `apiKey`, an MCP `env`), and the unguarded GET returns it whole, like the cc-config reads do — do not describe this route as secret-free.
- **A lane's card position is a viewer preference; its name is a lane fact.** The sidebar workspace list's drag/Alt+↑/↓ order lives in `localStorage` (`agenthub.workspace.laneOrder`, `client/src/lib/laneOrderStorage.ts`) and writes NOTHING server-side — `GET /api/lanes` and `agenthub lanes` stay in id order, so no `sort_order` column, no reorder route, no websocket message. Its groups (`client/src/lib/workspaceGroups.ts`) are the same kind of viewer arrangement — no lane column, nothing in the CLI — but kept per ACCOUNT as the synced preference `agenthub.workspace.groups` (`/api/me/prefs`, 64 KB), so they outlive the browser; deleting a group only ungroups. Renaming is the opposite: it is `PATCH /api/lanes/:id` with `{title}`, the one lane column a user now types into directly, so `updateLane` normalizes it (`normalizeTitle`: trim, 200-char cap, `null` ⇒ `""`, because clearing is legitimate — every render is `lane.title || lane.cwd`). The rule is the column-wide one, not a special case for the field the UI exposes: `bindable` rejects any non-scalar patch value with `EBADFIELD` (`links` excepted — it is stored as JSON), and the route answers 400 for that AND for the `SQLITE_CONSTRAINT` family, rather than handing better-sqlite3 a datatype throw and reporting 500 on plain bad input.
- **Adopted lanes are not destroyable.** A lane with `kind === "adopted"` may never have its worktree reset or removed; "removing" one only drops the dashboard's own record of it. This is enforced in code (`assertDestroyable`'s first check) — do not add a path that bypasses it.
- **Removing a lane deletes the lane's RECORD and nothing else — for every kind, managed included.** `POST /api/lanes/:id/remove` stops the run and the stack, clears AgentHub's own runtime bookkeeping under `LANES_ROOT/.state/lane<slot>/`, and deletes the row. It calls neither `removeWorktree` nor `removeLaneData`: the working tree, its branch and the lane's database are the USER's data, and a mis-click on a dashboard row may never cost a branch's unpushed code. Deleting those is the user's own `git worktree remove` / `git branch -d` / db drop. Consequently `remove` has no unpushed-commits force-gate (only `reset` does) and no preflight blocker stops it. Do not add a "also delete the worktree" flag back onto this route. (A session's own `claude --worktree` tree is a different thing with its own guarded route — see the session-worktree rule below; a lane's folder is never it.)
@@ -59,7 +59,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which
- **The File Explorer (`server/lib/file-explorer.js`, `/api/lanes/:id/fs/*`) reuses the read path's guards and never replaces silently.** Every path is `lanePath` + the PARENT `resolveInLane`-confined with the last segment kept as a name (so a symlink is moved/deleted as a link, never followed); never in or under `.git`, never a secret, never the workspace folder itself. Writes run as the folder's account — a member's only through `write-as` (`move`/`copy` modes; `writeAsAsync` streams an upload to its stdin; a batch upload of many small files is ONE `extract` of a tar the server writes itself — folders and regular files only, names it checked, `tar -k`) — and a move/copy never crosses accounts (`sameOwner`, `EFOREIGN`). A taken name is settled by `conflict` (`fail` → 409 with `names`, `skip`, `keep`, `replace` = the old one to the trash first). Delete is a move into the account's trash (member: `~/.local/share/agenthub/trash/`, host: `<data dir>/trash/<user id>/`), restorable 7 days. `write-as` is root-installed: a change to it needs `agenthub server install-system` again (`EUPGRADE` until then). A user's HOME is browsed through the same module inside `inHome` (`/api/homes/:who/fs/*`, `server/lib/homes.js` picks the folder): every dot entry at the TOP of a home stays hidden and refused — through a symlink, a download, a zip or as a copy source too — and another user's home is an admin's READ-ONLY, audited view (`EREADONLY`) — except its TRASH, whose restore/purge an admin may run (`TRASH_W` in `server/routes/homes.js`, the owner's trash via `trashKey`, audited with the item id); keep that exception to the trash routes only. A home's Trash is its USER's one recycle bin (`listUserTrash`: home-account store + host store `<data dir>/trash/<user id>/`, items tagged `store`). Do not add a delete that skips the trash, an overwrite without `conflict`, or a request path that is not run through `locate`/`folder`.
- **The whole-file editor and the Files tab's upload are the two non-git writes on the lane file surface, and both live outside `git-ops.js` on purpose.** `git-ops.js` only touches paths `git status` reports as changed; `lane-file-write.js` may save any existing text file, so it must reuse the READ path's guards (`lanePath`, `resolveInLane`, `isSecretOnDisk`) and never be weaker than them, and it saves only over the `baseSha` it was opened at — do not let it create files or touch a binary. `lane-file-transfer.js`'s upload (`POST /api/lanes/:id/git/upload`, issue #78) is the opposite and only that: it CREATES one file (any bytes, ≤ 25 MB) under the same guards, into an existing folder, refuses a taken name (`EEXISTS`, a symlink included) and anything into or named `.git` (a hook there runs on the dashboard's next git call), and writes as the folder's member through `write-as` under S3 — do not let it overwrite, make folders or take a path from anything but `dir` + one plain name. Its `GET …/git/download` twin streams any file through `openSafe` as a `sandbox`ed attachment. The tree, expand-all and quick-open read the disk (`walkFolder`) for a folder that is not a repo.
- **Server tests never touch the user's tmux.** Run them through `npm run test:server` (`scripts/test-server.js`), never a bare `node --test server/__tests__/*.test.js` from inside a lane pane — the suites create and kill real `agenthub-lane-<id>` sessions.
- **An artifact is shared as a SNAPSHOT and installed only into the viewer's own account.** `server/lib/artifacts.js` (`/api/artifacts`): a pinned file or a skill is copied once into `<data dir>/artifacts/<random>/` and every viewer reads only that copy (an owner's edit of a skill writes a NEW snapshot and drops the old one — never a write into the one being read) — `raw` serves only a file the snapshot recorded, under a `sandbox` CSP + `nosniff`; a `page` is a `https://claude.ai/…` link only. A developer sees their own rows plus shared ones; only the owner or an admin changes one. Install writes a skill into BOTH `~/.claude/skills/` and the cross-tool `~/.agents/skills/` of every account the CALLER's sessions run as (`installAccounts`: their S3 member home, plus the host's for an admin, whose host-folder workspaces run as the host; S3: as them through `write-as`), never over an existing one without `replace` (the old one is moved to `~/.claude/agenthub-replaced-skills/`, never deleted), and runs a plugin's `claude plugin marketplace add`/`install --scope user` as the caller, never with `-y`; Uninstall (`POST /api/artifacts/:id/uninstall`) takes it out of the same accounts — a skill MOVED to `agenthub-replaced-skills/`, never deleted, a plugin `claude plugin uninstall --scope user --keep-data`; an admin's switch (Settings → AI tools, `ArtifactsHubSettings`) turns installs off. An uploaded or written skill is snapshotted under the same checks (paths relative, no `..`, no secret names, zip sizes read before inflating). `POST /api/artifacts/plugin/inspect` clones a user-named https link only to read `marketplace.json`, with every credential helper OFF — never send a login with it. Do not add a route that serves a live file of another user, installs into someone else's account, or takes a plugin command from the request.
- **An artifact is shared as a SNAPSHOT and installed only into the viewer's own account.** `server/lib/artifacts.js` (`/api/artifacts`): a pinned file or a skill is copied once into `<data dir>/artifacts/<random>/` and every viewer reads only that copy (an owner's edit of a skill — in the Hub, or in the skill's source folder, which the server takes when the Hub is listed and an installer's differing copy is then offered **Update** — writes a NEW snapshot and drops the old one — never a write into the one being read; the newer of the two changes wins) — `raw` serves only a file the snapshot recorded, under a `sandbox` CSP + `nosniff`; a `page` is a `https://claude.ai/…` link only. A developer sees their own rows plus shared ones; only the owner or an admin changes one. Install writes a skill into BOTH `~/.claude/skills/` and the cross-tool `~/.agents/skills/` of every account the CALLER's sessions run as (`installAccounts`: their S3 member home, plus the host's for an admin, whose host-folder workspaces run as the host; S3: as them through `write-as`), never over an existing one without `replace` (the old one is moved to `~/.claude/agenthub-replaced-skills/`, never deleted), and runs a plugin's `claude plugin marketplace add`/`install --scope user` as the caller, never with `-y`; Uninstall (`POST /api/artifacts/:id/uninstall`) takes it out of the same accounts — a skill MOVED to `agenthub-replaced-skills/`, never deleted, a plugin `claude plugin uninstall --scope user --keep-data`; an admin's switch (Settings → AI tools, `ArtifactsHubSettings`) turns installs off. An uploaded or written skill is snapshotted under the same checks (paths relative, no `..`, no secret names, zip sizes read before inflating). `POST /api/artifacts/plugin/inspect` clones a user-named https link only to read `marketplace.json`, with every credential helper OFF — never send a login with it. Do not add a route that serves a live file of another user, installs into someone else's account, or takes a plugin command from the request.
- **A workspace's instructions, extra folders and context files are lane facts, and a pane reads them only from the lane's rows.** The one addition is the workspace OWNER's own agent instructions (`user_prefs.instructions`, `PUT /api/me/instructions`, Settings → AI tools), which `launchExtras` puts first — keyed by `lanes.owner_id` (`local` in local mode), never by whoever clicked Start. `lanes.instructions` and `lane_sources` (`server/lib/lane-sources.js`) reach a pane through `launchExtras(laneId, tool)`, which `spawnRun` calls ITSELF — so neither spawn path can take them from a request — and every write is same-origin (`server/routes/lane-sources.js`). A folder must be absolute, exist, be a directory, not be or contain the OS home (`validateHomeDir`) and not be the workspace's own folder; it is the user's, and AgentHub never deletes it. An uploaded file is stored under `<data dir>/workspace-context/<lane id>/` under a name AgentHub derives (never a path from the request), and those copies are the only thing here AgentHub deletes (`ownsFile`): with their row, and with the workspace in `deleteLane`. Claude's `--add-dir` is VARIADIC, so it goes first in the argv where `--permission-mode` ends it, and the text (instructions + context file list + extra folders) is written at every spawn as the ONLY file `CLAUDE.md` of `<data dir>/workspace-prompts/<lane id>/` (a member's: `prompt-<lane id>/` in their handoff dir, via `write-as`), which goes in as one more `--add-dir` with `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1` in the pane env — never `--append-system-prompt-file`: a custom system prompt is a launch flag Claude cannot carry over a restart, so it refused `/tui` in every pane with instructions (side effect: a CLAUDE.md in an extra folder is now read too); Codex gets one `--add-dir` per directory and `-c developer_instructions=<JSON string>` — the text itself up to 6 KB, else a line pointing at that file. Only `claude`/`codex` take them. Never write them into the user's repo (CLAUDE.md/AGENTS.md).
- **A workspace's sessions are the ones it OWNS, never the ones that ran in its folder.** `sessions.lane_id` is stamped by `bindRunSession` (`server/routes/hooks.js`) for the session a dashboard pane holds — first writer wins, a nested `claude -p` excluded — and no route sets it. A Codex/Antigravity session (their hooks are opt-in, so a pane may never learn its session) is also stamped by `claimToolSessions` (`server/lib/dashboard-runs.js`, called by both pollers and once at start): the lane of the newest same-tool dashboard run in the session's folder that was live when the session started, never a Claude session. A new Claude pane is started with `--session-id <uuid>` (`spawnRun`), so its `dashboard_runs.session_id` is known before any hook and a hook that carries no `run_id` (a hook handler older than `AGENTHUB_RUN_ID` forwarding) is still stamped from that run (`laneForSession`). Every workspace session list reads `GET /api/sessions?lane=<id>` — which also counts a still-unstamped session one of the lane's `dashboard_runs` holds, because hooks reach only the lowest-port dashboard on a shared DB and that one may be an older build that never stamps — and shows nothing else (no "other sessions in this folder"). Do not go back to listing a workspace by `cwd`, and do not guess ownership for old history.
- **A session's worktree is `claude --worktree`'s, resolved from the session row — and removing it never forces.** A Worktree start passes `--worktree=<server-made name>` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `<repo>/.claude/worktrees/<name>`; AgentHub only excludes `/.claude/worktrees/` at the repo root. A git route, a resume or a removal may work in a session's worktree only through `sessionWorktree` (`server/lib/session-worktree.js`: the session owned by the lane, its cwd not the lane's folder, registered by `git worktree list`) — `?session=` on the git routes answers `ENOWORKTREE` rather than falling back to the main folder. `POST /api/lanes/:id/session-worktree/remove` (same-origin, `confirm: true`, refused while a live pane holds the session) is the ONE dashboard path that deletes a worktree: `git worktree remove` with no `--force`, `git branch -d` never `-D`, a live-pid lock refused. Do not add a force, and do not let it touch a lane's own folder.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 411 KiB

After

Width:  |  Height:  |  Size: 465 KiB

+1 -1
View File
@@ -49,7 +49,7 @@
<g transform="translate(20,22)"><rect width="48" height="48" rx="12" fill="#4cc3f5" fill-opacity="0.12"/><rect x="11" y="11" width="11" height="11" rx="3" fill="#4cc3f5"/><rect x="26" y="11" width="11" height="11" rx="3" fill="none" stroke="#4cc3f5" stroke-width="2.5"/><rect x="11" y="26" width="11" height="11" rx="3" fill="none" stroke="#4cc3f5" stroke-width="2.5"/><rect x="26" y="26" width="11" height="11" rx="3" fill="#4cc3f5"/></g>
<text x="84" y="44" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="21" font-weight="700" fill="#f1f5ff">Every AI coding agent</text>
<text x="84" y="72" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="16" fill="#9aa6c4">Claude Code, Codex, Antigravity,</text>
<text x="84" y="94" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="16" fill="#9aa6c4">Cursor, Kimi, Grok and more</text>
<text x="84" y="94" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="16" fill="#9aa6c4">Cursor, omp and more</text>
</g>
<g transform="translate(792,300)">
<rect width="344" height="116" rx="16" fill="#ffffff" fill-opacity="0.045" stroke="#ffffff" stroke-opacity="0.10"/>

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

-3
View File
@@ -50,9 +50,6 @@ const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = {
codex: "Codex",
antigravity: "Antigravity",
cursor: "Cursor",
grok: "Grok",
kimi: "Kimi",
opencode: "OpenCode Go",
};
function localDate(d = new Date()): string {
@@ -78,7 +78,12 @@ import { useSpin } from "../../hooks/useSpin";
import { useOnScreen, isShown } from "../../hooks/useOnScreen";
import { throttle } from "../../lib/throttle";
import { useTranslation } from "react-i18next";
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
import type {
TranscriptMessage,
TranscriptInfo,
TranscriptResult,
WSMessage,
} from "../../lib/types";
// Catch-up poll interval. Claude Code only fires hooks on PreToolUse /
// PostToolUse / Stop, which means a user-typed message (no hook) and any
@@ -175,6 +180,8 @@ function ConversationViewPane({
initialTranscriptId ?? null
);
const [hasMore, setHasMore] = useState(false);
// The edits of the turn the oldest loaded page starts inside of (`turn_edits`).
const [leadEdits, setLeadEdits] = useState<TranscriptResult["turn_edits"]>(undefined);
const [error, setError] = useState<string | null>(null);
const [transcripts, setTranscripts] = useState<TranscriptInfo[]>([]);
const [showNewMsg, setShowNewMsg] = useState(false);
@@ -283,6 +290,7 @@ function ConversationViewPane({
if (cancelled) return;
setNotStarted(false);
setMessages(result.messages);
setLeadEdits(result.turn_edits);
setTotal(result.total);
setHasMore(result.has_more);
lastLineRef.current = result.last_line;
@@ -343,6 +351,7 @@ function ConversationViewPane({
// Seed the view in a single render so the user sees the whole
// catch-up batch instead of a blank panel followed by a partial one.
setMessages(result.messages);
setLeadEdits(result.turn_edits);
firstLineRef.current = result.first_line;
setHasMore(result.has_more);
} else {
@@ -487,6 +496,7 @@ function ConversationViewPane({
firstLineRef.current = result.first_line;
setMessages((prev) => [...result.messages, ...prev]);
setLeadEdits(result.turn_edits);
setHasMore(result.has_more);
// Preserve scroll position (don't jump to top)
@@ -686,6 +696,7 @@ function ConversationViewPane({
stopped={stopped}
agentLinks={agentLinks}
compact={compact}
leadEdits={leadEdits}
sessionId={
selectedTranscript && selectedTranscript !== "main" ? undefined : sessionId
}
@@ -172,6 +172,9 @@ interface MessageListProps {
/** A subagent's conversation inside its step: tight spacing, and the task
* the orchestrator gave it reads as the prompt it is. */
compact?: boolean;
/** The files the first, cut-off turn changed before `messages` begins (the
* transcript page's `turn_edits`): counted in that turn's card. */
leadEdits?: TranscriptContent["edits"];
}
/** Build a map from tool_use id → tool_result for matching */
@@ -491,6 +494,10 @@ type Item =
| { kind: "command"; key: string; name: string | null; output: string | null; error: boolean };
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
// A subagent's result carries the files IT changed (`edits`): they count too.
const AGENT_TOOLS = new Set(["Agent", "Task"]);
// The stand-in for the part of a turn the page cut off: its `edits` count.
const LEAD = "__lead_edits";
/** A line that is only Claude Code's local-command plumbing — the caveat it
* writes first, the command, what the command printed — or null for
@@ -515,11 +522,18 @@ export function localCommand(
return { name, output, error };
}
export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[] {
export function chatItems(
messages: TranscriptMessage[],
keys: string[],
leadEdits?: TranscriptContent["edits"]
): Item[] {
const items: Item[] = [];
// The file-changing calls of the turn so far, closed into one card when the
// human speaks again (or the list ends).
let edits: TranscriptContent[] = [];
// human speaks again (or the list ends) — starting with what the page's
// cut-off first turn changed before it.
let edits: TranscriptContent[] = leadEdits?.length
? [{ type: "tool_use", name: LEAD, edits: leadEdits }]
: [];
// The turn's prompt time, its newest assistant line and its output so far,
// for the "Done in …" line under a turn that ended.
let start: string | null = null;
@@ -592,7 +606,11 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
if (msg.usage) output.add(msg.usage);
msg.content.forEach((block, b) => {
const last = items[items.length - 1];
if (block.type === "tool_use" && EDIT_TOOLS.has(block.name ?? "")) edits.push(block);
if (
block.type === "tool_use" &&
(EDIT_TOOLS.has(block.name ?? "") || AGENT_TOOLS.has(block.name ?? ""))
)
edits.push(block);
// A question to the user is a turn of the conversation, not hidden work.
if (block.type === "tool_use" && block.name === "AskUserQuestion") {
items.push({ kind: "ask", key: `${key}:${b}`, block });
@@ -672,19 +690,38 @@ function EditedFilesCard({
const files = new Map<string, { added: number; removed: number; counted: boolean }>();
// Written whole in this turn (a Write): a component made here is an artifact.
const created = new Set<string>();
// Changed only by a subagent: listed, never an artifact card — the chat
// rebuilds an artifact from this transcript's Write/Edit inputs, which a
// subagent's are not.
const byAgent = new Set<string>();
for (const b of blocks) {
const r0 = b.id ? toolResultMap.get(b.id) : undefined;
if (AGENT_TOOLS.has(b.name ?? "") || b.name === LEAD) {
for (const e of (b.name === LEAD ? b.edits : r0?.edits) ?? []) {
if (!files.has(e.path)) byAgent.add(e.path);
const f = files.get(e.path) ?? { added: 0, removed: 0, counted: false };
f.added += e.added;
f.removed += e.removed;
f.counted ||= e.counted;
files.set(e.path, f);
}
continue;
}
const input = (b.input ?? {}) as Record<string, unknown>;
const p = input.file_path ?? input.notebook_path;
const r = b.id ? toolResultMap.get(b.id) : undefined;
if (typeof p !== "string" || !r || r.is_error) continue;
if (b.name === "Write") created.add(p);
byAgent.delete(p);
const f = files.get(p) ?? { added: 0, removed: 0, counted: false };
f.added += r.diff?.added ?? 0;
f.removed += r.diff?.removed ?? 0;
f.counted ||= !!r.diff;
files.set(p, f);
}
const artifacts = [...files.keys()].filter((p) => isArtifact(p, created.has(p)));
const artifacts = [...files.keys()].filter(
(p) => !byAgent.has(p) && isArtifact(p, created.has(p))
);
const cards = artifacts.length > 0 && (
<div data-testid="artifact-cards" className="flex flex-col gap-2">
{artifacts.map((p) => (
@@ -773,6 +810,7 @@ function MessageListView({
sessionId,
agentLinks,
compact = false,
leadEdits,
}: MessageListProps) {
const { t } = useTranslation("sessions");
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
@@ -798,9 +836,10 @@ function MessageListView({
m.sender === "orchestrator" ? { ...m, sender: "user" as const } : m
)
: messages,
keys
keys,
leadEdits
),
[messages, keys, compact]
[messages, keys, compact, leadEdits]
);
const agents = useMemo(
() => (live ? backgroundAgents(messages, toolResultMap) : []),
@@ -4,7 +4,10 @@
* Desktop's "Edited N files" card: one row per file with the lines added and
* removed summed over the turn, a failed call left out, a row click handed
* to the caller, and a row (or the header, every file) dragged as `@path`
* attachments for a prompt box.
* attachments for a prompt box. Files a subagent changed (its Agent
* result's `edits`, read from its own transcript) are counted in the same
* card, never as an artifact; so are the files a turn changed before the
* loaded page began (`leadEdits`, the page's `turn_edits`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
@@ -127,4 +130,71 @@ describe("the Edited N files card", () => {
JSON.parse(all["application/x-agenthub-attach"]!).map((a: { path: string }) => a.path)
).toEqual(["@/w/a.ts", "@/w/b.json"]);
});
it("counts the files a subagent changed, beside the turn's own edits", () => {
render(
<MessageList
loading={false}
messages={[
user("run the task"),
call("1", "Edit", "/w/a.go"),
result("1", { added: 1, removed: 0 }),
{
type: "assistant",
sender: "assistant",
timestamp: at,
content: [{ type: "tool_use", id: "2", name: "Agent", input: { prompt: "do it" } }],
} as TranscriptMessage,
{
type: "user",
sender: "tool",
timestamp: at,
content: [
{
type: "tool_result",
id: "2",
output: "done",
agent: { id: "x1" },
edits: [
{ path: "/w/a.go", added: 4, removed: 2, counted: true },
{ path: "/w/b_test.go", added: 9, removed: 0, counted: true },
{ path: "/w/report.html", added: 5, removed: 0, counted: true },
],
},
],
} as TranscriptMessage,
user("thanks"),
]}
/>
);
const card = screen.getByTestId("edited-files");
expect(card.textContent).toContain("Edited 3 files");
expect(within(card).getByText("b_test.go")).toBeTruthy();
expect(within(card).getByTitle("/w/a.go").textContent).toContain("+5"); // a.go: 1 + 4
// A page only a subagent wrote is listed, not shown as an artifact card.
expect(within(card).getByText("report.html")).toBeTruthy();
expect(screen.queryByTestId("artifact-card")).toBeNull();
});
it("adds what the cut-off first turn changed before the page to that turn's card", () => {
render(
<MessageList
loading={false}
leadEdits={[{ path: "/w/rbac_test.go", added: 176, removed: 0, counted: true }]}
messages={[
call("1", "Write", "/w/handler_test.go"),
result("1", { added: 113, removed: 0 }),
user("what changed?"),
call("2", "Edit", "/w/x.go"),
result("2", { added: 1, removed: 0 }),
]}
/>
);
const [first, second] = screen.getAllByTestId("edited-files");
expect(first!.textContent).toContain("Edited 2 files");
expect(within(first!).getByText("rbac_test.go")).toBeTruthy();
expect(within(first!).getByText("handler_test.go")).toBeTruthy();
// The next turn's card is its own.
expect(second!.textContent).toContain("Edited 1 file");
});
});
@@ -69,7 +69,7 @@ const TOOLS: AiToolsView = {
tool("codex", "Codex", { launcher: "codex", proxyCapable: true, prompt: null }),
tool("gemini", "Gemini"),
tool("aider", "Aider", { installed: false, path: null }),
tool("grok", "Grok", { enabled: false }),
tool("goose", "Goose", { enabled: false }),
],
};
@@ -65,7 +65,7 @@ const VIEW: AiToolsView = {
tools: [
tool("claude", "Claude Code"),
tool("gemini", "Gemini"),
tool("grok", "Grok", { enabled: false }),
tool("goose", "Goose", { enabled: false }),
tool("aider", "Aider", { installed: false, path: null }),
],
};
@@ -155,9 +155,9 @@ describe("AiToolsSettings", () => {
await user.click(within(gemini).getByText("Set default"));
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { default: true });
// A disabled tool cannot become the default.
const grok = screen.getByTestId("ai-tool-grok");
expect(within(grok).getByText("Set default")).toBeDisabled();
expect(within(grok).getByRole("switch")).toHaveAttribute("aria-checked", "false");
const goose = screen.getByTestId("ai-tool-goose");
expect(within(goose).getByText("Set default")).toBeDisabled();
expect(within(goose).getByRole("switch")).toHaveAttribute("aria-checked", "false");
});
it("refreshes detection", async () => {
@@ -179,7 +179,7 @@ describe("AiToolsSettings", () => {
await user.click(screen.getByRole("menuitem", { name: "Disable" }));
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { enabled: false });
// A disabled tool cannot become the default from the menu either.
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("ai-tool-grok") });
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("ai-tool-goose") });
expect(screen.getByRole("menuitem", { name: "Set default" })).toBeDisabled();
});
});
@@ -301,11 +301,9 @@ describe("AiToolsSettings — install and update", () => {
describe("AiToolIcon", () => {
it("retries the icon when the same instance shows another tool", async () => {
const { AiToolIcon } = await import("../../AiToolIcon");
const { container, rerender } = render(
<AiToolIcon tool={{ id: "opencode", label: "OpenCode" }} />
);
const { container, rerender } = render(<AiToolIcon tool={{ id: "aider", label: "Aider" }} />);
fireEvent.error(container.querySelector("img")!);
expect(screen.getByText("O")).toBeInTheDocument();
expect(screen.getByText("A")).toBeInTheDocument();
rerender(<AiToolIcon tool={{ id: "cursor", label: "Cursor CLI" }} />);
expect(container.querySelector('img[src="/api/ai-tools/cursor/icon"]')).toBeTruthy();
});
+28 -2
View File
@@ -34,7 +34,6 @@
"download": "Download",
"noPreview": "This file type cannot be shown here — download it to open it.",
"install": "Install",
"installs": "{{count}} installs",
"installed": "Installed to {{path}}",
"installedPlugin": "Installed {{name}} — start a new session (or /reload-plugins) to use it",
"rename": "Edit",
@@ -197,5 +196,32 @@
"unowned.claimed": "Added to your library",
"unowned.declined": "Removed from your list",
"unowned.assigned": "Owner assigned",
"unowned.empty": "Nothing without an owner."
"unowned.empty": "Nothing without an owner.",
"installs_one": "{{count}} install",
"installs_other": "{{count}} installs",
"private": "Private",
"privateHint": "Not shared: only you (and admins) see it. Share puts it in the Hub.",
"showMore": "Show {{count}} more",
"view.label": "Layout",
"view.list": "List view",
"view.grid": "Grid view",
"pack.title": "{{prefix}}-* pack",
"pack.count_one": "{{count}} skill",
"pack.count_other": "{{count}} skills",
"pack.installAll": "Install all",
"pack.installRest": "Install {{count}} more",
"pack.installTitle": "Install {{count}} skills of {{prefix}}-*?",
"pack.installBody": "Each one is written into your own account. A skill you already have under the same name is left as it is:",
"pack.installed": "Installed {{ok}} · already there {{skipped}} · failed {{failed}}",
"section.unshared": "Not shared by their owners",
"section.unsharedHint": "Other users' private items — only admins see these",
"update": "Update",
"updateHint": "Its owner changed it since you installed it",
"updateTitle": "Update “{{title}}”?",
"updateBody": "Your copy of {{name}} is replaced by the newest version. The current one is moved to ~/.claude/agenthub-replaced-skills/, not deleted.",
"updated": "Updated {{name}}",
"pack.updateN": "Update {{count}}",
"pack.updateTitle": "Update {{count}} skills of {{prefix}}-*?",
"pack.updateBody": "Each copy is replaced by the newest version; the current ones are moved to ~/.claude/agenthub-replaced-skills/:",
"pack.updated": "Updated {{ok}} · failed {{failed}}"
}
+25 -1
View File
@@ -197,5 +197,29 @@
"unowned.claimed": "Đã nhận về thư viện của bạn",
"unowned.declined": "Đã bỏ khỏi danh sách của bạn",
"unowned.assigned": "Đã gán chủ",
"unowned.empty": "Không có mục nào chưa có chủ."
"unowned.empty": "Không có mục nào chưa có chủ.",
"private": "Riêng tư",
"privateHint": "Chưa chia sẻ: chỉ bạn (và admin) thấy. Bấm Chia sẻ để đưa lên Hub.",
"showMore": "Xem thêm {{count}}",
"view.label": "Cách hiển thị",
"view.list": "Dạng danh sách",
"view.grid": "Dạng lưới",
"pack.title": "Bộ {{prefix}}-*",
"pack.count": "{{count}} skill",
"pack.installAll": "Cài tất cả",
"pack.installRest": "Cài {{count}} còn lại",
"pack.installTitle": "Cài {{count}} skill của bộ {{prefix}}-*?",
"pack.installBody": "Mỗi skill được ghi vào tài khoản của bạn. Skill nào bạn đã có cùng tên thì giữ nguyên:",
"pack.installed": "Đã cài {{ok}} · đã có sẵn {{skipped}} · lỗi {{failed}}",
"section.unshared": "Chủ chưa chia sẻ",
"section.unsharedHint": "Mục riêng tư của người khác — chỉ admin thấy",
"update": "Cập nhật",
"updateHint": "Người tạo đã sửa skill này từ khi bạn cài",
"updateTitle": "Cập nhật “{{title}}”?",
"updateBody": "Bản {{name}} của bạn sẽ được thay bằng phiên bản mới nhất. Bản hiện tại được chuyển sang ~/.claude/agenthub-replaced-skills/, không bị xóa.",
"updated": "Đã cập nhật {{name}}",
"pack.updateN": "Cập nhật {{count}}",
"pack.updateTitle": "Cập nhật {{count}} skill của bộ {{prefix}}-*?",
"pack.updateBody": "Mỗi bản sẽ được thay bằng phiên bản mới nhất; bản hiện tại được chuyển sang ~/.claude/agenthub-replaced-skills/:",
"pack.updated": "Đã cập nhật {{ok}} · lỗi {{failed}}"
}
+10
View File
@@ -3143,6 +3143,13 @@ export const api = {
method: "POST",
body: JSON.stringify({ replace }),
}),
/** POST /api/artifacts/:id/install `{update: true}` — an installed skill's
* copies replaced by its newest version (the old ones moved aside). */
updateInstalled: (id: number) =>
request<{ installed: string; replaced?: boolean }>(`/artifacts/${id}/install`, {
method: "POST",
body: JSON.stringify({ update: true }),
}),
/** POST /api/artifacts/:id/uninstall — a skill moved aside, a plugin `claude plugin uninstall`. */
uninstall: (id: number) =>
request<{ uninstalled: string; paths: string[] }>(`/artifacts/${id}/uninstall`, {
@@ -4423,6 +4430,9 @@ export interface Artifact {
inLibrary: boolean;
/** List rows only: a skill/plugin already in every account the viewer's sessions run as. */
installed?: boolean;
/** List rows only: an installed skill whose copy differs from its newest
* version (its owner changed it since) — the list offers Update. */
updateAvailable?: boolean;
/** file: captured when its session stopped, not pinned by hand. */
auto?: boolean;
/** Server mode: nobody owns it yet. */
+12 -5
View File
@@ -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";
/** One per plan window: `5h`, `wk`, `mo`, …; `usedPercent` is 0–100.
* `group` names the model pool a window belongs to (Antigravity's). */
windows: Array<{
@@ -3236,6 +3235,10 @@ export interface TranscriptContent {
/** Present on a Write/Edit/MultiEdit "tool_result": the lines it added and
* removed (from Claude Code's `structuredPatch`). */
diff?: { added: number; removed: number };
/** Present on an Agent/Task "tool_result" whose subagent (or one it started)
* wrote or edited files: each file with its +/- lines (`counted`: the
* results carried them). Read from the subagent's own transcript. */
edits?: { path: string; added: number; removed: number; counted: boolean }[];
/** Present on an AskUserQuestion "tool_result": question text → the option
* the user picked, or what they typed. */
answers?: Record<string, string>;
@@ -3320,6 +3323,10 @@ export interface TranscriptResult {
last_line: number;
/** JSONL line number of the first message in this page. */
first_line: number;
/** Claude only, when the page starts in the middle of a turn: the files
* that turn changed before the page's first message — the chat adds them
* to the turn's "Edited N files" card. */
turn_edits?: { path: string; added: number; removed: number; counted: boolean }[];
}
/** One entry in a session's transcript picker (main agent, a subagent, or a
File diff suppressed because it is too large Load Diff
+82 -11
View File
@@ -13,6 +13,10 @@
* the sandbox cannot show (Office, zip, PDF) offers Download only, the
* Add menu closes on Escape or a click outside it, the front page is shelved
* by kind until a category is picked, and a card opens its detail sheet.
* Rows carry one primary action; Preview, Share and Edit live in the ⋯ menu.
* A section shows 30 entries before "Show more", and three or more skills of
* one owner sharing a name prefix are one pack row that installs them all.
* An installed skill whose owner changed it shows Update (after a confirmation).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -33,6 +37,7 @@ vi.mock("../../lib/api", () => ({
update: vi.fn(),
remove: vi.fn(),
install: vi.fn(),
updateInstalled: vi.fn(),
uninstall: vi.fn(),
installs: vi.fn(),
setInstallsEnabled: vi.fn(),
@@ -113,6 +118,16 @@ const addMenu = async (user: ReturnType<typeof userEvent.setup>, label: string)
await user.click(screen.getByRole("button", { name: label }));
};
/** Run one item of the first (or the given) row's ⋯ menu. */
async function menuAction(
user: ReturnType<typeof userEvent.setup>,
name: string,
scope: HTMLElement = document.body
) {
await user.click(within(scope).getAllByRole("button", { name: "More actions" })[0]!);
await user.click(within(screen.getByTestId("artifact-menu")).getByRole("menuitem", { name }));
}
describe("Adding", () => {
it("adds the plugins picked from a marketplace link", async () => {
const user = userEvent.setup();
@@ -218,7 +233,11 @@ describe("Artifacts", () => {
const user = userEvent.setup();
open();
expect(await screen.findByRole("button", { name: "Hub" })).toBeInTheDocument();
expect(screen.getAllByRole("button", { name: /^Share$/ }).length).toBe(2);
await user.click(screen.getAllByRole("button", { name: "More actions" })[0]!);
expect(
within(screen.getByTestId("artifact-menu")).getByRole("menuitem", { name: "Share" })
).toBeInTheDocument();
await user.keyboard("{Escape}");
// One merged list by default; Hub is a filter over it.
expect(api.artifacts.list).toHaveBeenLastCalledWith(expect.objectContaining({ scope: "all" }));
await user.click(screen.getByRole("button", { name: "Hub" }));
@@ -271,20 +290,46 @@ describe("Artifacts", () => {
expect(within(screen.getByTestId("half-mine.own")).getByText("my-page")).toBeInTheDocument();
});
it("pages a long section 12 cards at a time", async () => {
it("shows a long section 30 entries at a time, then the rest on Show more", async () => {
vi.mocked(api.artifacts.list).mockResolvedValue({
items: Array.from({ length: 14 }, (_, i) =>
items: Array.from({ length: 34 }, (_, i) =>
item({ id: i + 1, kind: "page", title: `p${i + 1}`, files: [] })
),
});
const user = userEvent.setup();
open();
await screen.findByText("p1");
expect(screen.queryByText("p13")).toBeNull();
expect(screen.getByText("Showing 1-12 of 14")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Next" }));
expect(screen.getByText("p13")).toBeInTheDocument();
expect(screen.queryByText("p1")).toBeNull();
expect(screen.getByText("p30")).toBeInTheDocument();
expect(screen.queryByText("p31")).toBeNull();
await user.click(screen.getByRole("button", { name: "Show 4 more" }));
expect(screen.getByText("p34")).toBeInTheDocument();
expect(screen.getByText("p1")).toBeInTheDocument();
});
it("folds three skills sharing a name prefix into one pack that installs them all", async () => {
vi.mocked(api.artifacts.list).mockResolvedValue({
items: [
item({ id: 1, kind: "skill", title: "sg-qa", source: "sg-qa" }),
item({ id: 2, kind: "skill", title: "sg-ship", source: "sg-ship", installed: true }),
item({ id: 3, kind: "skill", title: "sg-fix", source: "sg-fix" }),
item({ id: 4, kind: "skill", title: "lone", source: "lone" }),
],
});
vi.mocked(api.artifacts.install).mockResolvedValue({ installed: "/h/x" } as never);
const user = userEvent.setup();
open();
const pack = await screen.findByTestId("pack-sg");
expect(within(pack).getByText("sg-* pack")).toBeInTheDocument();
expect(within(pack).getByText("3 skills")).toBeInTheDocument();
expect(screen.queryByTestId("artifact-1")).toBeNull();
expect(screen.getByTestId("artifact-4")).toBeInTheDocument();
await user.click(within(pack).getByRole("button", { expanded: false }));
expect(within(pack).getByTestId("artifact-1")).toBeInTheDocument();
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
await user.click(within(pack).getByRole("button", { name: "Install 2 more" }));
await waitFor(() => expect(api.artifacts.install).toHaveBeenCalledTimes(2));
expect(api.artifacts.install).toHaveBeenCalledWith(1);
expect(api.artifacts.install).toHaveBeenCalledWith(3);
});
it("lists who installed an artifact, when and where, from its install count", async () => {
@@ -333,12 +378,38 @@ describe("Artifacts", () => {
expect(within(card).getByText("Hub only")).toBeInTheDocument();
expect(within(card).queryByRole("button", { name: "Delete" })).toBeNull();
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
await user.click(within(card).getByRole("button", { name: "Remove from Hub" }));
await menuAction(user, "Remove from Hub", card);
await waitFor(() => expect(api.artifacts.update).toHaveBeenCalledWith(3, { shared: false }));
const calls = vi.mocked(confirmDialog).mock.calls;
expect(calls[calls.length - 1]?.[0].title).toMatch(/Remove “hub-only” from the Hub/);
});
it("offers Update for an installed skill its owner changed, after a confirmation", async () => {
vi.mocked(api.artifacts.list).mockResolvedValue({
items: [
item({
id: 2,
kind: "skill",
title: "deploy",
source: "deploy",
installed: true,
updateAvailable: true,
}),
],
});
vi.mocked(api.artifacts.updateInstalled).mockResolvedValue({ installed: "/h/x" });
const user = userEvent.setup();
open();
const card = await screen.findByTestId("artifact-2");
expect(within(card).queryByTestId("artifact-installed")).toBeNull();
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
await user.click(within(card).getByRole("button", { name: "Update" }));
expect(api.artifacts.updateInstalled).not.toHaveBeenCalled();
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
await user.click(within(card).getByRole("button", { name: "Update" }));
await waitFor(() => expect(api.artifacts.updateInstalled).toHaveBeenCalledWith(2));
});
it("shows Installed instead of Install for a skill already installed", async () => {
vi.mocked(api.artifacts.list).mockResolvedValue({
items: [item({ id: 2, kind: "skill", title: "deploy", source: "deploy", installed: true })],
@@ -436,7 +507,7 @@ describe("Artifacts", () => {
const user = userEvent.setup();
open();
await screen.findByText("standup");
await user.click(screen.getByRole("button", { name: "Preview" }));
await menuAction(user, "Preview");
expect(await screen.findByRole("heading", { name: "Daily standup" })).toBeInTheDocument();
expect(screen.getByTestId("skill-summary")).toHaveTextContent("/out/report.html");
expect(screen.queryByTestId("artifact-preview")).toBeNull();
@@ -464,7 +535,7 @@ describe("Artifacts", () => {
const user = userEvent.setup();
open();
await screen.findByText("standup");
await user.click(screen.getByRole("button", { name: "Edit" }));
await menuAction(user, "Edit");
const name = await screen.findByLabelText(/Skill name/);
expect(name).toHaveValue("standup");
expect(screen.getByLabelText(/Instructions/)).toHaveValue("Old body");
@@ -7244,7 +7244,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.8.23 (rebuild client to match)
UI build v2.8.24 (rebuild client to match)
</span>
</p>
</div>
+6 -5
View File
File diff suppressed because one or more lines are too long
-1
View File
@@ -260,7 +260,6 @@ DASHBOARD_ANTIGRAVITY_POLL_MS=15000 # agy conversation files → Antigravity
AGENTHUB_GEMINI_DIR= # where agy keeps its data (default ~/.gemini)
DASHBOARD_CURSOR_POLL_MS=15000 # Cursor agent transcripts → Cursor sessions (no tokens; 60 s while idle)
DASHBOARD_OMP_POLL_MS=15000 # omp session journals → Oh My Pi sessions/tokens/subagents (60 s while idle)
DASHBOARD_OPENCODE_POLL_MS=15000 # opencode.db → OpenCode sessions/tokens/subagents (60 s while idle)
AGENTHUB_CURSOR_DIR= # where Cursor keeps its data (default ~/.cursor)
AGENTHUB_AGY_USAGE= # 0 never runs `agy --print /usage` for the Usage panel's Antigravity quota (default on; running agy may update it)
+1 -1
View File
@@ -1838,7 +1838,7 @@ agenthub stage <stage> [--evidence "..."] # Report the current stage
## Workspaces on any AI tool
A workspace can run any AI coding CLI in the AI Tools catalog (`server/data/ai-tools/*.json` — seven built in: Claude Code, Codex, Antigravity, OpenCode, Cursor CLI (`cursor-agent`), Grok Build (`grok`) and Oh My Pi —, plus any `*.json` in `DASHBOARD_AI_TOOLS_DIR`, which replaces a built-in file with the same `id` but may not set `launcher`). Settings → Agents → **AI tools** (`/settings/agents`; the old `/settings#ai-tools` redirects there) lists what is installed (found on PATH; **Refresh** re-reads the login shell's PATH first) with an enable switch, **Set default** and **Update** (for every tool whose catalog file names an updater — no version lookup, the updater itself reports "up to date"), and, folded behind a disclosure, what is available to install with a homepage link. Each installed, enabled tool's own settings (`/settings/agents/<id>`) are tabs on that page — **All tools** · Claude Code · Codex · … — rather than items in the left nav, and a click on a tool's row (or its name) opens its tab. The Add-workspace modal shows the chosen tool as one button (the default until changed); clicking it lists every installed, enabled tool as buttons to pick from; `agenthub workspace add --tool <id>` does the same from a terminal. The run form's **Model** picker lists the workspace tool's own models (`GET /api/ai-tools/:id/models`; Claude, Codex, and any catalog tool whose file declares `model: { flag }` — the id is validated and passed as `<flag> <id>`), plus **Custom…**, and starts on the tool's default model when one is set.
A workspace can run any AI coding CLI in the AI Tools catalog (`server/data/ai-tools/*.json` — five built in: Claude Code, Codex, Antigravity, Cursor CLI (`cursor-agent`) and Oh My Pi —, plus any `*.json` in `DASHBOARD_AI_TOOLS_DIR`, which replaces a built-in file with the same `id` but may not set `launcher`). Settings → Agents → **AI tools** (`/settings/agents`; the old `/settings#ai-tools` redirects there) lists what is installed (found on PATH; **Refresh** re-reads the login shell's PATH first) with an enable switch, **Set default** and **Update** (for every tool whose catalog file names an updater — no version lookup, the updater itself reports "up to date"), and, folded behind a disclosure, what is available to install with a homepage link. Each installed, enabled tool's own settings (`/settings/agents/<id>`) are tabs on that page — **All tools** · Claude Code · Codex · … — rather than items in the left nav, and a click on a tool's row (or its name) opens its tab. The Add-workspace modal shows the chosen tool as one button (the default until changed); clicking it lists every installed, enabled tool as buttons to pick from; `agenthub workspace add --tool <id>` does the same from a terminal. The run form's **Model** picker lists the workspace tool's own models (`GET /api/ai-tools/:id/models`; Claude, Codex, and any catalog tool whose file declares `model: { flag }` — the id is validated and passed as `<flag> <id>`), plus **Custom…**, and starts on the tool's default model when one is set.
A catalog file:
+4 -7
View File
@@ -310,13 +310,10 @@ per model and one agent per subagent the `task` tool spawned (its journal sits i
the folder named like the session's). An omp session can be resumed by id from
its workspace (`omp --resume <id>`), like a Claude or Codex one.
**OpenCode** sessions are read from its SQLite store
(`~/.local/share/opencode/opencode.db`, every `DASHBOARD_OPENCODE_POLL_MS` ms),
with their tokens and cost per model and one agent per subagent, and are
resumable by id (`opencode --session <id>`). **Grok Build** runs the Claude hooks in
`~/.claude/settings.json` itself, so its sessions arrive through AgentHub's own
hooks, recorded as Grok sessions (live status, no tokens); they resume with
`grok --resume <id>`. Cursor sessions resume by id too (`--resume <id>`).
Cursor sessions resume by id too (`--resume <id>`), and open as a chat like
omp ones. OpenCode and Grok Build are no longer AgentHub tools: a Grok Build
running the Claude hooks in `~/.claude/settings.json` is ignored, and sessions an
older build recorded for either stay in the history.
**Pipelines are dormant.** AgentHub ships no pipeline template, so a workspace has no
stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see
+1 -1
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3
info:
title: Agent Dashboard for Claude Code API
version: 2.8.23
version: 2.8.24
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact:
name: Nguyễn Ngọc Trí Vĩ
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.8.23",
"version": "2.8.24",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.23",
"version": "2.8.24",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.23",
"version": "2.8.24",
"bin": {
"agenthub": "bin/agenthub.js"
},
+1 -3
View File
@@ -51,14 +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 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
// "the" service manager and its server.conf would send user changes to sudo.
+6 -17
View File
@@ -655,23 +655,12 @@ An `antigravity` workspace carries no pipeline either.
what it said so far; `GET /api/sessions/:id/transcript(s)` reads an omp
session's journals from there. Under S3 each member's `~/.omp` is read only
through `openSafeAsync`.
- **OpenCode sessions:** `server/lib/opencode-usage.js` reads OpenCode's
SQLite store (`~/.local/share/opencode/opencode.db`, under `XDG_DATA_HOME`
when set) every `DASHBOARD_OPENCODE_POLL_MS` ms (default 15000, `0`
disables), when its file or WAL changed: each root `session` row becomes a
`sessions` row (`tool = 'opencode'`), its tree's assistant `message` tokens
per `modelID` become `token_usage` (reasoning counted as output), and each
child session (the `task` tool's) an `opencode-<id>` subagent row.
`opencode_stores` holds each store's mtime and `opencode_sessions` each root
session's newest `time_updated`; the transcript routes read the conversation
(messages + parts) from the store itself. A member's store and WAL are copied
through the guard before they are opened.
- **Grok sessions:** Grok runs the hooks in `~/.claude/settings.json` (its
Claude compatibility), so AgentHub's own hook reaches `POST /api/hooks/event`
with no `tool`; `routes/hooks.js` tells it apart by the camelCase keys only
Grok sends (`hookEventName` + `workspaceRoot`), records it as `tool = 'grok'`
and drops its `transcript_path` (Grok's file, never parsed as a Claude
transcript). No tokens: Grok's session files are not read.
- **Grok hooks:** Grok is not an AgentHub tool, but it runs the hooks in
`~/.claude/settings.json` (its Claude compatibility), so AgentHub's own hook
can still reach `POST /api/hooks/event` with no `tool`; `routes/hooks.js`
tells it apart by the camelCase keys only Grok sends (`hookEventName` +
`workspaceRoot`) and drops it, rather than record a Claude session whose
`transcript_path` is Grok's file.
- **Quiet sessions:** agy fires no exit hook, so a session quiet for ten
minutes (with hook events) or two (without) is settled to `completed`, its
agents too, and sets its workspace idle. That workspace must be one the hooks left
+167
View File
@@ -0,0 +1,167 @@
/**
* @file Tests for server/lib/agent-edits.js (issue #86): the files a
* subagent wrote or edited are read from its own transcript — +/- lines from
* `structuredPatch` or a created file's content, a failed call left out, a
* nested subagent folded in — and put on the parent's Agent result as `edits`;
* and `turnCarry` hands a page of the chat the edits its cut-off first turn
* made before the page began.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const HOME = fs.mkdtempSync(path.join(os.tmpdir(), "agent-edits-"));
process.env.CLAUDE_HOME = HOME;
const { encodeCwd } = require("../lib/claude-home");
const { agentEdits, attachAgentEdits, turnCarry, _cache } = require("../lib/agent-edits");
const CWD = "/work/repo";
const SID = "11111111-2222-3333-4444-555555555555";
const subDir = path.join(HOME, "projects", encodeCwd(CWD), SID, "subagents");
const call = (id, name, input) => ({
type: "assistant",
message: { content: [{ type: "tool_use", id, name, input }] },
});
const result = (id, toolUseResult, isError = false) => ({
type: "user",
message: { content: [{ type: "tool_result", tool_use_id: id, is_error: isError }] },
toolUseResult,
});
const write = (agentId, lines) =>
fs.writeFileSync(
path.join(subDir, `agent-${agentId}.jsonl`),
lines.map((l) => JSON.stringify(l)).join("\n")
);
before(() => {
fs.mkdirSync(subDir, { recursive: true });
write("a1", [
call("t1", "Edit", { file_path: "/work/repo/main.go" }),
result("t1", { structuredPatch: [{ lines: [" x", "-old", "+new", "+more"] }] }),
call("t2", "Write", { file_path: "/work/repo/new_test.go" }),
result("t2", { type: "create", content: "a\nb\nc\n" }),
call("t3", "Edit", { file_path: "/work/repo/broken.go" }),
result("t3", "String to replace not found", true),
call("t4", "Agent", { prompt: "go deeper" }),
result("t4", { agentId: "a2", status: "completed" }),
]);
write("a2", [
call("u1", "Edit", { file_path: "/work/repo/main.go" }),
result("u1", { structuredPatch: [{ lines: ["+x"] }] }),
call("u2", "Bash", { command: "go test ./..." }),
result("u2", { stdout: "ok" }),
]);
});
after(() => fs.rmSync(HOME, { recursive: true, force: true }));
describe("agentEdits", () => {
it("lists a subagent's files with their lines, nested subagents folded in", async () => {
const edits = await agentEdits(SID, CWD, "a1");
assert.deepEqual(edits, [
{ path: "/work/repo/main.go", added: 3, removed: 1, counted: true },
{ path: "/work/repo/new_test.go", added: 3, removed: 0, counted: true },
]);
});
it("answers an empty list for a subagent with no transcript", async () => {
assert.deepEqual(await agentEdits(SID, CWD, "nope"), []);
});
it("reads a transcript again only after it changed", async () => {
await agentEdits(SID, CWD, "a2");
const file = path.join(subDir, "agent-a2.jsonl");
const before = _cache.get(file);
await agentEdits(SID, CWD, "a2");
assert.equal(_cache.get(file), before);
fs.appendFileSync(
file,
"\n" +
[
call("u3", "Write", { file_path: "/work/repo/late.go" }),
result("u3", { type: "create", content: "z\n" }),
]
.map((l) => JSON.stringify(l))
.join("\n")
);
const edits = await agentEdits(SID, CWD, "a2");
assert.ok(edits.some((e) => e.path === "/work/repo/late.go"));
});
});
describe("attachAgentEdits", () => {
it("puts edits on an Agent result only", async () => {
const messages = [
{
content: [
{ type: "tool_result", id: "t9", agent: { id: "a1" } },
{ type: "tool_result", id: "t8", diff: { added: 1, removed: 0 } },
{ type: "tool_result", id: "t7", agent: { id: "a1" }, is_error: true },
],
},
];
await attachAgentEdits(messages, SID, CWD);
const [ok, plain, failed] = messages[0].content;
assert.ok(ok.edits.some((e) => e.path === "/work/repo/new_test.go"));
assert.equal(plain.edits, undefined);
assert.equal(failed.edits, undefined);
});
});
describe("turnCarry", () => {
const prompt = (text) => ({ type: "user", sender: "user", content: [{ type: "text", text }] });
const use = (id, name, file_path) => ({
type: "assistant",
sender: "assistant",
content: [{ type: "tool_use", id, name, input: file_path ? { file_path } : {} }],
});
const res = (id, extra = {}) => ({
type: "user",
sender: "tool",
content: [{ type: "tool_result", id, ...extra }],
});
it("answers the files the cut-off turn changed before the page", async () => {
const c = turnCarry();
for (const m of [
prompt("old turn"),
use("o1", "Write", "/w/old.go"),
res("o1", { diff: { added: 9, removed: 0 } }),
prompt("this turn"),
use("1", "Write", "/w/a_test.go"),
res("1", { diff: { added: 176, removed: 0 } }),
use("2", "Edit", "/w/bad.go"),
res("2", { is_error: true }),
use("3", "Edit", "/w/b.go"),
])
c.drop(m);
// The page: the result of a dropped call, then more of the same turn.
const page = [res("3", { diff: { added: 2, removed: 1 } }), use("4", "Write", "/w/c.go")];
assert.deepEqual(await c.result(page, SID, CWD), [
{ path: "/w/a_test.go", added: 176, removed: 0, counted: true },
{ path: "/w/b.go", added: 2, removed: 1, counted: true },
]);
});
it("answers null when the page starts on a prompt", async () => {
const c = turnCarry();
c.drop(prompt("p"));
c.drop(use("1", "Write", "/w/a.go"));
c.drop(res("1", { diff: { added: 1, removed: 0 } }));
assert.equal(await c.result([prompt("next")], SID, CWD), null);
});
it("folds in a dropped subagent's own edits", async () => {
const c = turnCarry();
c.drop(prompt("p"));
c.drop(use("9", "Agent"));
c.drop(res("9", { agent: { id: "a1" } }));
const got = await c.result([use("10", "Bash")], SID, CWD);
assert.ok(got.some((e) => e.path === "/work/repo/new_test.go"));
});
});
+2 -2
View File
@@ -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");
+31 -11
View File
@@ -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 () => {
+67 -2
View File
@@ -10,8 +10,10 @@
* junk skipped, nothing stored when one fails) or written in the dialog, a
* file uploaded, sharing refused in local mode, visibility for a server-mode
* developer, the admin's installs switch, same-origin writes, Claude Docs
* docs captured at their birth, documents captured at a session's stop, and
* unowned rows claimed, declined and assigned.
* docs captured at their birth, documents captured at a session's stop,
* unowned rows claimed, declined and assigned, and a skill that follows its
* source folder (a newer change wins over a Hub edit) with Update offered to
* an installed copy that differs.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -338,6 +340,69 @@ describe("skills", () => {
assert.equal(empty.status, 400);
});
it("follows its source folder and offers Update to an installed copy", async () => {
const src = path.join(ws, ".claude", "skills", "notes");
fs.mkdirSync(src, { recursive: true });
fs.writeFileSync(path.join(src, "SKILL.md"), "---\nname: notes\ndescription: v1\n---\nOne.\n");
const r = await request("POST", "/api/artifacts/skill", { laneId: lane.id, name: "notes" });
assert.equal(r.status, 201, r.text);
const id = r.body.item.id;
const listed = async () =>
(await request("GET", "/api/artifacts?kind=skill")).body.items.find((i) => i.id === id);
const raw = async () => (await request("GET", `/api/artifacts/${id}/raw?path=SKILL.md`)).text;
assert.equal((await request("POST", `/api/artifacts/${id}/install`, {})).status, 200);
let item = await listed();
assert.equal(item.installed, true);
assert.equal(item.updateAvailable, false);
const installs = item.installs;
// Unchanged folder: nothing taken.
assert.deepEqual(artifacts.syncSkillSources({ force: true }), []);
// The owner changes it: a new snapshot, and the installed copy is behind.
const snapBefore = db.prepare("SELECT snapshot FROM artifacts WHERE id = ?").get(id).snapshot;
fs.writeFileSync(path.join(src, "SKILL.md"), "---\nname: notes\ndescription: v2\n---\nTwo.\n");
fs.writeFileSync(path.join(src, "extra.md"), "more\n");
const changed = artifacts.syncSkillSources({ force: true });
assert.deepEqual(
changed.map((c) => c.id),
[id]
);
assert.match(await raw(), /Two\./);
assert.ok(!fs.existsSync(path.join(process.env.DASHBOARD_DATA_DIR, "artifacts", snapBefore)));
item = await listed();
assert.equal(item.description, "v2");
assert.ok(item.files.some((f) => f.path === "extra.md"));
assert.equal(item.updateAvailable, true);
// Update: the copy is replaced (old one moved aside), not counted again.
const up = await request("POST", `/api/artifacts/${id}/install`, { update: true });
assert.equal(up.status, 200, up.text);
const target = path.join(process.env.CLAUDE_HOME, "skills", "notes");
assert.match(fs.readFileSync(path.join(target, "SKILL.md"), "utf8"), /Two\./);
item = await listed();
assert.equal(item.updateAvailable, false);
assert.equal(item.installs, installs);
// A Hub edit is not undone by the unchanged folder; a newer folder change wins.
const edit = await request("PATCH", `/api/artifacts/${id}`, {
skill: { name: "notes", description: "hub", content: "Edited in the Hub." },
});
assert.equal(edit.status, 200, edit.text);
artifacts.syncSkillSources({ force: true });
assert.match(await raw(), /Edited in the Hub\./);
fs.writeFileSync(
path.join(src, "SKILL.md"),
"---\nname: notes\ndescription: v3\n---\nThree.\n"
);
artifacts.syncSkillSources({ force: true });
assert.match(await raw(), /Three\./);
// Update needs an installed copy.
await request("POST", `/api/artifacts/${id}/uninstall`, {});
const none = await request("POST", `/api/artifacts/${id}/install`, { update: true });
assert.equal(none.body.error.code, "ENOTINSTALLED");
});
it("refuses a skill holding a symlink or a secret-looking file", async () => {
const bad = path.join(ws, ".claude", "skills", "leaky");
fs.mkdirSync(bad, { recursive: true });
@@ -1,7 +1,7 @@
{
"id": "grok",
"label": "Grok",
"detect": ["grok"],
"id": "septool",
"label": "Separator tool",
"detect": ["septool"],
"prompt": {
"mode": "argv",
"separator": "--"
+9 -14
View File
@@ -2,10 +2,10 @@
* @file Tests POST /api/hooks/event for a hook Grok Build fired through the
* Claude hooks in `~/.claude/settings.json` (Grok's Claude compatibility): it
* arrives with no `tool`, is told apart by the camelCase keys only Grok sends
* (`hookEventName`, `workspaceRoot`), and lands as a `tool = 'grok'` session
* on the grok workspace — never the claude one sharing the directory — with
* its `transcript_path` dropped. A Claude hook is left Claude's. The DB lives
* in a temp dir.
* (`hookEventName`, `workspaceRoot`), and dropped — Grok is not an AgentHub
* tool, and taken as Claude's it would bind the claude workspace and name a
* Grok file as a Claude transcript. A Claude hook is left Claude's. The DB
* lives in a temp dir.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -58,23 +58,18 @@ const grokData = (id, extra = {}) => ({
});
describe("Grok hooks through the Claude settings", () => {
it("records a grok session, on the grok workspace, with no transcript path", async () => {
it("drops a Grok hook: no session, no event, no workspace bound", async () => {
const claudeLane = lanes.createLane({ title: "c", cwd: CWD, tool: "claude" });
const grokLane = lanes.createLane({ title: "g", cwd: CWD, tool: "grok" });
const id = "0199f0aa-1111-7222-8333-444455556666";
await post({ hook_type: "SessionStart", data: grokData(id) });
const r = await post({ hook_type: "SessionStart", data: grokData(id) });
assert.equal(r.ignored, true);
await post({
hook_type: "UserPromptSubmit",
data: grokData(id, { hookEventName: "user_prompt_submit", prompt: "fix the bug" }),
});
const s = db.prepare("SELECT * FROM sessions WHERE id = ?").get(id);
assert.equal(s.tool, "grok");
assert.equal(s.transcript_path, null);
const events = db.prepare("SELECT data FROM events WHERE session_id = ?").all(id);
assert.ok(events.length >= 1);
for (const e of events) assert.equal(JSON.parse(e.data).transcript_path, undefined);
assert.equal(db.prepare("SELECT * FROM sessions WHERE id = ?").get(id), undefined);
assert.equal(db.prepare("SELECT COUNT(*) n FROM events WHERE session_id = ?").get(id).n, 0);
assert.equal(lanes.getLane(claudeLane.id).session_id, null);
assert.equal(lanes.getLane(grokLane.id).session_id, id);
});
it("leaves a Claude hook Claude's", async () => {
-188
View File
@@ -1,188 +0,0 @@
/**
* @file Tests server/lib/opencode-usage.js and the OpenCode branch of the
* transcript routes — OpenCode sessions from its SQLite store: a root session
* created as `tool = 'opencode'` with its title, cwd, model and per-model
* tokens (its subagent session's included, reasoning counted as output), a
* subagent row per child session, an unchanged store never re-read, a
* session the user deleted staying deleted until it is updated, and the
* conversation (text, reasoning, tool call + result) in the Claude
* transcript shape. The store is a temp file with the tables and columns
* opencode 1.19 writes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const fs = require("node:fs");
const path = require("node:path");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-opencode-usage-"));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
process.env.AGENTHUB_OPENCODE_DIR = path.join(ROOT, "opencode");
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const { db, Database } = require("../db");
const oc = require("../lib/opencode-usage");
const { createApp, startServer } = require("../index");
const ID = "ses_eac8393b4ffeEz26VGbeyq8sir";
const CHILD = "ses_eac8393b4fffChildSession01";
const CWD = path.join(fs.realpathSync(ROOT), "work");
const STORE = path.join(process.env.AGENTHUB_OPENCODE_DIR, "opencode.db");
const T0 = Date.parse("2026-10-19T09:27:05.000Z");
let server;
let BASE;
after(() => {
server?.close();
fs.rmSync(ROOT, { recursive: true, force: true });
});
function withStore(fn) {
const conn = new Database(STORE);
try {
fn(conn);
} finally {
conn.close();
}
}
const assistant = (model, tokens) => JSON.stringify({ role: "assistant", modelID: model, tokens });
const tokens = (input, output, reasoning = 0, read = 0, write = 0) => ({
input,
output,
reasoning,
cache: { read, write },
});
before(async () => {
fs.mkdirSync(path.dirname(STORE), { recursive: true });
withStore((c) => {
c.exec(`
CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT,
slug TEXT NOT NULL, directory TEXT NOT NULL, title TEXT NOT NULL, version TEXT NOT NULL,
model TEXT, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, time_archived INTEGER);
CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL, data TEXT NOT NULL);
CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT NOT NULL, session_id TEXT NOT NULL,
time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, data TEXT NOT NULL);
`);
const s = c.prepare(
"INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, model, time_created, time_updated) VALUES (?, 'global', ?, 'x', ?, ?, '1.19.3', ?, ?, ?)"
);
s.run(ID, null, CWD, "Fix the login bug", '{"id":"claude-sonnet-4-5","providerID":"anthropic"}', T0, T0 + 5000);
s.run(CHILD, ID, CWD, "Explore auth (@explore subagent)", null, T0 + 1000, T0 + 3000);
const m = c.prepare("INSERT INTO message VALUES (?, ?, ?, ?, ?)");
m.run("msg_1", ID, T0 + 10, T0 + 10, JSON.stringify({ role: "user" }));
m.run("msg_2", ID, T0 + 20, T0 + 20, assistant("claude-sonnet-4-5", tokens(100, 20, 5, 1000, 50)));
m.run("msg_3", CHILD, T0 + 1100, T0 + 1100, JSON.stringify({ role: "user" }));
m.run("msg_4", CHILD, T0 + 1200, T0 + 1200, assistant("claude-haiku-4-5", tokens(10, 4)));
const p = c.prepare("INSERT INTO part VALUES (?, ?, ?, ?, ?, ?)");
p.run("prt_1", "msg_1", ID, T0 + 11, T0 + 11, JSON.stringify({ type: "text", text: "Fix the login bug" }));
p.run("prt_2", "msg_2", ID, T0 + 21, T0 + 21, JSON.stringify({ type: "reasoning", text: "Look first" }));
p.run(
"prt_3",
"msg_2",
ID,
T0 + 22,
T0 + 22,
JSON.stringify({
type: "tool",
tool: "read",
callID: "call_1",
state: { status: "completed", input: { filePath: "auth.ts" }, output: "export {}" },
})
);
p.run("prt_4", "msg_2", ID, T0 + 23, T0 + 23, JSON.stringify({ type: "step-start" }));
p.run("prt_5", "msg_3", CHILD, T0 + 1101, T0 + 1101, JSON.stringify({ type: "text", text: "Explore auth" }));
p.run("prt_6", "msg_4", CHILD, T0 + 1201, T0 + 1201, JSON.stringify({ type: "text", text: "Found it" }));
});
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
const sessionRow = () => db.prepare("SELECT * FROM sessions WHERE id = ?").get(ID);
describe("opencode usage ingest", () => {
it("creates the root session with its title, cwd, model and its tree's tokens", async () => {
const r = await oc.ingestOpencodeUsage({ now: T0 + 600_000 });
assert.equal(r.files, 1);
const s = sessionRow();
assert.equal(s.tool, "opencode");
assert.equal(s.name, "Fix the login bug");
assert.equal(s.cwd, CWD);
assert.equal(s.model, "claude-sonnet-4-5");
assert.equal(s.status, "completed");
assert.equal(db.prepare("SELECT 1 FROM sessions WHERE id = ?").get(CHILD), undefined);
const usage = db
.prepare(
"SELECT model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens FROM token_usage WHERE session_id = ? ORDER BY model"
)
.all(ID);
assert.deepEqual(usage, [
{ model: "claude-haiku-4-5", input_tokens: 10, output_tokens: 4, cache_read_tokens: 0, cache_write_tokens: 0 },
{ model: "claude-sonnet-4-5", input_tokens: 100, output_tokens: 25, cache_read_tokens: 1000, cache_write_tokens: 50 },
]);
const agent = db.prepare("SELECT * FROM agents WHERE id = ?").get(`opencode-${CHILD}`);
assert.equal(agent.session_id, ID);
assert.equal(agent.type, "subagent");
assert.equal(agent.name, "Explore auth (@explore subagent)");
assert.equal(agent.status, "completed");
});
it("leaves an unchanged store unread", async () => {
const r = await oc.ingestOpencodeUsage({ now: T0 + 600_000 });
assert.equal(r.files, 0);
});
it("maps the store to the transcript shape, and lists the subagent's", async () => {
const list = await (await fetch(`${BASE}/api/sessions/${ID}/transcripts`)).json();
assert.deepEqual(
list.transcripts.map((t) => [t.id, t.type, t.db_agent_id]),
[
["main", "main", null],
[CHILD, "subagent", `opencode-${CHILD}`],
]
);
const body = await (await fetch(`${BASE}/api/sessions/${ID}/transcript`)).json();
assert.deepEqual(
body.messages.map((m) => [m.type, m.sender, m.content.map((c) => c.type).join("+")]),
[
["user", "user", "text"],
["assistant", "assistant", "thinking+tool_use"],
["user", "tool", "tool_result"],
]
);
assert.equal(body.messages[2].content[0].output, "export {}");
assert.equal(body.last_line, 3);
const tail = await (await fetch(`${BASE}/api/sessions/${ID}/transcript?after=2`)).json();
assert.equal(tail.messages.length, 1);
const sub = await (
await fetch(`${BASE}/api/sessions/${ID}/transcript?agent_id=${CHILD}`)
).json();
assert.deepEqual(
sub.messages.map((m) => [m.sender, m.content[0].text]),
[
["orchestrator", "Explore auth"],
["assistant", "Found it"],
]
);
});
it("keeps a deleted session deleted until OpenCode updates it", async () => {
db.prepare("DELETE FROM sessions WHERE id = ?").run(ID);
const bump = (t) => {
withStore((c) => c.prepare("UPDATE session SET title = title WHERE id = ?").run(ID));
const at = new Date(t);
fs.utimesSync(STORE, at, at);
};
bump(Date.now() + 5000);
await oc.ingestOpencodeUsage({ now: T0 + 600_000 });
assert.equal(sessionRow(), undefined);
withStore((c) => c.prepare("UPDATE session SET time_updated = ? WHERE id = ?").run(T0 + 9000, ID));
fs.utimesSync(STORE, new Date(Date.now() + 10_000), new Date(Date.now() + 10_000));
await oc.ingestOpencodeUsage({ now: T0 + 600_000 });
assert.equal(sessionRow()?.tool, "opencode");
});
});
+2 -2
View File
@@ -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"]);
});
+1 -178
View File
@@ -6,9 +6,7 @@
* with its own token only while it is valid, never refreshed, and never shown
* 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.
* valid; and Antigravity's `agy --print /usage` pools, never on an old agy.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -22,12 +20,6 @@ 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"]) {
process.env[k] = require("node:fs").mkdtempSync(
require("node:path").join(require("node:os").tmpdir(), "quota-home-")
);
}
test("windowLabel names the plan windows", () => {
assert.strictEqual(windowLabel(300), "5h");
@@ -415,172 +407,3 @@ 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");
const { quotas, __resetCache } = require("../lib/quota");
const dir = path.join(process.env.KIMI_CODE_HOME, "credentials");
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, "kimi-code.json");
const f = stubFetch({
"https://api.kimi.com/coding/v1/usages": [
200,
{
usages: {
limit_5h: { used_ratio: 0.3, reset_time: "2099-01-01T05:00:00Z" },
limit_7d: { used_ratio: "0.2", reset_time: "2099-01-07T00:00:00Z" },
limit_month_total: { used_ratio: 0.4, reset_time: "2099-02-01T00:00:00Z" },
limit_month_code: { used_ratio: 0.25, reset_time: "2099-02-01T00:00:00Z" },
},
},
],
});
const kimi = async () => (await quotas()).tools.find((t) => t.id === "kimi");
try {
__resetCache();
assert.strictEqual(await kimi(), undefined);
const exp = Math.floor(Date.now() / 1000) + 600;
fs.writeFileSync(
file,
JSON.stringify({ access_token: "tok-k", refresh_token: "r", expires_at: exp })
);
__resetCache();
const row = await kimi();
assert.strictEqual(row.expiresAt, new Date(exp * 1000).toISOString());
assert.deepStrictEqual(
row.windows.map((w) => [w.label, Math.round(w.usedPercent)]),
[
["5h", 30],
["wk", 20],
["mo", 40],
]
);
assert.deepStrictEqual(
f.asked.filter((a) => a.includes("kimi")),
["https://api.kimi.com/coding/v1/usages Bearer tok-k"]
);
fs.writeFileSync(file, JSON.stringify({ access_token: "tok-k", expires_at: 1 }));
__resetCache();
assert.strictEqual((await kimi()).error, "ETOKENEXPIRED");
assert.strictEqual(f.asked.filter((a) => a.includes("kimi")).length, 1);
} finally {
f.restore();
fs.rmSync(file, { force: true });
__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();
}
});
+7 -44
View File
@@ -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 and Cursor 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,12 @@ 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,26 +72,6 @@ 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 = [];
@@ -125,11 +101,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 +131,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"]);
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 >= 3, 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 +156,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, ".config", "cursor"), { recursive: true });
require("node:child_process").execFileSync("mkfifo", [
path.join(BOB, ".config", "cursor", "auth.json"),
]);
__resetCache();
assert.deepEqual((await quotas({ home: BOB })).tools, []);
// mia's files now belong to another uid: none is read.
+2 -2
View File
@@ -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(
+1 -1
View File
@@ -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)
-41
View File
@@ -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"
}
]
}
-37
View File
@@ -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"
}
]
}
+6 -17
View File
@@ -964,9 +964,15 @@ try {
// (`captureOutputs`), not pinned by hand. `suggested_owner_id`: for a row with
// no owner, the user whose folder the session ran in — they may claim it or
// say it is not theirs (`claim`/`decline`); an admin assigns any (`assign`).
// `version`: a skill snapshot's content hash; `source_hash`: its source
// folder's hash when last taken — a skill follows its owner's folder and an
// installer whose copy differs from `version` is offered Update
// (`syncSkillSources`/`updateCheck`). NULL until first computed.
for (const [col, ddl] of [
["auto", "auto INTEGER NOT NULL DEFAULT 0"],
["suggested_owner_id", "suggested_owner_id INTEGER"],
["version", "version TEXT"],
["source_hash", "source_hash TEXT"],
]) {
try {
db.prepare(`SELECT ${col} FROM artifacts LIMIT 1`).get();
@@ -1486,23 +1492,6 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_omp_sessions_session ON omp_sessions(session_id);
`);
// Which OpenCode stores have been read at what mtime, and per root session the
// newest `time_updated` written (server/lib/opencode-usage.js) — the same
// "deleted stays deleted" rule.
db.exec(`
CREATE TABLE IF NOT EXISTS opencode_stores (
path TEXT PRIMARY KEY,
mtime_ms INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE IF NOT EXISTS opencode_sessions (
session_id TEXT PRIMARY KEY,
store TEXT NOT NULL,
time_updated INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
`);
// Which Cursor agent transcripts have been read, at what mtime
// (server/lib/cursor-usage.js) — the same "deleted stays deleted" rule.
db.exec(`
+5 -11
View File
@@ -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
+207
View File
@@ -0,0 +1,207 @@
/**
* @file agent-edits.js
* @description The files a Claude subagent wrote or edited, read from its own
* transcript (`agent-<id>.jsonl`), so the parent's chat can count them in its
* turn's "Edited N files" card (issue #86: a skill that hands its work to
* subagents showed only the parent's own edits). Each file's +/- lines come
* from the same `patchCounts` the chat uses; a failed call counts nothing; a
* subagent's own subagents are folded in, `MAX_DEPTH` levels down. The file is
* read through `readLines` (a member's transcript only as that member's), and
* the answer is cached by path + size + mtime, so a finished subagent is read
* once however often its parent's chat is polled. `turnCarry` does the same
* for the part of a turn the chat's page of messages cut off.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const { patchCounts, agentInfo } = require("./transcript-message");
const {
getSubagentTranscriptPath,
findSubagentTranscriptPath,
getSnapshotSubagentTranscriptPath,
} = require("./claude-home");
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
const AGENT_TOOLS = new Set(["Agent", "Task"]);
const MAX_DEPTH = 3;
const CACHE_MAX = 500;
// path → { key: "<size>:<mtime>", edits, agents }
const cache = new Map();
/** One transcript's own edits and the subagents it started. */
async function scan(file) {
const { readLines } = require("./run-as");
const pending = new Map(); // tool_use id → file path
const edits = new Map(); // path → { added, removed, counted }
const agents = [];
for await (const line of readLines(file)) {
if (!line.trim()) continue;
let entry;
try {
entry = JSON.parse(line);
} catch {
continue;
}
const content = entry.message?.content;
if (!Array.isArray(content)) continue;
if (entry.type === "assistant") {
for (const b of content) {
if (b?.type !== "tool_use" || !EDIT_TOOLS.has(b.name) || !b.id) continue;
const p = b.input?.file_path ?? b.input?.notebook_path;
if (typeof p === "string" && p) pending.set(b.id, p);
}
} else if (entry.type === "user") {
for (const b of content) {
if (b?.type !== "tool_result") continue;
const agent = agentInfo(entry.toolUseResult);
if (agent) agents.push(agent.id);
const p = pending.get(b.tool_use_id);
if (!p || b.is_error) continue;
const diff = patchCounts(entry.toolUseResult);
const f = edits.get(p) ?? { added: 0, removed: 0, counted: false };
f.added += diff?.added ?? 0;
f.removed += diff?.removed ?? 0;
f.counted ||= !!diff;
edits.set(p, f);
}
}
}
return { edits, agents };
}
async function scanCached(file) {
const st = await fs.promises.stat(file);
const key = `${st.size}:${st.mtimeMs}`;
const hit = cache.get(file);
if (hit && hit.key === key) return hit;
const fresh = { key, ...(await scan(file)) };
cache.delete(file);
cache.set(file, fresh);
if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value);
return fresh;
}
const transcriptOf = (sessionId, cwd, agentId) =>
getSubagentTranscriptPath(sessionId, cwd, agentId) ||
findSubagentTranscriptPath(sessionId, agentId, null, cwd) ||
getSnapshotSubagentTranscriptPath(sessionId, agentId);
/**
* The files subagent `agentId` of session `sessionId` (and its own subagents)
* changed, as `[{ path, added, removed, counted }]` in first-edit order; an
* empty list when its transcript cannot be found or read.
*/
async function agentEdits(sessionId, cwd, agentId, depth = 0, seen = new Set()) {
if (seen.has(agentId)) return [];
seen.add(agentId);
let file;
let got;
try {
file = transcriptOf(sessionId, cwd, agentId);
if (!file) return [];
got = await scanCached(file);
} catch {
return [];
}
const out = new Map();
const add = (p, f) => {
const o = out.get(p) ?? { added: 0, removed: 0, counted: false };
o.added += f.added;
o.removed += f.removed;
o.counted ||= f.counted;
out.set(p, o);
};
got.edits.forEach((f, p) => add(p, f));
if (depth < MAX_DEPTH) {
for (const id of got.agents) {
for (const f of await agentEdits(sessionId, cwd, id, depth + 1, seen)) add(f.path, f);
}
}
return [...out].map(([path, f]) => ({ path, ...f }));
}
/** Puts `edits` on every Agent/Task result in `messages` (in place). */
async function attachAgentEdits(messages, sessionId, cwd) {
for (const m of messages) {
for (const c of m.content ?? []) {
if (c.type !== "tool_result" || !c.agent?.id || c.is_error) continue;
const edits = await agentEdits(sessionId, cwd, c.agent.id);
if (edits.length) c.edits = edits;
}
}
}
/** A human prompt: the line that starts a turn (the chat's own rule). */
const isPrompt = (m) =>
(m.sender ?? (m.type === "assistant" ? "assistant" : "user")) === "user" &&
(m.content ?? []).some((c) => c.type === "text");
/**
* The edits of the turn a page of the chat starts in the middle of. The route
* keeps only the newest `limit` messages; every message it drops goes through
* `drop()`, which keeps the file changes since the last prompt it dropped.
* `result(window)` then answers the files that turn changed BEFORE the page's
* first message — `[{ path, added, removed, counted }]`, or null when the page
* starts on a prompt or nothing was changed — so the turn's "Edited N files"
* card is whole although its start was never sent (issue #86: a 120-message
* turn showed 1 of its files).
*/
function turnCarry() {
let pending = new Map(); // dropped Write/Edit call id → path
let pendingAgents = new Set(); // dropped Agent/Task call ids
let files = new Map();
let agents = [];
const add = (p, f) => {
const o = files.get(p) ?? { added: 0, removed: 0, counted: false };
o.added += f?.added ?? 0;
o.removed += f?.removed ?? 0;
o.counted ||= !!f && f.counted !== false;
files.set(p, o);
};
return {
drop(m) {
if (!m) return;
if (isPrompt(m)) {
pending = new Map();
pendingAgents = new Set();
files = new Map();
agents = [];
return;
}
for (const c of m.content ?? []) {
if (c.type === "tool_use" && c.id) {
if (AGENT_TOOLS.has(c.name)) pendingAgents.add(c.id);
if (!EDIT_TOOLS.has(c.name)) continue;
const p = c.input?.file_path ?? c.input?.notebook_path;
if (typeof p === "string" && p) pending.set(c.id, p);
} else if (c.type === "tool_result") {
if (c.agent?.id && !c.is_error) agents.push(c.agent.id);
pendingAgents.delete(c.id);
const p = pending.get(c.id);
if (!p) continue;
pending.delete(c.id);
if (!c.is_error) add(p, c.diff ?? null);
}
}
},
async result(window, sessionId, cwd) {
if (!window.length || isPrompt(window[0])) return null;
// A call dropped whose result is on the page: the page shows the result
// with no call to hang it on, so it counts here.
for (const m of window) {
for (const c of m.content ?? []) {
if (c.type !== "tool_result" || c.is_error) continue;
const p = pending.get(c.id);
if (p) add(p, c.diff ?? null);
if (pendingAgents.has(c.id)) for (const e of c.edits ?? []) add(e.path, e);
}
}
for (const id of agents) {
for (const e of await agentEdits(sessionId, cwd, id)) add(e.path, e);
}
return files.size ? [...files].map(([path, f]) => ({ path, ...f })) : null;
},
};
}
module.exports = { agentEdits, attachAgentEdits, turnCarry, _cache: cache };
+287 -17
View File
@@ -226,7 +226,11 @@ function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } =
).all(...params);
const owners = new Map();
const installed = installedCheck(req);
const out = rows.map((r) => ({ ...view(r, req, owners), installed: installed(r) }));
const updatable = updateCheck(req);
const out = rows.map((r) => {
const isIn = installed(r);
return { ...view(r, req, owners), installed: isIn, updateAvailable: isIn && updatable(r) };
});
return scope === "mine" && req?.user ? out.filter((a) => a.mine || a.installed) : out;
}
@@ -753,7 +757,7 @@ function walkSkill(dir) {
total += st.size;
if (files.length >= SKILL_MAX_FILES) throw fail("ETOOBIG", "the skill has too many files");
if (total > SKILL_MAX_BYTES) throw fail("ETOOBIG", "the skill is larger than 5 MB");
files.push({ abs: childAbs, path: childRel, size: st.size });
files.push({ abs: childAbs, path: childRel, size: st.size, mtimeMs: st.mtimeMs });
}
};
walk(dir, "", 0);
@@ -822,6 +826,7 @@ function storeSkill(req, { name, files, title, description, laneId = null }) {
const snap = newSnapshotDir();
let total = 0;
const recorded = [];
const hashed = [];
try {
for (const f of files) {
const data = typeof f.data === "function" ? f.data() : f.data;
@@ -831,6 +836,7 @@ function storeSkill(req, { name, files, title, description, laneId = null }) {
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
fs.writeFileSync(target, data, { mode: 0o600 });
recorded.push({ path: f.path, size: data.length });
hashed.push({ path: f.path, data });
}
} catch (err) {
dropSnapshot(snap.id);
@@ -845,20 +851,30 @@ function storeSkill(req, { name, files, title, description, laneId = null }) {
} catch {
/* no frontmatter */
}
return view(
insert({
kind: "skill",
title: cleanTitle(title, name),
description: cleanDescription(description) || summary.slice(0, DESCRIPTION_MAX),
owner_id: req?.user ? req.user.id : null,
lane_id: laneId,
source: name,
snapshot: snap.id,
files: JSON.stringify(recorded),
size: total,
}),
req
);
const row = insert({
kind: "skill",
title: cleanTitle(title, name),
description: cleanDescription(description) || summary.slice(0, DESCRIPTION_MAX),
owner_id: req?.user ? req.user.id : null,
lane_id: laneId,
source: name,
snapshot: snap.id,
files: JSON.stringify(recorded),
size: total,
version: contentHash(hashed),
});
// The owner's folder as it is now: only a LATER change to it is taken
// (`syncOne`), so an upload is never replaced by an older local copy.
try {
const h = sourceHash(row);
if (h) {
sql("UPDATE artifacts SET source_hash = ? WHERE id = ?").run(h, row.id);
row.source_hash = h;
}
} catch {
/* no folder */
}
return view(row, req);
}
// ── skill: written or uploaded ────────────────────────────────────────────
@@ -1569,10 +1585,22 @@ function editSkill(req, row, { name, description, content }) {
throw err;
}
const desc = frontmatterField(text, "description");
// The source folder as it is now, so this edit is not undone by it (`syncOne`).
const folder = sourceHash({ ...row, source: n });
sql(
`UPDATE artifacts SET source = ?, snapshot = ?, files = ?, size = ?, description = ?,
version = ?, source_hash = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
).run(n, snap.id, JSON.stringify(next), total, cleanDescription(desc), row.id);
).run(
n,
snap.id,
JSON.stringify(next),
total,
cleanDescription(desc),
contentHash(all),
folder,
row.id
);
dropSnapshot(row.snapshot);
}
@@ -1756,6 +1784,237 @@ async function installSkill(req, row, { replace = false } = {}) {
return { installed: paths.join(", "), paths, replaced: taken.length > 0 };
}
// ── skill versions: follow the owner's folder, offer installers an Update ──
/** A skill's content id: sha256 over its files (path + bytes), path order. */
function contentHash(files) {
const h = crypto.createHash("sha256");
for (const f of [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))) {
h.update(f.path);
h.update("\0");
h.update(crypto.createHash("sha256").update(f.data).digest());
}
return h.digest("hex").slice(0, 16);
}
/** A skill row's snapshot version (`artifacts.version`), computed once for an older row. */
function snapshotVersion(row) {
if (row.version) return row.version;
if (row.kind !== "skill" || !row.snapshot || !SNAPSHOT_RE.test(row.snapshot)) return null;
try {
const files = (row.files ? JSON.parse(row.files) : []).map((f) => ({
path: f.path,
data: fs.readFileSync(path.join(root(), row.snapshot, ...f.path.split("/"))),
}));
const v = contentHash(files);
sql("UPDATE artifacts SET version = ? WHERE id = ?").run(v, row.id);
row.version = v;
return v;
} catch {
return null;
}
}
// realpath → { sig, hash }: a folder is read again only once a file's name,
// size or mtime changed (a stat walk is cheap; reading 5 MB per poll is not).
const folderCache = new Map();
const FOLDER_CACHE_MAX = 2000;
/**
* A skill folder as it is on disk now — `{ real, hash, files, read() }` — or
* null when it is missing, has no SKILL.md, is a link out of `top`, or breaks
* a snapshot rule (`walkSkill`: no symlink, no secret, ≤ 5 MB). `read()`
* answers its files' bytes, a member's only as theirs (`readFileSafe`).
*/
function folderState(dir, top) {
let real;
let topReal;
try {
real = fs.realpathSync(dir);
topReal = fs.realpathSync(top);
} catch {
return null;
}
if (!inside(topReal, real) || path.basename(real) !== path.basename(dir)) return null;
let walked;
try {
walked = walkSkill(real);
} catch {
return null;
}
if (!walked.files.some((f) => f.path === "SKILL.md")) return null;
const sig = walked.files
.map((f) => `${f.path}:${f.size}:${f.mtimeMs}`)
.sort()
.join("|");
const read = () => {
const runAs = require("./run-as");
return walked.files.map((f) => ({
path: f.path,
data: runAs.readFileSafe(f.abs, null, { max: SKILL_MAX_BYTES }),
}));
};
const hit = folderCache.get(real);
if (hit && hit.sig === sig) return { real, hash: hit.hash, files: walked.files, read };
let hash;
try {
hash = contentHash(read());
} catch {
return null;
}
folderCache.delete(real);
folderCache.set(real, { sig, hash });
if (folderCache.size > FOLDER_CACHE_MAX) folderCache.delete(folderCache.keys().next().value);
return { real, hash, files: walked.files, read };
}
/**
* Where a skill row came from: `{dir, top}` — its workspace's
* `.claude/skills/<name>` when it was taken from one, else its OWNER's
* `~/.claude/skills/<name>` (the host's in local mode). Null for a row with no
* owner in server mode, or an owner with no Claude config of their own.
*/
function sourceDirOf(row) {
if (row.kind !== "skill" || !row.source || !SKILL_NAME_RE.test(row.source)) return null;
if (row.lane_id != null) {
const lane = sql("SELECT cwd FROM lanes WHERE id = ?").get(row.lane_id);
if (!lane?.cwd) return null;
return { dir: path.join(lane.cwd, ".claude", "skills", row.source), top: lane.cwd };
}
let claudeHome;
if (row.owner_id == null) {
if (isServer()) return null;
claudeHome = getClaudeHome();
} else {
const user = sql("SELECT id, username, role FROM users WHERE id = ?").get(row.owner_id);
if (!user) return null;
try {
claudeHome = ownAccount({ user }).claudeHome;
} catch {
return null;
}
}
return { dir: path.join(claudeHome, "skills", row.source), top: claudeHome };
}
/** The hash of a row's source folder now, or null when it has none. */
function sourceHash(row) {
const src = sourceDirOf(row);
return src ? (folderState(src.dir, src.top)?.hash ?? null) : null;
}
/**
* Take a skill row's NEW source folder as its snapshot, when the folder
* changed since it was last taken (`source_hash`) and differs from the
* snapshot. A change made in the Hub (`editSkill`) records the folder as it
* was then, so it is not undone until the folder itself changes again: the
* newer change wins. A new snapshot directory, then the old one dropped —
* never a write into the one being read. Answers whether the row changed.
*/
function syncOne(row) {
const src = sourceDirOf(row);
if (!src) return false;
const st = folderState(src.dir, src.top);
if (!st || st.hash === row.source_hash) return false;
if (st.hash === snapshotVersion(row)) {
sql("UPDATE artifacts SET source_hash = ? WHERE id = ?").run(st.hash, row.id);
return false;
}
let files;
try {
files = st.read();
checkSkillFiles(files);
} catch {
return false;
}
const snap = newSnapshotDir();
let total = 0;
const next = [];
try {
for (const f of files) {
const target = path.join(snap.dir, ...f.path.split("/"));
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
fs.writeFileSync(target, f.data, { mode: 0o600 });
total += f.data.length;
next.push({ path: f.path, size: f.data.length });
}
} catch {
dropSnapshot(snap.id);
return false;
}
// The description follows SKILL.md's only while it still says what the old one did.
const md = (list) => list.find((f) => f.path === "SKILL.md");
let oldSummary = "";
try {
oldSummary = frontmatterField(
fs.readFileSync(path.join(root(), row.snapshot, "SKILL.md"), "utf8"),
"description"
).slice(0, DESCRIPTION_MAX);
} catch {
/* none */
}
const newSummary = frontmatterField(md(files)?.data.toString("utf8") ?? "", "description");
const description =
!row.description || row.description === oldSummary
? cleanDescription(newSummary) || row.description
: row.description;
sql(
`UPDATE artifacts SET snapshot = ?, files = ?, size = ?, version = ?, source_hash = ?,
description = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
).run(snap.id, JSON.stringify(next), total, st.hash, st.hash, description, row.id);
if (row.snapshot && SNAPSHOT_RE.test(row.snapshot)) dropSnapshot(row.snapshot);
return true;
}
const SYNC_EVERY_MS = 15 * 1000;
let lastSync = 0;
/**
* Bring every skill row up to its source folder (`syncOne`), at most once per
* `SYNC_EVERY_MS` unless `force`. Runs when the Hub is listed. Answers the
* rows that changed, for the route to announce.
*/
function syncSkillSources({ force = false } = {}) {
if (!force && Date.now() - lastSync < SYNC_EVERY_MS) return [];
lastSync = Date.now();
const changedRows = [];
for (const row of sql(
"SELECT * FROM artifacts WHERE kind = 'skill' AND snapshot IS NOT NULL"
).all()) {
try {
if (syncOne(row)) changedRows.push(sql("SELECT * FROM artifacts WHERE id = ?").get(row.id));
} catch {
/* one bad row never stops the rest */
}
}
return changedRows;
}
/**
* `row => boolean`: an installed skill whose copy in the viewer's
* `~/.claude/skills/<name>` (any account it runs as) differs from the row's
* newest snapshot — the list offers **Update** for it. Pure reads.
*/
function updateCheck(req) {
let accounts = null;
return (row) => {
if (row.kind !== "skill" || !row.source) return false;
const version = snapshotVersion(row);
if (!version) return false;
if (accounts === null) {
try {
accounts = installAccounts(req);
} catch {
accounts = [];
}
}
return accounts.some((a) => {
const st = folderState(path.join(a.claudeHome, "skills", row.source), a.claudeHome);
return !!st && st.hash !== version;
});
};
}
let execImpl = promisify(require("node:child_process").execFile);
function __setExecImpl(fn) {
execImpl = fn;
@@ -1815,6 +2074,16 @@ async function installPlugin(req, row) {
async function install(req, id, opts = {}) {
const row = load(req, id);
installable(req, row);
// Update: an installed skill's copies replaced by the newest snapshot (the
// old ones moved aside like any replace). Not another install: no count, no
// second row in who-installed.
if (opts.update) {
if (row.kind !== "skill") throw fail("EBADKIND", "only a skill is updated here");
if (!installedCheck(req)(row)) throw fail("ENOTINSTALLED", `${row.source} is not installed`);
const result = await installSkill(req, row, { replace: true });
if (req?.user && row.owner_id !== req.user.id) audit(req, row, "updated");
return result;
}
const result =
row.kind === "skill" ? await installSkill(req, row, opts) : await installPlugin(req, row);
sql("UPDATE artifacts SET installs = installs + 1 WHERE id = ?").run(row.id);
@@ -1947,6 +2216,7 @@ module.exports = {
update,
remove,
snapshotFile,
syncSkillSources,
install,
uninstall,
installsEnabled,
-504
View File
@@ -1,504 +0,0 @@
/**
* @file OpenCode (`opencode`) sessions, subagents and token usage, read from
* OpenCode's own SQLite store (`~/.local/share/opencode/opencode.db`, under
* `XDG_DATA_HOME` when set). This is how an OpenCode session reaches
* `sessions` (as `tool = 'opencode'`), its tokens `token_usage` and its
* subagents `agents` — OpenCode has no hooks AgentHub installs.
*
* What the store holds (opencode 1.19, verified against a file it wrote — not
* a documented format):
* - `session(id, parent_id, directory, title, time_created, time_updated,
* time_archived)` — a session the `task` tool spawned has `parent_id` set,
* and is shown as a subagent of its root session;
* - `message(session_id, data)` — an assistant message's JSON carries
* `modelID` and `tokens: {input, output, reasoning, cache: {read, write}}`
* (input NOT including cache reads; reasoning is counted as output here);
* - `part(message_id, session_id, data)` — the message's text, reasoning and
* tool parts, read only for a transcript.
* The file is re-read when its mtime (or its non-empty `-wal`'s) changes; per
* root session the newest `time_updated` seen lives in `opencode_sessions`,
* so a session the user deleted stays deleted until OpenCode writes to it
* again. Status: active while its tree was updated in the last 2 minutes or a
* live dashboard pane holds it, else completed. S3: a member's store (and its
* WAL) is read through the guard into a private copy, never opened in place.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { db, stmts, Database } = require("../db");
const { claimToolSessions } = require("./dashboard-runs");
const pricingSync = require("./pricing-sync");
const ACTIVE_MS = 120_000; // the window codex-usage.js calls a session active
const FUTURE_SLACK_MS = 1000;
const SESSION_ID = /^[A-Za-z0-9_-]{8,120}$/;
const MAX_COPY = 256 << 20;
/** OpenCode's data dir: `AGENTHUB_OPENCODE_DIR`, `$XDG_DATA_HOME/opencode`, else `~/.local/share/opencode`. */
function getOpencodeDir() {
if (process.env.AGENTHUB_OPENCODE_DIR) return process.env.AGENTHUB_OPENCODE_DIR;
const xdg = process.env.XDG_DATA_HOME;
return path.join(xdg && path.isAbsolute(xdg) ? xdg : path.join(os.homedir(), ".local", "share"), "opencode");
}
/** Every store: `{path, mtime}` — this account's, then every member's. */
function storeFiles() {
const members = require("./run-as")
.memberHomes()
.map((h) => path.join(h, ".local", "share", "opencode"));
const out = [];
for (const dir of [getOpencodeDir(), ...members]) {
const file = path.join(dir, "opencode.db");
try {
const st = fs.lstatSync(file);
if (!st.isFile()) continue;
let mtime = st.mtimeMs;
try {
const wal = fs.lstatSync(`${file}-wal`);
if (wal.isFile() && wal.size > 0) mtime = Math.max(mtime, wal.mtimeMs);
} catch {
/* no WAL right now */
}
out.push({ path: file, mtime: Math.floor(mtime) });
} catch {
/* no store */
}
}
return out;
}
/**
* Run `fn(conn)` on a store, read-only. A member's store (S3) and its WAL are
* copied through the guard first: SQLite opens by NAME, and a member's FIFO
* standing in for the `-wal` would block this thread.
*/
function withStore(file, fn) {
const { ownerForRead, readFileSafe } = require("./run-as");
let tmp = null;
try {
let open = file;
if (require("./linux-accounts").s3Enabled() && ownerForRead(file)) {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-opencode-"));
open = path.join(tmp, "opencode.db");
fs.writeFileSync(open, readFileSafe(file, null, { max: MAX_COPY }));
try {
fs.writeFileSync(`${open}-wal`, readFileSafe(`${file}-wal`, null, { max: MAX_COPY }));
} catch {
/* no WAL (or not theirs): what is in the main file */
}
}
const conn = new Database(open, { readonly: !tmp, fileMustExist: true });
try {
return fn(conn);
} finally {
conn.close();
}
} finally {
if (tmp) fs.rmSync(tmp, { recursive: true, force: true });
}
}
/** A session's model column (`{"id": …}` JSON, or a plain id), or null. */
function modelOf(raw) {
if (typeof raw !== "string" || !raw) return null;
try {
const o = JSON.parse(raw);
return typeof o?.id === "string" ? o.id.slice(0, 128) : null;
} catch {
return raw.slice(0, 128);
}
}
/**
* Everything a pass needs from one store: `[{root, children, usage, updated}]`
* per root session — `usage` per model over the root and every descendant.
*/
function readStore(file) {
return withStore(file, (conn) => {
const rows = conn
.prepare(
`SELECT id, parent_id, directory, title, time_created, time_updated, time_archived, model
FROM session`
)
.all();
const byId = new Map(rows.map((r) => [r.id, r]));
const rootOf = (r) => {
let cur = r;
for (let i = 0; i < 32 && cur.parent_id && byId.has(cur.parent_id); i++) {
cur = byId.get(cur.parent_id);
}
return cur;
};
const trees = new Map();
for (const r of rows) {
if (!SESSION_ID.test(r.id)) continue;
const root = rootOf(r);
if (!SESSION_ID.test(root.id)) continue;
const t = trees.get(root.id) || { root, children: [], usage: {}, updated: 0, model: null };
if (r !== root) t.children.push(r);
t.updated = Math.max(t.updated, Number(r.time_updated) || 0);
trees.set(root.id, t);
}
const sums = conn
.prepare(
`SELECT session_id,
json_extract(data, '$.modelID') AS model,
SUM(COALESCE(json_extract(data, '$.tokens.input'), 0)) AS input,
SUM(COALESCE(json_extract(data, '$.tokens.output'), 0)
+ COALESCE(json_extract(data, '$.tokens.reasoning'), 0)) AS output,
SUM(COALESCE(json_extract(data, '$.tokens.cache.read'), 0)) AS cache_read,
SUM(COALESCE(json_extract(data, '$.tokens.cache.write'), 0)) AS cache_write,
MAX(time_created) AS last
FROM message
WHERE json_extract(data, '$.role') = 'assistant'
GROUP BY session_id, model`
)
.all();
for (const s of sums) {
const r = byId.get(s.session_id);
const t = r && trees.get(rootOf(r).id);
if (!t) continue;
const model = typeof s.model === "string" && s.model ? s.model.slice(0, 128) : "unknown";
const u = (t.usage[model] ||= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
u.input += Number(s.input) || 0;
u.output += Number(s.output) || 0;
u.cacheRead += Number(s.cache_read) || 0;
u.cacheWrite += Number(s.cache_write) || 0;
if (r === t.root && (!t.lastAt || s.last > t.lastAt)) {
t.lastAt = s.last;
t.model = model;
}
}
return [...trees.values()];
});
}
const getStore = db.prepare("SELECT * FROM opencode_stores WHERE path = ?");
const upsertStore = db.prepare(
`INSERT INTO opencode_stores (path, mtime_ms, updated_at)
VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
ON CONFLICT(path) DO UPDATE SET mtime_ms = excluded.mtime_ms, updated_at = excluded.updated_at`
);
const getTracked = db.prepare("SELECT * FROM opencode_sessions WHERE session_id = ?");
const upsertTracked = db.prepare(
`INSERT INTO opencode_sessions (session_id, store, time_updated, updated_at)
VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
ON CONFLICT(session_id) DO UPDATE SET store = excluded.store,
time_updated = excluded.time_updated, updated_at = excluded.updated_at`
);
const activeOpencode = db.prepare(
"SELECT s.id, t.time_updated FROM sessions s JOIN opencode_sessions t ON t.session_id = s.id WHERE s.tool = 'opencode' AND s.status = 'active'"
);
const heldByPane = db.prepare(
"SELECT 1 FROM dashboard_runs WHERE session_id = ? AND status IN ('running', 'spawning') LIMIT 1"
);
const insertSession = db.prepare(
`INSERT INTO sessions (id, name, status, cwd, model, started_at, ended_at, updated_at, metadata, tool)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'opencode')`
);
const updateSession = db.prepare(
`UPDATE sessions SET name = ?, status = ?, model = COALESCE(?, model), ended_at = ?,
updated_at = ?, cwd = COALESCE(cwd, ?), started_at = COALESCE(?, started_at)
WHERE id = ? AND tool = 'opencode'`
);
const settle = db.prepare(
"UPDATE sessions SET status = 'completed', ended_at = ? WHERE id = ? AND tool = 'opencode'"
);
const clearUsage = db.prepare("DELETE FROM token_usage WHERE session_id = ?");
const agentTimes = db.prepare(
"UPDATE agents SET started_at = COALESCE(?, started_at), ended_at = ? WHERE id = ?"
);
const fresh = (t, now) => t - now < FUTURE_SLACK_MS && now - t < ACTIVE_MS;
const iso = (t) => (Number.isFinite(Number(t)) && Number(t) > 0 ? new Date(Number(t)).toISOString() : null);
/** Write one root session's row, usage and subagents. @returns its broadcast type, or null. */
function writeSession(store, t, now, broadcast) {
const r = t.root;
const existing = stmts.getSession.get(r.id);
if (existing && existing.tool !== "opencode") return null;
const cwd = typeof r.directory === "string" && path.isAbsolute(r.directory) ? r.directory : null;
const runAs = require("./run-as");
// S3: a member's store names only their own folder and session.
if (!runAs.sameOwner(store, cwd, existing?.cwd)) return null;
const lastWrite = iso(t.updated) || new Date(now).toISOString();
const active = !r.time_archived && (fresh(t.updated, now) || !!heldByPane.get(r.id));
const status = active ? "active" : "completed";
const endedAt = active ? null : lastWrite;
const name =
(typeof r.title === "string" && r.title.trim().slice(0, 200)) ||
existing?.name ||
`OpenCode ${r.id.slice(4, 12)}`;
const model = t.model || modelOf(r.model);
const agentEvents = [];
db.transaction(() => {
if (existing) {
updateSession.run(name, status, model, endedAt, lastWrite, cwd, iso(r.time_created), r.id);
} else {
insertSession.run(
r.id,
name,
status,
cwd,
model,
iso(r.time_created) || lastWrite,
endedAt,
lastWrite,
JSON.stringify({ source: "opencode-store" })
);
}
claimToolSessions(r.id);
clearUsage.run(r.id);
for (const [m, u] of Object.entries(t.usage)) {
stmts.setTokenUsage.run(
r.id,
m,
"standard",
"global",
"standard",
u.input,
u.output,
u.cacheRead,
u.cacheWrite,
0,
0,
0,
0
);
}
for (const c of t.children) {
const id = `opencode-${c.id}`;
const done = !active || !fresh(Number(c.time_updated) || 0, now);
const agentStatus = done ? "completed" : "working";
const ended = done ? iso(c.time_updated) : null;
const label = (typeof c.title === "string" && c.title.trim().slice(0, 120)) || c.id;
const prior = stmts.getAgent.get(id);
if (prior && prior.session_id !== r.id) continue;
if (!prior) {
stmts.insertAgent.run(
id,
r.id,
label,
"subagent",
"task",
agentStatus,
label,
null,
JSON.stringify({ source: "opencode-store", transcript: c.id })
);
} else if (prior.status !== agentStatus || prior.name !== label) {
stmts.updateAgent.run(label, agentStatus, label, null, ended, null, id);
} else continue;
agentTimes.run(iso(c.time_created), ended, id);
agentEvents.push([prior ? "agent_updated" : "agent_created", id]);
}
upsertTracked.run(r.id, store, t.updated);
})();
for (const [type, id] of agentEvents) broadcast(type, stmts.getAgent.get(id));
return existing ? "session_updated" : "session_created";
}
let running = null;
let lastPending = 0;
/**
* One pass: every store whose file changed is read, and each root session
* updated since it was last written is rewritten; an `active` one that went
* quiet is settled.
* @returns {Promise<{files: number, sessions: number}>}
*/
function ingestOpencodeUsage({ broadcast = () => {}, now = Date.now() } = {}) {
if (running) return running;
running = (async () => {
let read = 0;
let written = 0;
const done = new Set();
for (const f of storeFiles()) {
const seen = getStore.get(f.path);
if (seen && seen.mtime_ms === f.mtime) continue;
let trees;
try {
trees = readStore(f.path);
} catch (err) {
if (getStore.get(f.path)?.mtime_ms !== f.mtime) {
console.warn(`[opencode-usage] skipped ${f.path}: ${err.message}`);
}
upsertStore.run(f.path, f.mtime);
continue;
}
read += 1;
for (const t of trees) {
const tracked = getTracked.get(t.root.id);
const changed = !tracked || tracked.time_updated !== t.updated;
const s = stmts.getSession.get(t.root.id);
// Unchanged and deleted by the user: stays deleted.
if (!changed && (!s || s.status !== "active")) continue;
try {
const type = writeSession(f.path, t, now, broadcast);
if (!type) continue;
done.add(t.root.id);
written += 1;
broadcast(type, stmts.getSession.get(t.root.id));
} catch (err) {
console.warn(`[opencode-usage] session ${t.root.id}: ${err.message}`);
}
}
upsertStore.run(f.path, f.mtime);
await new Promise((r) => setImmediate(r));
}
// An active session in a store that did not change: settled once quiet.
let pending = 0;
for (const { id, time_updated: updated } of activeOpencode.all()) {
if (done.has(id)) {
pending += 1;
continue;
}
if (fresh(updated, now) || heldByPane.get(id)) {
pending += 1;
continue;
}
settle.run(iso(updated) || new Date(now).toISOString(), id);
for (const a of stmts.listAgentsBySession.all(id)) {
if (a.status === "completed" || a.status === "error") continue;
stmts.updateAgent.run(null, "completed", null, null, iso(updated), null, a.id);
broadcast("agent_updated", stmts.getAgent.get(a.id));
}
written += 1;
broadcast("session_updated", stmts.getSession.get(id));
}
lastPending = pending;
return { files: read, sessions: written };
})().finally(() => {
running = null;
});
return running;
}
/** A tool part's input/output, kept small. */
const short = (v, max = 10240) => {
const s = typeof v === "string" ? v : v === undefined ? "" : JSON.stringify(v);
return s.length > max ? `${s.slice(0, max)}[truncated]` : s;
};
const TEXT_MAX = 1024 * 1024;
/**
* One OpenCode session's conversation (`sessionId`, or one of its subagent
* sessions by `childId`), in the Claude transcript shape the client renders:
* `[{type, sender, timestamp, content}]`, oldest first. Null when unknown.
*/
function transcriptMessages(sessionId, childId = null) {
const tracked = getTracked.get(sessionId);
if (!tracked) return null;
const target = childId || sessionId;
if (!SESSION_ID.test(target)) return null;
return withStore(tracked.store, (conn) => {
if (childId) {
// Only a session of this tree.
let cur = conn.prepare("SELECT id, parent_id FROM session WHERE id = ?").get(childId);
for (let i = 0; i < 32 && cur && cur.id !== sessionId; i++) {
cur = cur.parent_id
? conn.prepare("SELECT id, parent_id FROM session WHERE id = ?").get(cur.parent_id)
: null;
}
if (!cur) return null;
}
const messages = conn
.prepare("SELECT id, time_created, data FROM message WHERE session_id = ? ORDER BY time_created, id")
.all(target);
const parts = conn
.prepare("SELECT message_id, data FROM part WHERE session_id = ? ORDER BY time_created, id")
.all(target);
const byMessage = new Map();
for (const p of parts) {
let d;
try {
d = JSON.parse(p.data);
} catch {
continue;
}
(byMessage.get(p.message_id) || byMessage.set(p.message_id, []).get(p.message_id)).push(d);
}
const out = [];
for (const m of messages) {
let info;
try {
info = JSON.parse(m.data);
} catch {
continue;
}
const at = iso(m.time_created);
const role = info.role === "user" ? "user" : info.role === "assistant" ? "assistant" : null;
if (!role) continue;
const content = [];
const results = [];
for (const p of byMessage.get(m.id) || []) {
if (p.type === "text" && typeof p.text === "string" && p.text.trim() && !p.synthetic) {
content.push({ type: "text", text: short(p.text, TEXT_MAX) });
} else if (p.type === "reasoning" && typeof p.text === "string" && p.text.trim()) {
content.push({ type: "thinking", text: short(p.text) });
} else if (p.type === "tool") {
const st = p.state || {};
content.push({
type: "tool_use",
name: p.tool || "unknown",
id: p.callID || null,
input: st.input && typeof st.input === "object" ? st.input : {},
});
if (st.status === "completed" || st.status === "error") {
results.push({
type: "tool_result",
id: p.callID || null,
output: short(st.status === "error" ? st.error : st.output),
is_error: st.status === "error",
});
}
}
}
if (content.length) {
const sender = role === "user" ? (childId && !out.length ? "orchestrator" : "user") : "assistant";
const msg = { type: role, sender, timestamp: at, content };
if (role === "assistant" && info.modelID) msg.model = info.modelID;
out.push(msg);
}
if (results.length) out.push({ type: "user", sender: "tool", timestamp: at, content: results });
}
return out;
});
}
/** A root session's subagent sessions: `[{id, name, rowId}]`. */
function subagentSessions(sessionId) {
return stmts
.listAgentsBySession.all(sessionId)
.filter((a) => a.id.startsWith("opencode-"))
.map((a) => ({ id: a.id.slice("opencode-".length), name: a.name, rowId: a.id }));
}
/**
* First pass at once, then every DASHBOARD_OPENCODE_POLL_MS (default 15 s; 0
* disables) — once a minute after a pass that found nothing to do.
*/
function startOpencodeUsagePoll(broadcast) {
const POLL_MS = process.env.DASHBOARD_OPENCODE_POLL_MS
? Number(process.env.DASHBOARD_OPENCODE_POLL_MS)
: 15_000;
if (!Number.isFinite(POLL_MS) || POLL_MS <= 0) return;
require("./backoff-poll").startBackoffPoll(
() =>
ingestOpencodeUsage({ broadcast }).then((r) => {
if (r.sessions > 0 && pricingSync.syncEnabled()) void pricingSync.syncPricing(db);
return r.files + r.sessions + lastPending > 0;
}),
{ baseMs: POLL_MS }
);
}
module.exports = {
getOpencodeDir,
ingestOpencodeUsage,
transcriptMessages,
subagentSessions,
startOpencodeUsagePoll,
};
+5 -194
View File
@@ -34,21 +34,6 @@
* 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.
*
* 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,9 +44,8 @@
*
* 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
* saved Claude profiles (the Profiles modal is the host's) and no agy: its
* `.cursor`, 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.
*
* Only percentages, window lengths, reset/expiry times, the plan's name and
@@ -80,9 +64,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 +73,6 @@ 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 +343,6 @@ 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"),
};
}
@@ -480,160 +456,6 @@ async function antigravityQuota() {
}
}
const isoOf = (v) => {
const ms = Date.parse(v);
return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
};
/** A Bearer GET to a tool's own host: its windows, or the row's error. */
async function bearerWindows(who, url, token, headers, toWindows) {
try {
const res = await fetch(url, {
headers: { Accept: "application/json", Authorization: `Bearer ${token}`, ...headers },
signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (!res.ok) {
const expired = res.status === 401 || res.status === 403;
return { ...who, windows: [], error: expired ? "ETOKENEXPIRED" : "EUPSTREAM" };
}
return { ...who, ...toWindows(await res.json()) };
} catch {
return { ...who, windows: [], error: "EUPSTREAM" };
}
}
/** 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"));
if (typeof cred?.access_token !== "string" || !cred.access_token) return null;
const exp = Number(cred.expires_at) * 1000;
const who = { id: "kimi", active: true, expiresAt: exp ? iso(exp) : null };
if (exp && !fresh(exp)) return { ...who, windows: [], error: "ETOKENEXPIRED" };
return bearerWindows(who, KIMI_USAGE_URL, cred.access_token, {}, (body) => ({
windows: [
["5h", body?.usages?.limit_5h],
["wk", body?.usages?.limit_7d],
["mo", body?.usages?.limit_month_total],
]
.filter(([, u]) => u && Number.isFinite(Number(u.used_ratio)))
.map(([label, u]) => ({
label,
usedPercent: Number(u.used_ratio) * 100,
resetsAt: isoOf(u.reset_time),
})),
}));
}
/**
* 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 +463,12 @@ 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] = 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].filter(Boolean);
return { tools, checkedAt: new Date().toISOString() };
}
+4
View File
@@ -75,6 +75,9 @@ router.get(
"/",
wrap((req, res) => {
const q = req.query;
// A skill follows its owner's folder: take what changed there first
// (throttled), and tell every open Hub page.
for (const row of artifacts.syncSkillSources()) changed(row);
res.json({
items: artifacts.list(req, {
scope: q.scope,
@@ -358,6 +361,7 @@ router.post(
wrap(async (req, res) => {
const result = await artifacts.install(req, req.params.id, {
replace: req.body?.replace === true,
update: req.body?.update === true,
});
res.json(result);
})
+12 -23
View File
@@ -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);
+16 -71
View File
@@ -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) {
@@ -1177,6 +1144,9 @@ router.get("/:id/transcript", async (req, res) => {
: (e, n) => parseMessage(e, n);
const parseMessage = createClaudeLineParser(isSubagentFile);
const claudeFile = !isCodex && !isOmp && !isCursor && !isAgy;
// The edits of the turn the page starts inside of (agent-edits.js).
const carry = claudeFile ? require("../lib/agent-edits").turnCarry() : null;
if (afterLine !== null) {
// Incremental mode: skip lines until after afterLine, collect up to limit, then stop.
@@ -1232,7 +1202,8 @@ router.get("/:id/transcript", async (req, res) => {
messages.push(message);
// Sliding window: only keep the last `limit` messages
if (messages.length > limit) {
messages.shift();
const dropped = messages.shift();
carry?.drop(dropped);
}
}
if (total > limit) hasMore = true;
@@ -1283,13 +1254,22 @@ router.get("/:id/transcript", async (req, res) => {
messages.push(message);
// Sliding window: only keep the last `limit` messages in memory
if (messages.length > limit) {
messages.shift();
const dropped = messages.shift();
carry?.drop(dropped);
}
}
// If we shifted any messages out, there are more
hasMore = total > limit;
}
// A Claude subagent's own Write/Edit calls are in ITS transcript: put the
// files on its Agent result, so the turn's "Edited N files" counts them.
let turnEdits = null;
if (claudeFile) {
await require("../lib/agent-edits").attachAgentEdits(messages, req.params.id, session.cwd);
turnEdits = await carry.result(messages, req.params.id, session.cwd);
}
const lastLine = messages.length > 0 ? messages[messages.length - 1].line : 0;
const firstLine = messages.length > 0 ? messages[0].line : 0;
@@ -1304,6 +1284,7 @@ router.get("/:id/transcript", async (req, res) => {
has_more: hasMore,
last_line: lastLine,
first_line: firstLine,
...(turnEdits ? { turn_edits: turnEdits } : {}),
});
} catch (err) {
res.json({ messages: [], total: 0, has_more: false, last_line: 0, first_line: 0 });
@@ -1578,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.