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 # 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 ## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring. - Maintain a reliable local-first dashboard for Claude Code session monitoring.
- Preserve real-time behavior (hooks -> API -> SQLite -> WebSocket -> UI). - Preserve real-time behavior (hooks -> API -> SQLite -> WebSocket -> UI).
## Repo map ## 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. - `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.) - `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. - `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`. - **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. - **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. - **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. - **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`, `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`. - **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. - **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. - **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. - **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.) - **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 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. - **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. - **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 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 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. - **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> <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="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="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>
<g transform="translate(792,300)"> <g transform="translate(792,300)">
<rect width="344" height="116" rx="16" fill="#ffffff" fill-opacity="0.045" stroke="#ffffff" stroke-opacity="0.10"/> <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", codex: "Codex",
antigravity: "Antigravity", antigravity: "Antigravity",
cursor: "Cursor", cursor: "Cursor",
grok: "Grok",
kimi: "Kimi",
opencode: "OpenCode Go",
}; };
function localDate(d = new Date()): string { function localDate(d = new Date()): string {
@@ -78,7 +78,12 @@ import { useSpin } from "../../hooks/useSpin";
import { useOnScreen, isShown } from "../../hooks/useOnScreen"; import { useOnScreen, isShown } from "../../hooks/useOnScreen";
import { throttle } from "../../lib/throttle"; import { throttle } from "../../lib/throttle";
import { useTranslation } from "react-i18next"; 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 / // Catch-up poll interval. Claude Code only fires hooks on PreToolUse /
// PostToolUse / Stop, which means a user-typed message (no hook) and any // PostToolUse / Stop, which means a user-typed message (no hook) and any
@@ -175,6 +180,8 @@ function ConversationViewPane({
initialTranscriptId ?? null initialTranscriptId ?? null
); );
const [hasMore, setHasMore] = useState(false); 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 [error, setError] = useState<string | null>(null);
const [transcripts, setTranscripts] = useState<TranscriptInfo[]>([]); const [transcripts, setTranscripts] = useState<TranscriptInfo[]>([]);
const [showNewMsg, setShowNewMsg] = useState(false); const [showNewMsg, setShowNewMsg] = useState(false);
@@ -283,6 +290,7 @@ function ConversationViewPane({
if (cancelled) return; if (cancelled) return;
setNotStarted(false); setNotStarted(false);
setMessages(result.messages); setMessages(result.messages);
setLeadEdits(result.turn_edits);
setTotal(result.total); setTotal(result.total);
setHasMore(result.has_more); setHasMore(result.has_more);
lastLineRef.current = result.last_line; lastLineRef.current = result.last_line;
@@ -343,6 +351,7 @@ function ConversationViewPane({
// Seed the view in a single render so the user sees the whole // 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. // catch-up batch instead of a blank panel followed by a partial one.
setMessages(result.messages); setMessages(result.messages);
setLeadEdits(result.turn_edits);
firstLineRef.current = result.first_line; firstLineRef.current = result.first_line;
setHasMore(result.has_more); setHasMore(result.has_more);
} else { } else {
@@ -487,6 +496,7 @@ function ConversationViewPane({
firstLineRef.current = result.first_line; firstLineRef.current = result.first_line;
setMessages((prev) => [...result.messages, ...prev]); setMessages((prev) => [...result.messages, ...prev]);
setLeadEdits(result.turn_edits);
setHasMore(result.has_more); setHasMore(result.has_more);
// Preserve scroll position (don't jump to top) // Preserve scroll position (don't jump to top)
@@ -686,6 +696,7 @@ function ConversationViewPane({
stopped={stopped} stopped={stopped}
agentLinks={agentLinks} agentLinks={agentLinks}
compact={compact} compact={compact}
leadEdits={leadEdits}
sessionId={ sessionId={
selectedTranscript && selectedTranscript !== "main" ? undefined : sessionId selectedTranscript && selectedTranscript !== "main" ? undefined : sessionId
} }
@@ -172,6 +172,9 @@ interface MessageListProps {
/** A subagent's conversation inside its step: tight spacing, and the task /** A subagent's conversation inside its step: tight spacing, and the task
* the orchestrator gave it reads as the prompt it is. */ * the orchestrator gave it reads as the prompt it is. */
compact?: boolean; 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 */ /** 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 }; | { kind: "command"; key: string; name: string | null; output: string | null; error: boolean };
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]); 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 /** 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 * writes first, the command, what the command printed — or null for
@@ -515,11 +522,18 @@ export function localCommand(
return { name, output, error }; 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[] = []; const items: Item[] = [];
// The file-changing calls of the turn so far, closed into one card when the // The file-changing calls of the turn so far, closed into one card when the
// human speaks again (or the list ends). // human speaks again (or the list ends) — starting with what the page's
let edits: TranscriptContent[] = []; // 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, // The turn's prompt time, its newest assistant line and its output so far,
// for the "Done in …" line under a turn that ended. // for the "Done in …" line under a turn that ended.
let start: string | null = null; let start: string | null = null;
@@ -592,7 +606,11 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
if (msg.usage) output.add(msg.usage); if (msg.usage) output.add(msg.usage);
msg.content.forEach((block, b) => { msg.content.forEach((block, b) => {
const last = items[items.length - 1]; 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. // A question to the user is a turn of the conversation, not hidden work.
if (block.type === "tool_use" && block.name === "AskUserQuestion") { if (block.type === "tool_use" && block.name === "AskUserQuestion") {
items.push({ kind: "ask", key: `${key}:${b}`, block }); 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 }>(); 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. // Written whole in this turn (a Write): a component made here is an artifact.
const created = new Set<string>(); 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) { 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 input = (b.input ?? {}) as Record<string, unknown>;
const p = input.file_path ?? input.notebook_path; const p = input.file_path ?? input.notebook_path;
const r = b.id ? toolResultMap.get(b.id) : undefined; const r = b.id ? toolResultMap.get(b.id) : undefined;
if (typeof p !== "string" || !r || r.is_error) continue; if (typeof p !== "string" || !r || r.is_error) continue;
if (b.name === "Write") created.add(p); if (b.name === "Write") created.add(p);
byAgent.delete(p);
const f = files.get(p) ?? { added: 0, removed: 0, counted: false }; const f = files.get(p) ?? { added: 0, removed: 0, counted: false };
f.added += r.diff?.added ?? 0; f.added += r.diff?.added ?? 0;
f.removed += r.diff?.removed ?? 0; f.removed += r.diff?.removed ?? 0;
f.counted ||= !!r.diff; f.counted ||= !!r.diff;
files.set(p, f); 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 && ( const cards = artifacts.length > 0 && (
<div data-testid="artifact-cards" className="flex flex-col gap-2"> <div data-testid="artifact-cards" className="flex flex-col gap-2">
{artifacts.map((p) => ( {artifacts.map((p) => (
@@ -773,6 +810,7 @@ function MessageListView({
sessionId, sessionId,
agentLinks, agentLinks,
compact = false, compact = false,
leadEdits,
}: MessageListProps) { }: MessageListProps) {
const { t } = useTranslation("sessions"); const { t } = useTranslation("sessions");
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]); const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
@@ -798,9 +836,10 @@ function MessageListView({
m.sender === "orchestrator" ? { ...m, sender: "user" as const } : m m.sender === "orchestrator" ? { ...m, sender: "user" as const } : m
) )
: messages, : messages,
keys keys,
leadEdits
), ),
[messages, keys, compact] [messages, keys, compact, leadEdits]
); );
const agents = useMemo( const agents = useMemo(
() => (live ? backgroundAgents(messages, toolResultMap) : []), () => (live ? backgroundAgents(messages, toolResultMap) : []),
@@ -4,7 +4,10 @@
* Desktop's "Edited N files" card: one row per file with the lines added and * 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 * 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` * 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> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { describe, it, expect, vi } from "vitest"; 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) JSON.parse(all["application/x-agenthub-attach"]!).map((a: { path: string }) => a.path)
).toEqual(["@/w/a.ts", "@/w/b.json"]); ).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("codex", "Codex", { launcher: "codex", proxyCapable: true, prompt: null }),
tool("gemini", "Gemini"), tool("gemini", "Gemini"),
tool("aider", "Aider", { installed: false, path: null }), 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: [ tools: [
tool("claude", "Claude Code"), tool("claude", "Claude Code"),
tool("gemini", "Gemini"), tool("gemini", "Gemini"),
tool("grok", "Grok", { enabled: false }), tool("goose", "Goose", { enabled: false }),
tool("aider", "Aider", { installed: false, path: null }), tool("aider", "Aider", { installed: false, path: null }),
], ],
}; };
@@ -155,9 +155,9 @@ describe("AiToolsSettings", () => {
await user.click(within(gemini).getByText("Set default")); await user.click(within(gemini).getByText("Set default"));
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { default: true }); expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { default: true });
// A disabled tool cannot become the default. // A disabled tool cannot become the default.
const grok = screen.getByTestId("ai-tool-grok"); const goose = screen.getByTestId("ai-tool-goose");
expect(within(grok).getByText("Set default")).toBeDisabled(); expect(within(goose).getByText("Set default")).toBeDisabled();
expect(within(grok).getByRole("switch")).toHaveAttribute("aria-checked", "false"); expect(within(goose).getByRole("switch")).toHaveAttribute("aria-checked", "false");
}); });
it("refreshes detection", async () => { it("refreshes detection", async () => {
@@ -179,7 +179,7 @@ describe("AiToolsSettings", () => {
await user.click(screen.getByRole("menuitem", { name: "Disable" })); await user.click(screen.getByRole("menuitem", { name: "Disable" }));
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { enabled: false }); expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { enabled: false });
// A disabled tool cannot become the default from the menu either. // 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(); expect(screen.getByRole("menuitem", { name: "Set default" })).toBeDisabled();
}); });
}); });
@@ -301,11 +301,9 @@ describe("AiToolsSettings — install and update", () => {
describe("AiToolIcon", () => { describe("AiToolIcon", () => {
it("retries the icon when the same instance shows another tool", async () => { it("retries the icon when the same instance shows another tool", async () => {
const { AiToolIcon } = await import("../../AiToolIcon"); const { AiToolIcon } = await import("../../AiToolIcon");
const { container, rerender } = render( const { container, rerender } = render(<AiToolIcon tool={{ id: "aider", label: "Aider" }} />);
<AiToolIcon tool={{ id: "opencode", label: "OpenCode" }} />
);
fireEvent.error(container.querySelector("img")!); fireEvent.error(container.querySelector("img")!);
expect(screen.getByText("O")).toBeInTheDocument(); expect(screen.getByText("A")).toBeInTheDocument();
rerender(<AiToolIcon tool={{ id: "cursor", label: "Cursor CLI" }} />); rerender(<AiToolIcon tool={{ id: "cursor", label: "Cursor CLI" }} />);
expect(container.querySelector('img[src="/api/ai-tools/cursor/icon"]')).toBeTruthy(); expect(container.querySelector('img[src="/api/ai-tools/cursor/icon"]')).toBeTruthy();
}); });
+28 -2
View File
@@ -34,7 +34,6 @@
"download": "Download", "download": "Download",
"noPreview": "This file type cannot be shown here — download it to open it.", "noPreview": "This file type cannot be shown here — download it to open it.",
"install": "Install", "install": "Install",
"installs": "{{count}} installs",
"installed": "Installed to {{path}}", "installed": "Installed to {{path}}",
"installedPlugin": "Installed {{name}} — start a new session (or /reload-plugins) to use it", "installedPlugin": "Installed {{name}} — start a new session (or /reload-plugins) to use it",
"rename": "Edit", "rename": "Edit",
@@ -197,5 +196,32 @@
"unowned.claimed": "Added to your library", "unowned.claimed": "Added to your library",
"unowned.declined": "Removed from your list", "unowned.declined": "Removed from your list",
"unowned.assigned": "Owner assigned", "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.claimed": "Đã nhận về thư viện của bạn",
"unowned.declined": "Đã bỏ khỏi danh sách của bạn", "unowned.declined": "Đã bỏ khỏi danh sách của bạn",
"unowned.assigned": "Đã gán chủ", "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", method: "POST",
body: JSON.stringify({ replace }), 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`. */ /** POST /api/artifacts/:id/uninstall — a skill moved aside, a plugin `claude plugin uninstall`. */
uninstall: (id: number) => uninstall: (id: number) =>
request<{ uninstalled: string; paths: string[] }>(`/artifacts/${id}/uninstall`, { request<{ uninstalled: string; paths: string[] }>(`/artifacts/${id}/uninstall`, {
@@ -4423,6 +4430,9 @@ export interface Artifact {
inLibrary: boolean; inLibrary: boolean;
/** List rows only: a skill/plugin already in every account the viewer's sessions run as. */ /** List rows only: a skill/plugin already in every account the viewer's sessions run as. */
installed?: boolean; 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. */ /** file: captured when its session stopped, not pinned by hand. */
auto?: boolean; auto?: boolean;
/** Server mode: nobody owns it yet. */ /** 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 — /** The AI coding CLI that produced the session: `"claude"` (the default —
* Claude Code's hooks and imported transcripts), `"codex"` (Codex's rollout * Claude Code's hooks and imported transcripts), `"codex"` (Codex's rollout
* journals and hooks), `"antigravity"` (agy's conversation files and * journals and hooks), `"antigravity"` (agy's conversation files and
* hooks), `"cursor"` (Cursor's agent transcripts, no tokens), `"omp"`, * hooks), `"cursor"` (Cursor's agent transcripts, no tokens) or `"omp"`
* `"opencode"` (their own session stores, with tokens and subagents) or * (its own session journals, with tokens and subagents). Rows an older build
* `"grok"` (Grok running the Claude hooks, no tokens). Maps to * wrote may still say `"opencode"` or `"grok"`. Maps to `sessions.tool`. */
* `sessions.tool`. */
tool?: string; tool?: string;
/** The workspace (lane id) that owns the session — the one whose pane held /** The workspace (lane id) that owns the session — the one whose pane held
* it; null for a session no workspace started. * it; null for a session no workspace started.
@@ -1217,7 +1216,7 @@ export interface CostResult {
/** GET /api/pricing/quota: plan quota spent per native tool (server/lib/quota.js). */ /** GET /api/pricing/quota: plan quota spent per native tool (server/lib/quota.js). */
export interface ToolQuota { 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. /** One per plan window: `5h`, `wk`, `mo`, …; `usedPercent` is 0–100.
* `group` names the model pool a window belongs to (Antigravity's). */ * `group` names the model pool a window belongs to (Antigravity's). */
windows: Array<{ windows: Array<{
@@ -3236,6 +3235,10 @@ export interface TranscriptContent {
/** Present on a Write/Edit/MultiEdit "tool_result": the lines it added and /** Present on a Write/Edit/MultiEdit "tool_result": the lines it added and
* removed (from Claude Code's `structuredPatch`). */ * removed (from Claude Code's `structuredPatch`). */
diff?: { added: number; removed: number }; 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 /** Present on an AskUserQuestion "tool_result": question text → the option
* the user picked, or what they typed. */ * the user picked, or what they typed. */
answers?: Record<string, string>; answers?: Record<string, string>;
@@ -3320,6 +3323,10 @@ export interface TranscriptResult {
last_line: number; last_line: number;
/** JSONL line number of the first message in this page. */ /** JSONL line number of the first message in this page. */
first_line: number; 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 /** 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 * 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 * 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. * 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> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -33,6 +37,7 @@ vi.mock("../../lib/api", () => ({
update: vi.fn(), update: vi.fn(),
remove: vi.fn(), remove: vi.fn(),
install: vi.fn(), install: vi.fn(),
updateInstalled: vi.fn(),
uninstall: vi.fn(), uninstall: vi.fn(),
installs: vi.fn(), installs: vi.fn(),
setInstallsEnabled: 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 })); 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", () => { describe("Adding", () => {
it("adds the plugins picked from a marketplace link", async () => { it("adds the plugins picked from a marketplace link", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
@@ -218,7 +233,11 @@ describe("Artifacts", () => {
const user = userEvent.setup(); const user = userEvent.setup();
open(); open();
expect(await screen.findByRole("button", { name: "Hub" })).toBeInTheDocument(); 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. // One merged list by default; Hub is a filter over it.
expect(api.artifacts.list).toHaveBeenLastCalledWith(expect.objectContaining({ scope: "all" })); expect(api.artifacts.list).toHaveBeenLastCalledWith(expect.objectContaining({ scope: "all" }));
await user.click(screen.getByRole("button", { name: "Hub" })); 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(); 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({ 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: [] }) item({ id: i + 1, kind: "page", title: `p${i + 1}`, files: [] })
), ),
}); });
const user = userEvent.setup(); const user = userEvent.setup();
open(); open();
await screen.findByText("p1"); await screen.findByText("p1");
expect(screen.queryByText("p13")).toBeNull(); expect(screen.getByText("p30")).toBeInTheDocument();
expect(screen.getByText("Showing 1-12 of 14")).toBeInTheDocument(); expect(screen.queryByText("p31")).toBeNull();
await user.click(screen.getByRole("button", { name: "Next" })); await user.click(screen.getByRole("button", { name: "Show 4 more" }));
expect(screen.getByText("p13")).toBeInTheDocument(); expect(screen.getByText("p34")).toBeInTheDocument();
expect(screen.queryByText("p1")).toBeNull(); 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 () => { 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).getByText("Hub only")).toBeInTheDocument();
expect(within(card).queryByRole("button", { name: "Delete" })).toBeNull(); expect(within(card).queryByRole("button", { name: "Delete" })).toBeNull();
vi.mocked(confirmDialog).mockResolvedValueOnce(true); 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 })); await waitFor(() => expect(api.artifacts.update).toHaveBeenCalledWith(3, { shared: false }));
const calls = vi.mocked(confirmDialog).mock.calls; const calls = vi.mocked(confirmDialog).mock.calls;
expect(calls[calls.length - 1]?.[0].title).toMatch(/Remove “hub-only” from the Hub/); 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 () => { it("shows Installed instead of Install for a skill already installed", async () => {
vi.mocked(api.artifacts.list).mockResolvedValue({ vi.mocked(api.artifacts.list).mockResolvedValue({
items: [item({ id: 2, kind: "skill", title: "deploy", source: "deploy", installed: true })], items: [item({ id: 2, kind: "skill", title: "deploy", source: "deploy", installed: true })],
@@ -436,7 +507,7 @@ describe("Artifacts", () => {
const user = userEvent.setup(); const user = userEvent.setup();
open(); open();
await screen.findByText("standup"); 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(await screen.findByRole("heading", { name: "Daily standup" })).toBeInTheDocument();
expect(screen.getByTestId("skill-summary")).toHaveTextContent("/out/report.html"); expect(screen.getByTestId("skill-summary")).toHaveTextContent("/out/report.html");
expect(screen.queryByTestId("artifact-preview")).toBeNull(); expect(screen.queryByTestId("artifact-preview")).toBeNull();
@@ -464,7 +535,7 @@ describe("Artifacts", () => {
const user = userEvent.setup(); const user = userEvent.setup();
open(); open();
await screen.findByText("standup"); await screen.findByText("standup");
await user.click(screen.getByRole("button", { name: "Edit" })); await menuAction(user, "Edit");
const name = await screen.findByLabelText(/Skill name/); const name = await screen.findByLabelText(/Skill name/);
expect(name).toHaveValue("standup"); expect(name).toHaveValue("standup");
expect(screen.getByLabelText(/Instructions/)).toHaveValue("Old body"); expect(screen.getByLabelText(/Instructions/)).toHaveValue("Old body");
@@ -7244,7 +7244,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span <span
class="text-status-warning/90" class="text-status-warning/90"
> >
UI build v2.8.23 (rebuild client to match) UI build v2.8.24 (rebuild client to match)
</span> </span>
</p> </p>
</div> </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) AGENTHUB_GEMINI_DIR= # where agy keeps its data (default ~/.gemini)
DASHBOARD_CURSOR_POLL_MS=15000 # Cursor agent transcripts → Cursor sessions (no tokens; 60 s while idle) DASHBOARD_CURSOR_POLL_MS=15000 # Cursor agent transcripts → Cursor sessions (no tokens; 60 s while idle)
DASHBOARD_OMP_POLL_MS=15000 # omp session journals → Oh My Pi sessions/tokens/subagents (60 s while idle) DASHBOARD_OMP_POLL_MS=15000 # omp session journals → Oh My Pi sessions/tokens/subagents (60 s while idle)
DASHBOARD_OPENCODE_POLL_MS=15000 # opencode.db → OpenCode sessions/tokens/subagents (60 s while idle)
AGENTHUB_CURSOR_DIR= # where Cursor keeps its data (default ~/.cursor) AGENTHUB_CURSOR_DIR= # where Cursor keeps its data (default ~/.cursor)
AGENTHUB_AGY_USAGE= # 0 never runs `agy --print /usage` for the Usage panel's Antigravity quota (default on; running agy may update it) AGENTHUB_AGY_USAGE= # 0 never runs `agy --print /usage` for the Usage panel's Antigravity quota (default on; running agy may update it)
+1 -1
View File
@@ -1838,7 +1838,7 @@ agenthub stage <stage> [--evidence "..."] # Report the current stage
## Workspaces on any AI tool ## Workspaces on any AI tool
A workspace can run any AI coding CLI in the AI Tools catalog (`server/data/ai-tools/*.json` — 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: 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 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. its workspace (`omp --resume <id>`), like a Claude or Codex one.
**OpenCode** sessions are read from its SQLite store Cursor sessions resume by id too (`--resume <id>`), and open as a chat like
(`~/.local/share/opencode/opencode.db`, every `DASHBOARD_OPENCODE_POLL_MS` ms), omp ones. OpenCode and Grok Build are no longer AgentHub tools: a Grok Build
with their tokens and cost per model and one agent per subagent, and are running the Claude hooks in `~/.claude/settings.json` is ignored, and sessions an
resumable by id (`opencode --session <id>`). **Grok Build** runs the Claude hooks in older build recorded for either stay in the history.
`~/.claude/settings.json` itself, so its sessions arrive through AgentHub's own
hooks, recorded as Grok sessions (live status, no tokens); they resume with
`grok --resume <id>`. Cursor sessions resume by id too (`--resume <id>`).
**Pipelines are dormant.** AgentHub ships no pipeline template, so a workspace has no **Pipelines are dormant.** AgentHub ships no pipeline template, so a workspace has no
stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see
+1 -1
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: Agent Dashboard for Claude Code API 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. description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact: contact:
name: Nguyễn Ngọc Trí Vĩ name: Nguyễn Ngọc Trí Vĩ
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "2.8.23", "version": "2.8.24",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "agenthub", "name": "agenthub",
"version": "2.8.23", "version": "2.8.24",
"hasInstallScript": true, "hasInstallScript": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "2.8.23", "version": "2.8.24",
"bin": { "bin": {
"agenthub": "bin/agenthub.js" "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). // No suite may fetch LiteLLM's price list (reset-pricing syncs at once).
AGENTHUB_PRICING_SYNC: "0", AGENTHUB_PRICING_SYNC: "0",
// Nor run the developer's real agy (it may update itself) or send their // 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", AGENTHUB_AGY_USAGE: "0",
// Nor refresh (spend) a real Claude pool login: its refresh token is single use. // Nor refresh (spend) a real Claude pool login: its refresh token is single use.
DASHBOARD_POOL_REFRESH_MS: "0", DASHBOARD_POOL_REFRESH_MS: "0",
XDG_CONFIG_HOME: tmuxDir, XDG_CONFIG_HOME: tmuxDir,
XDG_DATA_HOME: tmuxDir, XDG_DATA_HOME: tmuxDir,
GROK_HOME: tmuxDir,
KIMI_CODE_HOME: tmuxDir,
DASHBOARD_PIPELINES_DIR: path.join(dir, "fixtures", "pipelines"), DASHBOARD_PIPELINES_DIR: path.join(dir, "fixtures", "pipelines"),
// Never this machine's server-mode S3 install: its system unit would become // 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. // "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 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 session's journals from there. Under S3 each member's `~/.omp` is read only
through `openSafeAsync`. through `openSafeAsync`.
- **OpenCode sessions:** `server/lib/opencode-usage.js` reads OpenCode's - **Grok hooks:** Grok is not an AgentHub tool, but it runs the hooks in
SQLite store (`~/.local/share/opencode/opencode.db`, under `XDG_DATA_HOME` `~/.claude/settings.json` (its Claude compatibility), so AgentHub's own hook
when set) every `DASHBOARD_OPENCODE_POLL_MS` ms (default 15000, `0` can still reach `POST /api/hooks/event` with no `tool`; `routes/hooks.js`
disables), when its file or WAL changed: each root `session` row becomes a tells it apart by the camelCase keys only Grok sends (`hookEventName` +
`sessions` row (`tool = 'opencode'`), its tree's assistant `message` tokens `workspaceRoot`) and drops it, rather than record a Claude session whose
per `modelID` become `token_usage` (reasoning counted as output), and each `transcript_path` is Grok's file.
child session (the `task` tool's) an `opencode-<id>` subagent row.
`opencode_stores` holds each store's mtime and `opencode_sessions` each root
session's newest `time_updated`; the transcript routes read the conversation
(messages + parts) from the store itself. A member's store and WAL are copied
through the guard before they are opened.
- **Grok sessions:** Grok runs the hooks in `~/.claude/settings.json` (its
Claude compatibility), so AgentHub's own hook reaches `POST /api/hooks/event`
with no `tool`; `routes/hooks.js` tells it apart by the camelCase keys only
Grok sends (`hookEventName` + `workspaceRoot`), records it as `tool = 'grok'`
and drops its `transcript_path` (Grok's file, never parsed as a Claude
transcript). No tokens: Grok's session files are not read.
- **Quiet sessions:** agy fires no exit hook, so a session quiet for ten - **Quiet sessions:** agy fires no exit hook, so a session quiet for ten
minutes (with hook events) or two (without) is settled to `completed`, its minutes (with hook events) or two (without) is settled to `completed`, its
agents too, and sets its workspace idle. That workspace must be one the hooks left agents too, and sets its workspace idle. That workspace must be one the hooks left
+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, builtin,
tool("gem", [ tool("gem", [
{ path: "~/.gem/settings.json" }, { path: "~/.gem/settings.json" },
{ path: "~/.gem/opencode.jsonc" }, { path: "~/.gem/tool.jsonc" },
{ path: "~/.gem/GEMINI.md" }, { path: "~/.gem/GEMINI.md" },
{ path: "~/.gem/missing.json" }, { path: "~/.gem/missing.json" },
]) ])
@@ -57,7 +57,7 @@ before(async () => {
fs.mkdirSync(path.join(home, ".gem")); 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/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"); fs.writeFileSync(path.join(home, ".gem/GEMINI.md"), "# rules\n");
const express = require("express"); const express = require("express");
+31 -11
View File
@@ -26,8 +26,28 @@ for (const name of ["claude", "agy", "npm"]) {
const origPath = process.env.PATH; const origPath = process.env.PATH;
process.env.PATH = BIN; 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"); 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 app;
let base; let base;
@@ -59,7 +79,7 @@ describe("/api/ai-tools", () => {
assert.equal(agy.path, path.join(BIN, "agy")); assert.equal(agy.path, path.join(BIN, "agy"));
assert.equal(agy.resume, true); assert.equal(agy.resume, true);
assert.equal(agy.bypass, 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); assert.equal("args" in agy, false);
}); });
@@ -76,7 +96,7 @@ describe("/api/ai-tools", () => {
it("answers ENOTOOL / EBADDEFAULT / EBADFIELD", async () => { it("answers ENOTOOL / EBADDEFAULT / EBADFIELD", async () => {
assert.equal((await request("PUT", "/api/ai-tools/nope", { enabled: false })).status, 404); 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.status, 400);
assert.equal(bad.body.error.code, "EBADDEFAULT"); assert.equal(bad.body.error.code, "EBADDEFAULT");
assert.equal((await request("PUT", "/api/ai-tools/claude", { enabled: "no" })).status, 400); 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 () => { it("refresh re-detects", async () => {
fs.writeFileSync(path.join(BIN, "opencode"), "#!/bin/sh\n"); fs.writeFileSync(path.join(BIN, "npmtool"), "#!/bin/sh\n");
fs.chmodSync(path.join(BIN, "opencode"), 0o755); fs.chmodSync(path.join(BIN, "npmtool"), 0o755);
const r = await request("POST", "/api/ai-tools/refresh"); 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 () => { it("/api/run/binary resolves through the catalog", async () => {
@@ -132,9 +152,9 @@ describe("lanes on catalog tools", () => {
fs.mkdirSync(d, { recursive: true }); fs.mkdirSync(d, { recursive: true });
return d; 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.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" }); 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"); 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" }); 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"); const notInstalled = await request("POST", "/api/ai-tools/codex/update");
assert.equal(notInstalled.status, 409); assert.equal(notInstalled.status, 409);
assert.equal(notInstalled.body.error.code, "ENOTINSTALLED"); assert.equal(notInstalled.body.error.code, "ENOTINSTALLED");
// opencode was put on PATH by the refresh test, outside any npm prefix. // npmtool was put on PATH by the refresh test, outside any npm prefix.
const notNpm = await request("POST", "/api/ai-tools/opencode/update"); const notNpm = await request("POST", "/api/ai-tools/npmtool/update");
assert.equal(notNpm.status, 409); assert.equal(notNpm.status, 409);
assert.equal(notNpm.body.error.code, "ENOTNPM"); assert.equal(notNpm.body.error.code, "ENOTNPM");
const cross = await request("POST", "/api/ai-tools/codex/install", undefined, { 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"]); assert.deepEqual(spawned.at(-1), [path.join(BIN, "claude"), "update"]);
const v = await request("GET", "/api/ai-tools/versions"); const v = await request("GET", "/api/ai-tools/versions");
assert.equal(v.status, 200); 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 () => { 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 * 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 * file uploaded, sharing refused in local mode, visibility for a server-mode
* developer, the admin's installs switch, same-origin writes, Claude Docs * developer, the admin's installs switch, same-origin writes, Claude Docs
* docs captured at their birth, documents captured at a session's stop, and * docs captured at their birth, documents captured at a session's stop,
* unowned rows claimed, declined and assigned. * 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> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -338,6 +340,69 @@ describe("skills", () => {
assert.equal(empty.status, 400); 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 () => { it("refuses a skill holding a symlink or a secret-looking file", async () => {
const bad = path.join(ws, ".claude", "skills", "leaky"); const bad = path.join(ws, ".claude", "skills", "leaky");
fs.mkdirSync(bad, { recursive: true }); fs.mkdirSync(bad, { recursive: true });
@@ -1,7 +1,7 @@
{ {
"id": "grok", "id": "septool",
"label": "Grok", "label": "Separator tool",
"detect": ["grok"], "detect": ["septool"],
"prompt": { "prompt": {
"mode": "argv", "mode": "argv",
"separator": "--" "separator": "--"
+9 -14
View File
@@ -2,10 +2,10 @@
* @file Tests POST /api/hooks/event for a hook Grok Build fired through the * @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 * 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 * arrives with no `tool`, is told apart by the camelCase keys only Grok sends
* (`hookEventName`, `workspaceRoot`), and lands as a `tool = 'grok'` session * (`hookEventName`, `workspaceRoot`), and dropped — Grok is not an AgentHub
* on the grok workspace — never the claude one sharing the directory — with * tool, and taken as Claude's it would bind the claude workspace and name a
* its `transcript_path` dropped. A Claude hook is left Claude's. The DB lives * Grok file as a Claude transcript. A Claude hook is left Claude's. The DB
* in a temp dir. * lives in a temp dir.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @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", () => { 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 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"; 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({ await post({
hook_type: "UserPromptSubmit", hook_type: "UserPromptSubmit",
data: grokData(id, { hookEventName: "user_prompt_submit", prompt: "fix the bug" }), data: grokData(id, { hookEventName: "user_prompt_submit", prompt: "fix the bug" }),
}); });
const s = db.prepare("SELECT * FROM sessions WHERE id = ?").get(id); assert.equal(db.prepare("SELECT * FROM sessions WHERE id = ?").get(id), undefined);
assert.equal(s.tool, "grok"); assert.equal(db.prepare("SELECT COUNT(*) n FROM events WHERE session_id = ?").get(id).n, 0);
assert.equal(s.transcript_path, null);
const events = db.prepare("SELECT data FROM events WHERE session_id = ?").all(id);
assert.ok(events.length >= 1);
for (const e of events) assert.equal(JSON.parse(e.data).transcript_path, undefined);
assert.equal(lanes.getLane(claudeLane.id).session_id, null); assert.equal(lanes.getLane(claudeLane.id).session_id, null);
assert.equal(lanes.getLane(grokLane.id).session_id, id);
}); });
it("leaves a Claude hook Claude's", async () => { 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; let savedPath;
before(() => { before(() => {
fs.mkdirSync(BIN, { recursive: true }); 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.writeFileSync(path.join(BIN, name), "#!/bin/sh\n");
fs.chmodSync(path.join(BIN, name), 0o755); fs.chmodSync(path.join(BIN, name), 0o755);
} }
@@ -854,7 +854,7 @@ describe("pty-run", () => {
); );
assert.deepEqual(argvFor("antigravity"), [at("agy"), "-i", " -x y"]); assert.deepEqual(argvFor("antigravity"), [at("agy"), "-i", " -x y"]);
// Behind a `--` separator the prompt is already a positional, kept as typed. // 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("command-code"), [at("command-code"), "--trust", " -x y"]);
assert.deepEqual(argvFor("antigravity", { bypass: false }), [at("agy"), "-i", " -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 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 * 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 * 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 * valid; and Antigravity's `agy --print /usage` pools, never on an old agy.
* the Grok, Kimi Code and OpenCode Go logins, each sent only to its own host
* while valid, never refreshed.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @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. // Nor run the machine's real agy: only the Antigravity test's fake one runs.
process.env.AGENTHUB_AGY_USAGE = "0"; 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", () => { test("windowLabel names the plan windows", () => {
assert.strictEqual(windowLabel(300), "5h"); assert.strictEqual(windowLabel(300), "5h");
@@ -415,172 +407,3 @@ function stubFetch(answers) {
}; };
return { asked, restore: () => (global.fetch = prev) }; 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 * @file server-s3-quota.test.js
* @description S3 plan, P4: plan quota per member home. `quotas({home})` * @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 * 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 * 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 * 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.CODEX_HOME = path.join(HOST, ".codex");
process.env.XDG_CONFIG_HOME = path.join(HOST, ".config"); process.env.XDG_CONFIG_HOME = path.join(HOST, ".config");
process.env.AGENTHUB_CURSOR_DIR = path.join(HOST, ".cursor"); 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 { createApp, startServer } = require("../index");
const auth = require("../lib/auth"); const auth = require("../lib/auth");
const linux = require("../lib/linux-accounts"); const linux = require("../lib/linux-accounts");
const runAs = require("../lib/run-as"); const runAs = require("../lib/run-as");
const { quotas, __resetCache } = require("../lib/quota"); const { quotas, __resetCache } = require("../lib/quota");
const { Database } = require("../db");
const LATER = Date.now() + 3600_000; const LATER = Date.now() + 3600_000;
const write = (file, body) => { const write = (file, body) => {
@@ -76,26 +72,6 @@ function seedHome(home, who, { codexPercent }) {
write(path.join(home, ".cursor", "cli-config.json"), { write(path.join(home, ".cursor", "cli-config.json"), {
authInfo: { email: `${who}@cursor`, userId: "u1" }, 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 = []; let asked = [];
@@ -125,11 +101,7 @@ const setUid = (uid) => {
before(async () => { before(async () => {
seedHome(HOST, "host", { codexPercent: 77 }); 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 }); seedHome(MIA, "mia", { codexPercent: 21 });
seedOpenCodeDb(MIA, "mia-opencode");
setUid(process.getuid()); setUid(process.getuid());
fs.writeFileSync( fs.writeFileSync(
process.env.AGENTHUB_SERVER_CONF, process.env.AGENTHUB_SERVER_CONF,
@@ -159,27 +131,16 @@ describe("quotas({home}) reads a member's own logins", () => {
__resetCache(); __resetCache();
asked = []; asked = [];
const { tools } = await quotas({ home: MIA }); const { tools } = await quotas({ home: MIA });
assert.deepEqual(tools.map((t) => t.id).sort(), [ assert.deepEqual(tools.map((t) => t.id).sort(), ["claude", "codex", "cursor"]);
"claude",
"codex",
"cursor",
"grok",
"kimi",
"opencode",
]);
const codex = tools.find((t) => t.id === "codex"); const codex = tools.find((t) => t.id === "codex");
assert.equal(codex.account, "mia@codex"); assert.equal(codex.account, "mia@codex");
assert.equal(codex.windows[0].usedPercent, 21); assert.equal(codex.windows[0].usedPercent, 21);
assert.equal(tools.find((t) => t.id === "cursor").account, "mia@cursor"); 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( assert.ok(
asked.every((a) => a.includes("mia-")), asked.every((a) => a.includes("mia-")),
`only mia's tokens leave: ${asked.join(",")}` `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)); 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 () => { it("reads no file the member does not own, and never blocks on a FIFO", async () => {
fs.mkdirSync(path.join(BOB, ".grok"), { recursive: true }); fs.mkdirSync(path.join(BOB, ".config", "cursor"), { recursive: true });
require("node:child_process").execFileSync("mkfifo", [path.join(BOB, ".grok", "auth.json")]); require("node:child_process").execFileSync("mkfifo", [
path.join(BOB, ".config", "cursor", "auth.json"),
]);
__resetCache(); __resetCache();
assert.deepEqual((await quotas({ home: BOB })).tools, []); assert.deepEqual((await quotas({ home: BOB })).tools, []);
// mia's files now belong to another uid: none is read. // 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)", () => { it("a file in an npm package: the package folder (scoped too)", () => {
const plain = write( 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" "#!/usr/bin/env node\n"
); );
assert.deepEqual(shared.planFor(plain), { 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"), entry: path.join("bin", "oc.js"),
}); });
const scoped = write( 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 `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 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". 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) ## 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 // (`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 // 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`). // 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 [ for (const [col, ddl] of [
["auto", "auto INTEGER NOT NULL DEFAULT 0"], ["auto", "auto INTEGER NOT NULL DEFAULT 0"],
["suggested_owner_id", "suggested_owner_id INTEGER"], ["suggested_owner_id", "suggested_owner_id INTEGER"],
["version", "version TEXT"],
["source_hash", "source_hash TEXT"],
]) { ]) {
try { try {
db.prepare(`SELECT ${col} FROM artifacts LIMIT 1`).get(); 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); CREATE INDEX IF NOT EXISTS idx_omp_sessions_session ON omp_sessions(session_id);
`); `);
// Which OpenCode stores have been read at what mtime, and per root session the
// newest `time_updated` written (server/lib/opencode-usage.js) — the same
// "deleted stays deleted" rule.
db.exec(`
CREATE TABLE IF NOT EXISTS opencode_stores (
path TEXT PRIMARY KEY,
mtime_ms INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE IF NOT EXISTS opencode_sessions (
session_id TEXT PRIMARY KEY,
store TEXT NOT NULL,
time_updated INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
`);
// Which Cursor agent transcripts have been read, at what mtime // Which Cursor agent transcripts have been read, at what mtime
// (server/lib/cursor-usage.js) — the same "deleted stays deleted" rule. // (server/lib/cursor-usage.js) — the same "deleted stays deleted" rule.
db.exec(` db.exec(`
+5 -11
View File
@@ -793,17 +793,11 @@ function startBackgroundServices() {
} catch (err) { } catch (err) {
console.warn("cursor usage poll failed to start:", err.message); console.warn("cursor usage poll failed to start:", err.message);
} }
// Oh My Pi and OpenCode sessions, subagents and token usage, from each CLI's // Oh My Pi sessions, subagents and token usage, from its own session journals.
// own session store (omp-usage.js, opencode-usage.js). try {
for (const [name, mod, start] of [ require("./lib/omp-usage").startOmpUsagePoll(broadcast);
["omp", "./lib/omp-usage", "startOmpUsagePoll"], } catch (err) {
["opencode", "./lib/opencode-usage", "startOpencodeUsagePoll"], console.warn("omp usage poll failed to start:", err.message);
]) {
try {
require(mod)[start](broadcast);
} catch (err) {
console.warn(`${name} usage poll failed to start:`, err.message);
}
} }
// Codex/Antigravity hooks the user installed forward through a script whose // Codex/Antigravity hooks the user installed forward through a script whose
// content names this build; point it here (the command in the tool's // content names this build; point it here (the command in the tool's
+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); ).all(...params);
const owners = new Map(); const owners = new Map();
const installed = installedCheck(req); 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; return scope === "mine" && req?.user ? out.filter((a) => a.mine || a.installed) : out;
} }
@@ -753,7 +757,7 @@ function walkSkill(dir) {
total += st.size; total += st.size;
if (files.length >= SKILL_MAX_FILES) throw fail("ETOOBIG", "the skill has too many files"); 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"); 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); walk(dir, "", 0);
@@ -822,6 +826,7 @@ function storeSkill(req, { name, files, title, description, laneId = null }) {
const snap = newSnapshotDir(); const snap = newSnapshotDir();
let total = 0; let total = 0;
const recorded = []; const recorded = [];
const hashed = [];
try { try {
for (const f of files) { for (const f of files) {
const data = typeof f.data === "function" ? f.data() : f.data; 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.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
fs.writeFileSync(target, data, { mode: 0o600 }); fs.writeFileSync(target, data, { mode: 0o600 });
recorded.push({ path: f.path, size: data.length }); recorded.push({ path: f.path, size: data.length });
hashed.push({ path: f.path, data });
} }
} catch (err) { } catch (err) {
dropSnapshot(snap.id); dropSnapshot(snap.id);
@@ -845,20 +851,30 @@ function storeSkill(req, { name, files, title, description, laneId = null }) {
} catch { } catch {
/* no frontmatter */ /* no frontmatter */
} }
return view( const row = insert({
insert({ kind: "skill",
kind: "skill", title: cleanTitle(title, name),
title: cleanTitle(title, name), description: cleanDescription(description) || summary.slice(0, DESCRIPTION_MAX),
description: cleanDescription(description) || summary.slice(0, DESCRIPTION_MAX), owner_id: req?.user ? req.user.id : null,
owner_id: req?.user ? req.user.id : null, lane_id: laneId,
lane_id: laneId, source: name,
source: name, snapshot: snap.id,
snapshot: snap.id, files: JSON.stringify(recorded),
files: JSON.stringify(recorded), size: total,
size: total, version: contentHash(hashed),
}), });
req // 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 ──────────────────────────────────────────── // ── skill: written or uploaded ────────────────────────────────────────────
@@ -1569,10 +1585,22 @@ function editSkill(req, row, { name, description, content }) {
throw err; throw err;
} }
const desc = frontmatterField(text, "description"); 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( sql(
`UPDATE artifacts SET source = ?, snapshot = ?, files = ?, size = ?, description = ?, `UPDATE artifacts SET source = ?, snapshot = ?, files = ?, size = ?, description = ?,
version = ?, source_hash = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?` 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); dropSnapshot(row.snapshot);
} }
@@ -1756,6 +1784,237 @@ async function installSkill(req, row, { replace = false } = {}) {
return { installed: paths.join(", "), paths, replaced: taken.length > 0 }; 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); let execImpl = promisify(require("node:child_process").execFile);
function __setExecImpl(fn) { function __setExecImpl(fn) {
execImpl = fn; execImpl = fn;
@@ -1815,6 +2074,16 @@ async function installPlugin(req, row) {
async function install(req, id, opts = {}) { async function install(req, id, opts = {}) {
const row = load(req, id); const row = load(req, id);
installable(req, row); 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 = const result =
row.kind === "skill" ? await installSkill(req, row, opts) : await installPlugin(req, row); row.kind === "skill" ? await installSkill(req, row, opts) : await installPlugin(req, row);
sql("UPDATE artifacts SET installs = installs + 1 WHERE id = ?").run(row.id); sql("UPDATE artifacts SET installs = installs + 1 WHERE id = ?").run(row.id);
@@ -1947,6 +2216,7 @@ module.exports = {
update, update,
remove, remove,
snapshotFile, snapshotFile,
syncSkillSources,
install, install,
uninstall, uninstall,
installsEnabled, 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 * 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; * the last figure with its `asOf`. Running agy may let it update itself;
* `AGENTHUB_AGY_USAGE=0` never runs it. * `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 * Each tool also carries its signed-in `account` (an email), its `plan` (the
* subscription's name as the tool reports it: Claude's `subscriptionType`, * 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 * Server mode with S3 (`quotas({home})`): a member's plan is read from THEIR
* Linux account's home only — `.claude`, `.codex`, `.config/cursor` + * Linux account's home only — `.claude`, `.codex`, `.config/cursor` +
* `.cursor`, `.grok`, `.kimi-code`, `.local/share/opencode`, never the * `.cursor`, never the host's env overrides — through `run-as`'s
* host's env overrides — through `run-as`'s owner-checked reads (F3). No * owner-checked reads (F3). No saved Claude profiles (the Profiles modal is the host's) and no agy: its
* 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. * `/usage` needs the self-updating binary, so it stays host-only.
* *
* Only percentages, window lengths, reset/expiry times, the plan's name and * 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 { fetchUsage, fetchOwner } = require("./cc-usage");
const { allRolloutFiles } = require("./codex-ingest"); const { allRolloutFiles } = require("./codex-ingest");
const { getCursorDir } = require("./cursor-usage"); const { getCursorDir } = require("./cursor-usage");
const { Database } = require("../db"); const { readFileSafe, openSafe } = require("./run-as");
const { readFileSafe, openSafe, ownerForRead } = require("./run-as");
const { s3Enabled } = require("./linux-accounts");
const CLAUDE_TTL_MS = 5 * 60_000; const CLAUDE_TTL_MS = 5 * 60_000;
const FORCE_MIN_MS = 30_000; // a Refresh click still cannot hammer the endpoint 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 CODEX_FILES = 5;
const CURSOR_USAGE_URL = "https://cursor.com/api/usage-summary"; const CURSOR_USAGE_URL = "https://cursor.com/api/usage-summary";
const TIMEOUT_MS = 10_000; 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_TIMEOUT_MS = 60_000;
const AGY_MIN_VERSION = [1, 1, 11]; const AGY_MIN_VERSION = [1, 1, 11];
const AGY_WINDOW = { "5h": "5h", weekly: "wk" }; const AGY_WINDOW = { "5h": "5h", weekly: "wk" };
@@ -364,9 +343,6 @@ function toolDirs(home) {
codex: home ? path.join(home, ".codex") : getCodexHome(), codex: home ? path.join(home, ".codex") : getCodexHome(),
cursorConfig: path.join(env("XDG_CONFIG_HOME") || path.join(h, ".config"), "cursor"), cursorConfig: path.join(env("XDG_CONFIG_HOME") || path.join(h, ".config"), "cursor"),
cursor: home ? path.join(home, ".cursor") : getCursorDir(), 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 * Every tool's quota row. `home`: a member's Linux home (S3) — that account's
* logins only, and no agy; omitted, the host'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 } = {}) { async function quotas({ force = false, home = null } = {}) {
const dirs = toolDirs(home); const dirs = toolDirs(home);
const key = (k) => (home ? `${home}|${k}` : k); 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), claudeQuotas(force, home),
cached(key("cursor"), force, () => cursorQuota(dirs)), cached(key("cursor"), force, () => cursorQuota(dirs)),
home ? null : cached("antigravity", force, antigravityQuota), 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 = [ const tools = [...claude, codexQuota(dirs.codex), antigravity, cursor].filter(Boolean);
...claude,
codexQuota(dirs.codex),
antigravity,
cursor,
grok,
kimi,
opencode,
].filter(Boolean);
return { tools, checkedAt: new Date().toISOString() }; return { tools, checkedAt: new Date().toISOString() };
} }
+4
View File
@@ -75,6 +75,9 @@ router.get(
"/", "/",
wrap((req, res) => { wrap((req, res) => {
const q = req.query; 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({ res.json({
items: artifacts.list(req, { items: artifacts.list(req, {
scope: q.scope, scope: q.scope,
@@ -358,6 +361,7 @@ router.post(
wrap(async (req, res) => { wrap(async (req, res) => {
const result = await artifacts.install(req, req.params.id, { const result = await artifacts.install(req, req.params.id, {
replace: req.body?.replace === true, replace: req.body?.replace === true,
update: req.body?.update === true,
}); });
res.json(result); 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` * A Grok Build hook. Grok is not an AgentHub tool, but it runs the hooks in
* itself (its Claude compatibility, on by default), so AgentHub's own Claude * `~/.claude/settings.json` itself (its Claude compatibility, on by default),
* hook reaches here with no `tool` — told apart by the camelCase keys only * so AgentHub's own Claude hook still reaches here from a Grok the user
* Grok sends beside Claude's snake_case aliases (`hookEventName`, * installed, with no `tool` — told apart by the camelCase keys only Grok sends
* `workspaceRoot`). Its `transcript_path` is Grok's file, never a Claude * beside Claude's snake_case aliases (`hookEventName`, `workspaceRoot`) and
* transcript, so it is dropped before anything could parse it. * dropped, or it would land as a Claude session whose `transcript_path` is
* Grok's file.
*/ */
const isGrokPayload = (data) => const isGrokPayload = (data) =>
!!data && !!data &&
@@ -1486,18 +1487,9 @@ const isGrokPayload = (data) =>
typeof data.hookEventName === "string" && typeof data.hookEventName === "string" &&
typeof data.workspaceRoot === "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 = { const HOOK_TOOLS = {
codex: codexHookInput, codex: codexHookInput,
antigravity: antigravityHookInput, 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 } }; const ignored = { status: 200, body: { ok: true, ignored: true } };
// Which CLI sent it: `codex` from Codex's hooks (codex-hooks.js), // Which CLI sent it: `codex` from Codex's hooks (codex-hooks.js),
// `antigravity` from agy's (antigravity-hooks.js), `grok` for Grok running // `antigravity` from agy's (antigravity-hooks.js), anything else is Claude
// the Claude hooks (by its payload's own keys), anything else is Claude // Code — an allowlist, never a value stored as sent. Grok running the
// Code — an allowlist, never a value stored as sent. // Claude hooks is no AgentHub tool: dropped.
const tool = Object.hasOwn(HOOK_TOOLS, body.tool) const tool = Object.hasOwn(HOOK_TOOLS, body.tool) ? body.tool : "claude";
? body.tool if (tool === "claude" && isGrokPayload(data)) return ignored;
: isGrokPayload(data)
? "grok"
: "claude";
if (tool !== "claude") { if (tool !== "claude") {
if (!data || typeof data !== "object") return ignored; if (!data || typeof data !== "object") return ignored;
require("../lib/hook-auth").dropForeignPaths(peer, data); require("../lib/hook-auth").dropForeignPaths(peer, data);
+16 -71
View File
@@ -843,25 +843,6 @@ router.get("/:id/transcripts", async (req, res) => {
: []; : [];
return res.json({ transcripts: list }); return res.json({ transcripts: list });
} }
// OpenCode: a subagent is a child session of the store, tied to the agent
// row its poller made.
if (session.tool === "opencode") {
const list = [
{ id: "main", name: "Main Agent", type: "main", has_transcript: true, db_agent_id: null },
];
for (const sub of require("../lib/opencode-usage").subagentSessions(req.params.id)) {
list.push({
id: sub.id,
name: sub.name,
type: "subagent",
subagent_type: "task",
has_transcript: true,
db_agent_id: stmts.getAgent.get(sub.rowId) ? sub.rowId : null,
});
}
return res.json({ transcripts: list });
}
const result = []; const result = [];
// Query database agent list for db_agent_id association // Query database agent list for db_agent_id association
@@ -1101,20 +1082,6 @@ router.get("/:id/transcript", async (req, res) => {
const isCursor = session.tool === "cursor"; const isCursor = session.tool === "cursor";
// An agy conversation's step log (`transcript_full.jsonl`), found by antigravity-usage.js. // An agy conversation's step log (`transcript_full.jsonl`), found by antigravity-usage.js.
const isAgy = session.tool === "antigravity"; 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) { if (isOmp) {
jsonlPath = require("../lib/omp-usage").journalPath(req.params.id, agentId || ""); jsonlPath = require("../lib/omp-usage").journalPath(req.params.id, agentId || "");
} else if (isCursor) { } else if (isCursor) {
@@ -1177,6 +1144,9 @@ router.get("/:id/transcript", async (req, res) => {
: (e, n) => parseMessage(e, n); : (e, n) => parseMessage(e, n);
const parseMessage = createClaudeLineParser(isSubagentFile); 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) { if (afterLine !== null) {
// Incremental mode: skip lines until after afterLine, collect up to limit, then stop. // 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); messages.push(message);
// Sliding window: only keep the last `limit` messages // Sliding window: only keep the last `limit` messages
if (messages.length > limit) { if (messages.length > limit) {
messages.shift(); const dropped = messages.shift();
carry?.drop(dropped);
} }
} }
if (total > limit) hasMore = true; if (total > limit) hasMore = true;
@@ -1283,13 +1254,22 @@ router.get("/:id/transcript", async (req, res) => {
messages.push(message); messages.push(message);
// Sliding window: only keep the last `limit` messages in memory // Sliding window: only keep the last `limit` messages in memory
if (messages.length > limit) { if (messages.length > limit) {
messages.shift(); const dropped = messages.shift();
carry?.drop(dropped);
} }
} }
// If we shifted any messages out, there are more // If we shifted any messages out, there are more
hasMore = total > limit; 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 lastLine = messages.length > 0 ? messages[messages.length - 1].line : 0;
const firstLine = messages.length > 0 ? messages[0].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, has_more: hasMore,
last_line: lastLine, last_line: lastLine,
first_line: firstLine, first_line: firstLine,
...(turnEdits ? { turn_edits: turnEdits } : {}),
}); });
} catch (err) { } catch (err) {
res.json({ messages: [], total: 0, has_more: false, last_line: 0, first_line: 0 }); 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 = router;
module.exports.sessionStats = sessionStats; module.exports.sessionStats = sessionStats;
// Exported for unit tests — sender attribution is correctness-critical. // Exported for unit tests — sender attribution is correctness-critical.