Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c59e196e41 | |||
| 72aef2426c | |||
| 244f999538 | |||
| 127da516ac | |||
| c0832182f2 | |||
| 9ea7758abb | |||
| f51a6a895e | |||
| 5ccbaf5c23 |
@@ -1,13 +1,13 @@
|
||||
# Claude Code Working Guide
|
||||
|
||||
_Last synced: v2.8.23 (2026-10-23) — sidebar workspace groups; a parent folder's inner repos in Changes (opt-in); a session's sub-agents count in its tokens._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||
_Last synced: v2.8.24 (2026-10-26) — OpenCode, Grok Build and the Kimi quota reader left; a shared skill follows its owner's folder (Update when an installed copy differs); a sub-agent's edits count in Edited N files._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||
|
||||
## Project mission
|
||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
||||
- Preserve real-time behavior (hooks -> API -> SQLite -> WebSocket -> UI).
|
||||
|
||||
## Repo map
|
||||
- `server/`: Express API, hook ingestion, SQLite access, websocket broadcast (includes release-based update detection — `lib/update-check.js` reads the repo's own releases API and `routes/updates.js` exposes status/check/apply, refusing to self-update a `~/.claude/plugins/cache/` install, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks — and `lib/codex-ingest.js`, which mirrors a `codex` lane's running/idle from the mtime of Codex's own rollout journals (skipping a lane whose hooks are current), `lib/codex-usage.js`, which reads the same journals into `sessions` (`tool = 'codex'`) and `token_usage`, and `lib/codex-hooks.js`, which installs AgentHub's opt-in hooks into Codex's `hooks.json`, and their Antigravity counterparts `lib/antigravity-usage.js` (agy's conversation files → `tool = 'antigravity'` sessions and tokens, and its step log as their conversation view) and `lib/antigravity-hooks.js` (the named hook `agenthub` in `~/.gemini/config/hooks.json`), and `lib/cursor-usage.js` (Cursor's agent transcripts → `tool = 'cursor'` sessions and their conversation view, no tokens), and `lib/omp-usage.js`, `lib/opencode-usage.js` (omp's journals, OpenCode's `opencode.db` → `tool = 'omp'|'opencode'` sessions, tokens and subagent agents)).
|
||||
- `server/`: Express API, hook ingestion, SQLite access, websocket broadcast (includes release-based update detection — `lib/update-check.js` reads the repo's own releases API and `routes/updates.js` exposes status/check/apply, refusing to self-update a `~/.claude/plugins/cache/` install, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks — and `lib/codex-ingest.js`, which mirrors a `codex` lane's running/idle from the mtime of Codex's own rollout journals (skipping a lane whose hooks are current), `lib/codex-usage.js`, which reads the same journals into `sessions` (`tool = 'codex'`) and `token_usage`, and `lib/codex-hooks.js`, which installs AgentHub's opt-in hooks into Codex's `hooks.json`, and their Antigravity counterparts `lib/antigravity-usage.js` (agy's conversation files → `tool = 'antigravity'` sessions and tokens, and its step log as their conversation view) and `lib/antigravity-hooks.js` (the named hook `agenthub` in `~/.gemini/config/hooks.json`), and `lib/cursor-usage.js` (Cursor's agent transcripts → `tool = 'cursor'` sessions and their conversation view, no tokens), and `lib/omp-usage.js` (omp's journals → `tool = 'omp'` sessions, tokens and subagent agents)).
|
||||
- `client/`: React + Vite UI.
|
||||
- `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`, keyed on the forge's **newest release** rather than a commit count. `POST /api/updates/apply` has two modes and picks by install kind, never from the request: a fast-forwardable **checkout** merges the release tag `--ff-only`, rebuilds, and self-restarts via `server/lib/self-restart.js` — through our own unit when this process runs in one (`systemctl restart --no-block`, launchd's KeepAlive; a detached replacement beside a `Restart=always` unit crash-loops it), else the detached `scripts/restart-helper.js`; a **plugin-cache** install is handed to `server/lib/plugin-update.js` + the detached `scripts/plugin-update-helper.js`, which runs `claude plugin marketplace update` → `claude plugin update -y` → `plugin-bootstrap.js --force` from the NEW cache dir. Both write the HTTP response before anything stops the process, and every plugin-chain outcome is recorded to `plugin-update.json` in the runtime dir — outside the cache, so it survives the swap and the next server reports it as `last_plugin_update`. While it runs, the step it is on is `plugin-update-progress.json` → `plugin_update_progress` (the update modal's step line and the chip it leaves when hidden). Neither mode can run `/reload-plugins`: it is REPL-scoped to the session that invokes it, so an open Claude Code session still reloads by hand for new skills/commands. Elsewhere users run the printed manual command, surfaced in the UI and by `agenthub update-check`. `scripts/cc-update-all-helper.js` is the same detached pattern for the config panel's **Update all** button — marketplaces → every plugin → AgentHub LAST → one reload — journalling each step to `cc-update-all.json` in the runtime dir.)
|
||||
- `skills/`: this repo's skills. Because the repo root **is** the `agenthub` plugin root, this is also the plugin's skills directory — every skill here ships to anyone who installs the plugin, addressed as `/agenthub:<name>` (bare `/<name>` works as an alias). Put a skill here only if it should reach plugin users. The plugin ships no subagents and no pipeline skill, and depends on no other plugin (Superpowers included) — do not add an auto-install of one back into the bootstrap.
|
||||
@@ -48,10 +48,10 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which
|
||||
- **A clone runs as its folder's account with THAT account's own identity, and only the public key ever leaves the server.** `POST /api/lanes/clone` takes a URL only through `validateRemoteUrl` (the `publish` allowlist) and a parent held to the user's own folder like New folder; `server/lib/git-identity.js` runs `git clone` as the folder's account (S3: the member) and writes the identity into the CLONE's config (`core.sshCommand` / `credential.helper=store --file=`), never the user's global config. A member's SSH key and HTTPS store live in their home under `~/.config/agenthub/`, written only through `write-as` (secrets on stdin, never sudo argv/env), the host's under `<CLAUDE_HOME>/agenthub-ssh/` + the shared store below; `/api/me/git` returns only `publicKey`/`hasToken`. The git bar's fetch/pull/push in a member's folder uses that same store (`memberHttpsConfig`; a typed login goes in through `write-as` stdin for the one call), and a store that cannot be read is never rewritten (`ESTOREUNREADABLE`); the private key is only `lstat`ed, never read. The commit author is the ACCOUNT's `git config --global` (`PUT /api/me/git/author`). Do not add a route that returns a private key or a token, or one that writes a git identity outside the clone's own config or the account's own `~/.gitconfig`.
|
||||
- **AgentHub's git credentials are its OWN store, shared by every user of the install, and git is given ONLY that store when it holds a login for the host.** `server/lib/git-credentials.js` keeps one record per `protocol + host` under `<CLAUDE_HOME>/agenthub-git-credentials/` (0700/0600) and no route ever returns a password (`hasToken` only). `gitConfigFor` runs a network action with `-c credential.helper= -c credential.helper=<ours>` whenever AgentHub holds a login for a repo remote's host or the request carries one — the machine's helpers are left out on purpose: a stale token there would win over the typed one, and git's post-success `store` would copy a use-once token into the OS user's helper. The secret travels in the helper's env or the store file, never argv. A typed credential is accepted only for one of the repo's OWN remote hosts (`EBADHOST`), so a login is never sent to a server it was not typed for, and it is saved only after git SUCCEEDED and `remember` is set. Do not add a route that returns a stored password or a path that forwards one to a host other than its own.
|
||||
- **A lane's `tool` is its DEFAULT, immutable; a session's tool is the session's choice — and only an AI Tools catalog id ever comes from a request.** `lanes.tool` (default `claude`) is any id `server/lib/ai-tools.js` knows; `createLane` refuses an unknown one (`EBADTOOL`) and one the user disabled (`EDISABLED` — only CREATION asks, an existing lane on a tool disabled since keeps working), and it is deliberately absent from `PATCHABLE`. A NEW session names its own with `tool` on `POST /api/lanes/:id/start` (the home's **AI** select, sent only when it differs from the lane's): `validateTool` again (`EBADTOOL`/`EDISABLED`, 400), and the id only picks a catalog file — the command, args and env still come from that file. A resume keeps its session's own tool (`sessions.tool`), never the request's. `POST /api/run` still takes the lane's tool. A lane whose tool left the catalog answers `ENOTOOL` at start. The sidebar's tool icon is per SESSION (`WorkspaceSessionList`), not per workspace row. A pane has no run form: `RunSetup` is gone from `LaneConsolePane`, and the workspace home's prompt box (`WorkspaceHome.tsx`: AI and ONE Model chip — `ModelMenu` in `RunSetup.tsx`, the model list plus Effort, a proxy's own models on a proxy — inside the box; AI Proxy, Permission and Local / New worktree in a row under it, like Claude Desktop) is the one place a session starts with a prompt (the session view's tab bar `+`, `ConsoleTabs.tsx`, starts a bare CLI or a plain shell — `shell: true`, nothing from the request but the slot; the home's AI menu has the same **Open terminal**). `claude`/`codex` keep their hand-written argv builders (`launcher` in their catalog file — a `DASHBOARD_AI_TOOLS_DIR` file may NOT claim one); every other tool is started by its detected absolute path as `[path, ...args, ...resume?, ...bypass?, ...prompt]` (`...resumeById, <id>` in place of `resume` for a named session, only when its catalog file declares `resumeById`), every piece but the prompt from its catalog file, the prompt ONE argv element or, for an `after-start` tool, a tmux paste buffer (`tmux.pasteText`) after `delayMs`. Codex-only fields (`sandbox`, `approval`) are validated against the fixed allowlists in `server/lib/pty-run.js`. **Bypass args are emitted ONLY when the run request carries `bypass: true`** — the home's red per-run switch, which starts from the account's own default (Settings → AI tools → **Session defaults**, `client/src/lib/sessionDefaults.ts`: Claude's default mode may be `bypassPermissions`, Codex's bypass may default on — set only behind a confirm, never by the first-run wizard, never for the pet chat; the server never remembers it) — and they come from the tool's catalog file (Codex: `--dangerously-bypass-approvals-and-sandbox`, which then replaces `-s`/`-a`); Claude has none (it has the `bypassPermissions` mode), and `--dangerously-bypass-hook-trust` is never emitted. Every pane gets the dashboard's hydrated `PATH` through tmux `-e`. Only `claude`/`codex` are `proxyCapable`; `resolveProxy` accepts only `native` for any other tool, else `EUNSUPPORTED`. `GET /api/run/binary?tool=` resolves through the catalog — the query string never names a command. Do not add a route that takes a command, args or env for a tool from a request.
|
||||
- **Antigravity hooks are observational only, and never answer a permission.** `server/lib/antigravity-hooks.js` writes only the named hook `agenthub` in `~/.gemini/config/hooks.json`, only on a click, and leaves every other name alone. It installs PreInvocation, PostToolUse and Stop and NEVER PreToolUse: agy reads a PreToolUse answer as a permission decision and DENIES the tool on `{}` (verified on 1.2.13), `allow` would override the user's own permission rules and `ask` would force a prompt — there is no neutral answer. The forwarder must keep printing `{}` and nothing else. The file is global (the Antigravity IDE loads it too), so the route accepts a hook only when its `transcriptPath` lies under `~/.gemini/antigravity-cli/`, and a hook's `tool` is an allowlist (`codex`, `antigravity`, `grok` — also recognised by its payload's camelCase `hookEventName` + `workspaceRoot`, since Grok runs the Claude hooks — else `claude`). AgentHub never reads the IDE's data.
|
||||
- **Codex hooks run only because the user trusted them in Codex, and AgentHub never decides that for them.** `server/lib/codex-hooks.js` writes `$CODEX_HOME/hooks.json` only on the user's click, keeps every entry that is not AgentHub's, and never writes Codex's trust (`[hooks.state]` in `config.toml` — read only, to report); nothing may pass `--dangerously-bypass-hook-trust`. The hook command is the STABLE `'<CODEX_HOME>/agenthub-hook.sh' <Event>` because Codex keys trust on the definition: change what the script runs (`refreshScript`), never the command, the timeout or the event list without expecting every user to re-trust. A hook's `tool` is an allowlist (`codex`, `antigravity`, `grok` — also recognised by its payload's camelCase `hookEventName` + `workspaceRoot`, since Grok runs the Claude hooks — else `claude`), and a Codex hook's `transcript_path` is never stored or parsed as a Claude transcript — only `threadOf` reads it, and only a `rollout-*.jsonl` under `$CODEX_HOME/sessions`.
|
||||
- **Antigravity hooks are observational only, and never answer a permission.** `server/lib/antigravity-hooks.js` writes only the named hook `agenthub` in `~/.gemini/config/hooks.json`, only on a click, and leaves every other name alone. It installs PreInvocation, PostToolUse and Stop and NEVER PreToolUse: agy reads a PreToolUse answer as a permission decision and DENIES the tool on `{}` (verified on 1.2.13), `allow` would override the user's own permission rules and `ask` would force a prompt — there is no neutral answer. The forwarder must keep printing `{}` and nothing else. The file is global (the Antigravity IDE loads it too), so the route accepts a hook only when its `transcriptPath` lies under `~/.gemini/antigravity-cli/`, and a hook's `tool` is an allowlist (`codex`, `antigravity`, else `claude` — a Grok payload, recognised by its camelCase `hookEventName` + `workspaceRoot` since Grok runs the Claude hooks, is dropped). AgentHub never reads the IDE's data.
|
||||
- **Codex hooks run only because the user trusted them in Codex, and AgentHub never decides that for them.** `server/lib/codex-hooks.js` writes `$CODEX_HOME/hooks.json` only on the user's click, keeps every entry that is not AgentHub's, and never writes Codex's trust (`[hooks.state]` in `config.toml` — read only, to report); nothing may pass `--dangerously-bypass-hook-trust`. The hook command is the STABLE `'<CODEX_HOME>/agenthub-hook.sh' <Event>` because Codex keys trust on the definition: change what the script runs (`refreshScript`), never the command, the timeout or the event list without expecting every user to re-trust. A hook's `tool` is an allowlist (`codex`, `antigravity`, else `claude`; a Grok payload is dropped), and a Codex hook's `transcript_path` is never stored or parsed as a Claude transcript — only `threadOf` reads it, and only a `rollout-*.jsonl` under `$CODEX_HOME/sessions`.
|
||||
- **An AI Tools install runs only what a RELEASE catalog file names.** `install`/`update` (like `launcher`) are accepted in built-in files and in the synced copy of the newest release's catalog, never in `DASHBOARD_AI_TOOLS_DIR`, and no route takes a package, URL or argv from a request. Jobs are argv-only (`ai-tool-jobs.js`), stdin closed, one at a time; npm is resolved on the login shell's OWN PATH first (`shellPath()`), because `hydrateShellPath` appends, and the pinned Node's npm would install where the user's PATH never looks; a global prefix the account cannot write (a system-wide Node, root's `/usr/local`) makes the install `--prefix ~/.local` instead, never sudo; an npm update is refused (`ENOTNPM`) for a tool whose binary is under neither `npm prefix -g` nor `~/.local/lib/node_modules` (a standalone Codex, say) — it would install a shadowed second copy. A script URL must be https on the homepage's site and is re-checked after redirects — the one way off that site is the catalog entry's own `install.redirect`, an EXACT https URL (never a site wildcard). Icons are raster only — an SVG served from the dashboard's origin runs script when opened. Do not add an install source that a browser or a custom catalog file can name.
|
||||
- **A tool's config editor opens only what a RELEASE catalog file lists, and only an existing text file.** `config` is honoured in built-in and synced-release catalog files (never `DASHBOARD_AI_TOOLS_DIR`), each path `~/`-relative with no `..`, and no route takes a path from the request — `:index` picks from that list. `server/lib/ai-tool-config.js` refuses a missing file (it never creates one), a non-regular, binary or >1 MB file, and any whose basename — or its symlink target's — matches `CONFIG_SECRET_RE` (auth, credential, token, oauth, key files…; `ESECRET`); a save must match the opened `baseSha`, parse when JSON/JSONC, and back up the old text under `<data dir>/ai-tool-config-backups/` first. Do not add a create, a delete, a request-supplied path, or a catalog entry naming a login file. The guard is by NAME only: a listed settings file may still hold an API key (opencode `options.apiKey`, an MCP `env`), and the unguarded GET returns it whole, like the cc-config reads do — do not describe this route as secret-free.
|
||||
- **A tool's config editor opens only what a RELEASE catalog file lists, and only an existing text file.** `config` is honoured in built-in and synced-release catalog files (never `DASHBOARD_AI_TOOLS_DIR`), each path `~/`-relative with no `..`, and no route takes a path from the request — `:index` picks from that list. `server/lib/ai-tool-config.js` refuses a missing file (it never creates one), a non-regular, binary or >1 MB file, and any whose basename — or its symlink target's — matches `CONFIG_SECRET_RE` (auth, credential, token, oauth, key files…; `ESECRET`); a save must match the opened `baseSha`, parse when JSON/JSONC, and back up the old text under `<data dir>/ai-tool-config-backups/` first. Do not add a create, a delete, a request-supplied path, or a catalog entry naming a login file. The guard is by NAME only: a listed settings file may still hold an API key (a provider `apiKey`, an MCP `env`), and the unguarded GET returns it whole, like the cc-config reads do — do not describe this route as secret-free.
|
||||
- **A lane's card position is a viewer preference; its name is a lane fact.** The sidebar workspace list's drag/Alt+↑/↓ order lives in `localStorage` (`agenthub.workspace.laneOrder`, `client/src/lib/laneOrderStorage.ts`) and writes NOTHING server-side — `GET /api/lanes` and `agenthub lanes` stay in id order, so no `sort_order` column, no reorder route, no websocket message. Its groups (`client/src/lib/workspaceGroups.ts`) are the same kind of viewer arrangement — no lane column, nothing in the CLI — but kept per ACCOUNT as the synced preference `agenthub.workspace.groups` (`/api/me/prefs`, 64 KB), so they outlive the browser; deleting a group only ungroups. Renaming is the opposite: it is `PATCH /api/lanes/:id` with `{title}`, the one lane column a user now types into directly, so `updateLane` normalizes it (`normalizeTitle`: trim, 200-char cap, `null` ⇒ `""`, because clearing is legitimate — every render is `lane.title || lane.cwd`). The rule is the column-wide one, not a special case for the field the UI exposes: `bindable` rejects any non-scalar patch value with `EBADFIELD` (`links` excepted — it is stored as JSON), and the route answers 400 for that AND for the `SQLITE_CONSTRAINT` family, rather than handing better-sqlite3 a datatype throw and reporting 500 on plain bad input.
|
||||
- **Adopted lanes are not destroyable.** A lane with `kind === "adopted"` may never have its worktree reset or removed; "removing" one only drops the dashboard's own record of it. This is enforced in code (`assertDestroyable`'s first check) — do not add a path that bypasses it.
|
||||
- **Removing a lane deletes the lane's RECORD and nothing else — for every kind, managed included.** `POST /api/lanes/:id/remove` stops the run and the stack, clears AgentHub's own runtime bookkeeping under `LANES_ROOT/.state/lane<slot>/`, and deletes the row. It calls neither `removeWorktree` nor `removeLaneData`: the working tree, its branch and the lane's database are the USER's data, and a mis-click on a dashboard row may never cost a branch's unpushed code. Deleting those is the user's own `git worktree remove` / `git branch -d` / db drop. Consequently `remove` has no unpushed-commits force-gate (only `reset` does) and no preflight blocker stops it. Do not add a "also delete the worktree" flag back onto this route. (A session's own `claude --worktree` tree is a different thing with its own guarded route — see the session-worktree rule below; a lane's folder is never it.)
|
||||
@@ -59,7 +59,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which
|
||||
- **The File Explorer (`server/lib/file-explorer.js`, `/api/lanes/:id/fs/*`) reuses the read path's guards and never replaces silently.** Every path is `lanePath` + the PARENT `resolveInLane`-confined with the last segment kept as a name (so a symlink is moved/deleted as a link, never followed); never in or under `.git`, never a secret, never the workspace folder itself. Writes run as the folder's account — a member's only through `write-as` (`move`/`copy` modes; `writeAsAsync` streams an upload to its stdin; a batch upload of many small files is ONE `extract` of a tar the server writes itself — folders and regular files only, names it checked, `tar -k`) — and a move/copy never crosses accounts (`sameOwner`, `EFOREIGN`). A taken name is settled by `conflict` (`fail` → 409 with `names`, `skip`, `keep`, `replace` = the old one to the trash first). Delete is a move into the account's trash (member: `~/.local/share/agenthub/trash/`, host: `<data dir>/trash/<user id>/`), restorable 7 days. `write-as` is root-installed: a change to it needs `agenthub server install-system` again (`EUPGRADE` until then). A user's HOME is browsed through the same module inside `inHome` (`/api/homes/:who/fs/*`, `server/lib/homes.js` picks the folder): every dot entry at the TOP of a home stays hidden and refused — through a symlink, a download, a zip or as a copy source too — and another user's home is an admin's READ-ONLY, audited view (`EREADONLY`) — except its TRASH, whose restore/purge an admin may run (`TRASH_W` in `server/routes/homes.js`, the owner's trash via `trashKey`, audited with the item id); keep that exception to the trash routes only. A home's Trash is its USER's one recycle bin (`listUserTrash`: home-account store + host store `<data dir>/trash/<user id>/`, items tagged `store`). Do not add a delete that skips the trash, an overwrite without `conflict`, or a request path that is not run through `locate`/`folder`.
|
||||
- **The whole-file editor and the Files tab's upload are the two non-git writes on the lane file surface, and both live outside `git-ops.js` on purpose.** `git-ops.js` only touches paths `git status` reports as changed; `lane-file-write.js` may save any existing text file, so it must reuse the READ path's guards (`lanePath`, `resolveInLane`, `isSecretOnDisk`) and never be weaker than them, and it saves only over the `baseSha` it was opened at — do not let it create files or touch a binary. `lane-file-transfer.js`'s upload (`POST /api/lanes/:id/git/upload`, issue #78) is the opposite and only that: it CREATES one file (any bytes, ≤ 25 MB) under the same guards, into an existing folder, refuses a taken name (`EEXISTS`, a symlink included) and anything into or named `.git` (a hook there runs on the dashboard's next git call), and writes as the folder's member through `write-as` under S3 — do not let it overwrite, make folders or take a path from anything but `dir` + one plain name. Its `GET …/git/download` twin streams any file through `openSafe` as a `sandbox`ed attachment. The tree, expand-all and quick-open read the disk (`walkFolder`) for a folder that is not a repo.
|
||||
- **Server tests never touch the user's tmux.** Run them through `npm run test:server` (`scripts/test-server.js`), never a bare `node --test server/__tests__/*.test.js` from inside a lane pane — the suites create and kill real `agenthub-lane-<id>` sessions.
|
||||
- **An artifact is shared as a SNAPSHOT and installed only into the viewer's own account.** `server/lib/artifacts.js` (`/api/artifacts`): a pinned file or a skill is copied once into `<data dir>/artifacts/<random>/` and every viewer reads only that copy (an owner's edit of a skill writes a NEW snapshot and drops the old one — never a write into the one being read) — `raw` serves only a file the snapshot recorded, under a `sandbox` CSP + `nosniff`; a `page` is a `https://claude.ai/…` link only. A developer sees their own rows plus shared ones; only the owner or an admin changes one. Install writes a skill into BOTH `~/.claude/skills/` and the cross-tool `~/.agents/skills/` of every account the CALLER's sessions run as (`installAccounts`: their S3 member home, plus the host's for an admin, whose host-folder workspaces run as the host; S3: as them through `write-as`), never over an existing one without `replace` (the old one is moved to `~/.claude/agenthub-replaced-skills/`, never deleted), and runs a plugin's `claude plugin marketplace add`/`install --scope user` as the caller, never with `-y`; Uninstall (`POST /api/artifacts/:id/uninstall`) takes it out of the same accounts — a skill MOVED to `agenthub-replaced-skills/`, never deleted, a plugin `claude plugin uninstall --scope user --keep-data`; an admin's switch (Settings → AI tools, `ArtifactsHubSettings`) turns installs off. An uploaded or written skill is snapshotted under the same checks (paths relative, no `..`, no secret names, zip sizes read before inflating). `POST /api/artifacts/plugin/inspect` clones a user-named https link only to read `marketplace.json`, with every credential helper OFF — never send a login with it. Do not add a route that serves a live file of another user, installs into someone else's account, or takes a plugin command from the request.
|
||||
- **An artifact is shared as a SNAPSHOT and installed only into the viewer's own account.** `server/lib/artifacts.js` (`/api/artifacts`): a pinned file or a skill is copied once into `<data dir>/artifacts/<random>/` and every viewer reads only that copy (an owner's edit of a skill — in the Hub, or in the skill's source folder, which the server takes when the Hub is listed and an installer's differing copy is then offered **Update** — writes a NEW snapshot and drops the old one — never a write into the one being read; the newer of the two changes wins) — `raw` serves only a file the snapshot recorded, under a `sandbox` CSP + `nosniff`; a `page` is a `https://claude.ai/…` link only. A developer sees their own rows plus shared ones; only the owner or an admin changes one. Install writes a skill into BOTH `~/.claude/skills/` and the cross-tool `~/.agents/skills/` of every account the CALLER's sessions run as (`installAccounts`: their S3 member home, plus the host's for an admin, whose host-folder workspaces run as the host; S3: as them through `write-as`), never over an existing one without `replace` (the old one is moved to `~/.claude/agenthub-replaced-skills/`, never deleted), and runs a plugin's `claude plugin marketplace add`/`install --scope user` as the caller, never with `-y`; Uninstall (`POST /api/artifacts/:id/uninstall`) takes it out of the same accounts — a skill MOVED to `agenthub-replaced-skills/`, never deleted, a plugin `claude plugin uninstall --scope user --keep-data`; an admin's switch (Settings → AI tools, `ArtifactsHubSettings`) turns installs off. An uploaded or written skill is snapshotted under the same checks (paths relative, no `..`, no secret names, zip sizes read before inflating). `POST /api/artifacts/plugin/inspect` clones a user-named https link only to read `marketplace.json`, with every credential helper OFF — never send a login with it. Do not add a route that serves a live file of another user, installs into someone else's account, or takes a plugin command from the request.
|
||||
- **A workspace's instructions, extra folders and context files are lane facts, and a pane reads them only from the lane's rows.** The one addition is the workspace OWNER's own agent instructions (`user_prefs.instructions`, `PUT /api/me/instructions`, Settings → AI tools), which `launchExtras` puts first — keyed by `lanes.owner_id` (`local` in local mode), never by whoever clicked Start. `lanes.instructions` and `lane_sources` (`server/lib/lane-sources.js`) reach a pane through `launchExtras(laneId, tool)`, which `spawnRun` calls ITSELF — so neither spawn path can take them from a request — and every write is same-origin (`server/routes/lane-sources.js`). A folder must be absolute, exist, be a directory, not be or contain the OS home (`validateHomeDir`) and not be the workspace's own folder; it is the user's, and AgentHub never deletes it. An uploaded file is stored under `<data dir>/workspace-context/<lane id>/` under a name AgentHub derives (never a path from the request), and those copies are the only thing here AgentHub deletes (`ownsFile`): with their row, and with the workspace in `deleteLane`. Claude's `--add-dir` is VARIADIC, so it goes first in the argv where `--permission-mode` ends it, and the text (instructions + context file list + extra folders) is written at every spawn as the ONLY file `CLAUDE.md` of `<data dir>/workspace-prompts/<lane id>/` (a member's: `prompt-<lane id>/` in their handoff dir, via `write-as`), which goes in as one more `--add-dir` with `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1` in the pane env — never `--append-system-prompt-file`: a custom system prompt is a launch flag Claude cannot carry over a restart, so it refused `/tui` in every pane with instructions (side effect: a CLAUDE.md in an extra folder is now read too); Codex gets one `--add-dir` per directory and `-c developer_instructions=<JSON string>` — the text itself up to 6 KB, else a line pointing at that file. Only `claude`/`codex` take them. Never write them into the user's repo (CLAUDE.md/AGENTS.md).
|
||||
- **A workspace's sessions are the ones it OWNS, never the ones that ran in its folder.** `sessions.lane_id` is stamped by `bindRunSession` (`server/routes/hooks.js`) for the session a dashboard pane holds — first writer wins, a nested `claude -p` excluded — and no route sets it. A Codex/Antigravity session (their hooks are opt-in, so a pane may never learn its session) is also stamped by `claimToolSessions` (`server/lib/dashboard-runs.js`, called by both pollers and once at start): the lane of the newest same-tool dashboard run in the session's folder that was live when the session started, never a Claude session. A new Claude pane is started with `--session-id <uuid>` (`spawnRun`), so its `dashboard_runs.session_id` is known before any hook and a hook that carries no `run_id` (a hook handler older than `AGENTHUB_RUN_ID` forwarding) is still stamped from that run (`laneForSession`). Every workspace session list reads `GET /api/sessions?lane=<id>` — which also counts a still-unstamped session one of the lane's `dashboard_runs` holds, because hooks reach only the lowest-port dashboard on a shared DB and that one may be an older build that never stamps — and shows nothing else (no "other sessions in this folder"). Do not go back to listing a workspace by `cwd`, and do not guess ownership for old history.
|
||||
- **A session's worktree is `claude --worktree`'s, resolved from the session row — and removing it never forces.** A Worktree start passes `--worktree=<server-made name>` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `<repo>/.claude/worktrees/<name>`; AgentHub only excludes `/.claude/worktrees/` at the repo root. A git route, a resume or a removal may work in a session's worktree only through `sessionWorktree` (`server/lib/session-worktree.js`: the session owned by the lane, its cwd not the lane's folder, registered by `git worktree list`) — `?session=` on the git routes answers `ENOWORKTREE` rather than falling back to the main folder. `POST /api/lanes/:id/session-worktree/remove` (same-origin, `confirm: true`, refused while a live pane holds the session) is the ONE dashboard path that deletes a worktree: `git worktree remove` with no `--force`, `git branch -d` never `-D`, a live-pid lock refused. Do not add a force, and do not let it touch a lane's own folder.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 411 KiB After Width: | Height: | Size: 465 KiB |
@@ -49,7 +49,7 @@
|
||||
<g transform="translate(20,22)"><rect width="48" height="48" rx="12" fill="#4cc3f5" fill-opacity="0.12"/><rect x="11" y="11" width="11" height="11" rx="3" fill="#4cc3f5"/><rect x="26" y="11" width="11" height="11" rx="3" fill="none" stroke="#4cc3f5" stroke-width="2.5"/><rect x="11" y="26" width="11" height="11" rx="3" fill="none" stroke="#4cc3f5" stroke-width="2.5"/><rect x="26" y="26" width="11" height="11" rx="3" fill="#4cc3f5"/></g>
|
||||
<text x="84" y="44" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="21" font-weight="700" fill="#f1f5ff">Every AI coding agent</text>
|
||||
<text x="84" y="72" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="16" fill="#9aa6c4">Claude Code, Codex, Antigravity,</text>
|
||||
<text x="84" y="94" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="16" fill="#9aa6c4">Cursor, Kimi, Grok and more</text>
|
||||
<text x="84" y="94" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="16" fill="#9aa6c4">Cursor, omp and more</text>
|
||||
</g>
|
||||
<g transform="translate(792,300)">
|
||||
<rect width="344" height="116" rx="16" fill="#ffffff" fill-opacity="0.045" stroke="#ffffff" stroke-opacity="0.10"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
@@ -50,9 +50,6 @@ const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = {
|
||||
codex: "Codex",
|
||||
antigravity: "Antigravity",
|
||||
cursor: "Cursor",
|
||||
grok: "Grok",
|
||||
kimi: "Kimi",
|
||||
opencode: "OpenCode Go",
|
||||
};
|
||||
|
||||
function localDate(d = new Date()): string {
|
||||
|
||||
@@ -78,7 +78,12 @@ import { useSpin } from "../../hooks/useSpin";
|
||||
import { useOnScreen, isShown } from "../../hooks/useOnScreen";
|
||||
import { throttle } from "../../lib/throttle";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
|
||||
import type {
|
||||
TranscriptMessage,
|
||||
TranscriptInfo,
|
||||
TranscriptResult,
|
||||
WSMessage,
|
||||
} from "../../lib/types";
|
||||
|
||||
// Catch-up poll interval. Claude Code only fires hooks on PreToolUse /
|
||||
// PostToolUse / Stop, which means a user-typed message (no hook) and any
|
||||
@@ -175,6 +180,8 @@ function ConversationViewPane({
|
||||
initialTranscriptId ?? null
|
||||
);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
// The edits of the turn the oldest loaded page starts inside of (`turn_edits`).
|
||||
const [leadEdits, setLeadEdits] = useState<TranscriptResult["turn_edits"]>(undefined);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [transcripts, setTranscripts] = useState<TranscriptInfo[]>([]);
|
||||
const [showNewMsg, setShowNewMsg] = useState(false);
|
||||
@@ -283,6 +290,7 @@ function ConversationViewPane({
|
||||
if (cancelled) return;
|
||||
setNotStarted(false);
|
||||
setMessages(result.messages);
|
||||
setLeadEdits(result.turn_edits);
|
||||
setTotal(result.total);
|
||||
setHasMore(result.has_more);
|
||||
lastLineRef.current = result.last_line;
|
||||
@@ -343,6 +351,7 @@ function ConversationViewPane({
|
||||
// Seed the view in a single render so the user sees the whole
|
||||
// catch-up batch instead of a blank panel followed by a partial one.
|
||||
setMessages(result.messages);
|
||||
setLeadEdits(result.turn_edits);
|
||||
firstLineRef.current = result.first_line;
|
||||
setHasMore(result.has_more);
|
||||
} else {
|
||||
@@ -487,6 +496,7 @@ function ConversationViewPane({
|
||||
firstLineRef.current = result.first_line;
|
||||
|
||||
setMessages((prev) => [...result.messages, ...prev]);
|
||||
setLeadEdits(result.turn_edits);
|
||||
setHasMore(result.has_more);
|
||||
|
||||
// Preserve scroll position (don't jump to top)
|
||||
@@ -686,6 +696,7 @@ function ConversationViewPane({
|
||||
stopped={stopped}
|
||||
agentLinks={agentLinks}
|
||||
compact={compact}
|
||||
leadEdits={leadEdits}
|
||||
sessionId={
|
||||
selectedTranscript && selectedTranscript !== "main" ? undefined : sessionId
|
||||
}
|
||||
|
||||
@@ -172,6 +172,9 @@ interface MessageListProps {
|
||||
/** A subagent's conversation inside its step: tight spacing, and the task
|
||||
* the orchestrator gave it reads as the prompt it is. */
|
||||
compact?: boolean;
|
||||
/** The files the first, cut-off turn changed before `messages` begins (the
|
||||
* transcript page's `turn_edits`): counted in that turn's card. */
|
||||
leadEdits?: TranscriptContent["edits"];
|
||||
}
|
||||
|
||||
/** Build a map from tool_use id → tool_result for matching */
|
||||
@@ -491,6 +494,10 @@ type Item =
|
||||
| { kind: "command"; key: string; name: string | null; output: string | null; error: boolean };
|
||||
|
||||
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
||||
// A subagent's result carries the files IT changed (`edits`): they count too.
|
||||
const AGENT_TOOLS = new Set(["Agent", "Task"]);
|
||||
// The stand-in for the part of a turn the page cut off: its `edits` count.
|
||||
const LEAD = "__lead_edits";
|
||||
|
||||
/** A line that is only Claude Code's local-command plumbing — the caveat it
|
||||
* writes first, the command, what the command printed — or null for
|
||||
@@ -515,11 +522,18 @@ export function localCommand(
|
||||
return { name, output, error };
|
||||
}
|
||||
|
||||
export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[] {
|
||||
export function chatItems(
|
||||
messages: TranscriptMessage[],
|
||||
keys: string[],
|
||||
leadEdits?: TranscriptContent["edits"]
|
||||
): Item[] {
|
||||
const items: Item[] = [];
|
||||
// The file-changing calls of the turn so far, closed into one card when the
|
||||
// human speaks again (or the list ends).
|
||||
let edits: TranscriptContent[] = [];
|
||||
// human speaks again (or the list ends) — starting with what the page's
|
||||
// cut-off first turn changed before it.
|
||||
let edits: TranscriptContent[] = leadEdits?.length
|
||||
? [{ type: "tool_use", name: LEAD, edits: leadEdits }]
|
||||
: [];
|
||||
// The turn's prompt time, its newest assistant line and its output so far,
|
||||
// for the "Done in …" line under a turn that ended.
|
||||
let start: string | null = null;
|
||||
@@ -592,7 +606,11 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
|
||||
if (msg.usage) output.add(msg.usage);
|
||||
msg.content.forEach((block, b) => {
|
||||
const last = items[items.length - 1];
|
||||
if (block.type === "tool_use" && EDIT_TOOLS.has(block.name ?? "")) edits.push(block);
|
||||
if (
|
||||
block.type === "tool_use" &&
|
||||
(EDIT_TOOLS.has(block.name ?? "") || AGENT_TOOLS.has(block.name ?? ""))
|
||||
)
|
||||
edits.push(block);
|
||||
// A question to the user is a turn of the conversation, not hidden work.
|
||||
if (block.type === "tool_use" && block.name === "AskUserQuestion") {
|
||||
items.push({ kind: "ask", key: `${key}:${b}`, block });
|
||||
@@ -672,19 +690,38 @@ function EditedFilesCard({
|
||||
const files = new Map<string, { added: number; removed: number; counted: boolean }>();
|
||||
// Written whole in this turn (a Write): a component made here is an artifact.
|
||||
const created = new Set<string>();
|
||||
// Changed only by a subagent: listed, never an artifact card — the chat
|
||||
// rebuilds an artifact from this transcript's Write/Edit inputs, which a
|
||||
// subagent's are not.
|
||||
const byAgent = new Set<string>();
|
||||
for (const b of blocks) {
|
||||
const r0 = b.id ? toolResultMap.get(b.id) : undefined;
|
||||
if (AGENT_TOOLS.has(b.name ?? "") || b.name === LEAD) {
|
||||
for (const e of (b.name === LEAD ? b.edits : r0?.edits) ?? []) {
|
||||
if (!files.has(e.path)) byAgent.add(e.path);
|
||||
const f = files.get(e.path) ?? { added: 0, removed: 0, counted: false };
|
||||
f.added += e.added;
|
||||
f.removed += e.removed;
|
||||
f.counted ||= e.counted;
|
||||
files.set(e.path, f);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const input = (b.input ?? {}) as Record<string, unknown>;
|
||||
const p = input.file_path ?? input.notebook_path;
|
||||
const r = b.id ? toolResultMap.get(b.id) : undefined;
|
||||
if (typeof p !== "string" || !r || r.is_error) continue;
|
||||
if (b.name === "Write") created.add(p);
|
||||
byAgent.delete(p);
|
||||
const f = files.get(p) ?? { added: 0, removed: 0, counted: false };
|
||||
f.added += r.diff?.added ?? 0;
|
||||
f.removed += r.diff?.removed ?? 0;
|
||||
f.counted ||= !!r.diff;
|
||||
files.set(p, f);
|
||||
}
|
||||
const artifacts = [...files.keys()].filter((p) => isArtifact(p, created.has(p)));
|
||||
const artifacts = [...files.keys()].filter(
|
||||
(p) => !byAgent.has(p) && isArtifact(p, created.has(p))
|
||||
);
|
||||
const cards = artifacts.length > 0 && (
|
||||
<div data-testid="artifact-cards" className="flex flex-col gap-2">
|
||||
{artifacts.map((p) => (
|
||||
@@ -773,6 +810,7 @@ function MessageListView({
|
||||
sessionId,
|
||||
agentLinks,
|
||||
compact = false,
|
||||
leadEdits,
|
||||
}: MessageListProps) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
|
||||
@@ -798,9 +836,10 @@ function MessageListView({
|
||||
m.sender === "orchestrator" ? { ...m, sender: "user" as const } : m
|
||||
)
|
||||
: messages,
|
||||
keys
|
||||
keys,
|
||||
leadEdits
|
||||
),
|
||||
[messages, keys, compact]
|
||||
[messages, keys, compact, leadEdits]
|
||||
);
|
||||
const agents = useMemo(
|
||||
() => (live ? backgroundAgents(messages, toolResultMap) : []),
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
* Desktop's "Edited N files" card: one row per file with the lines added and
|
||||
* removed summed over the turn, a failed call left out, a row click handed
|
||||
* to the caller, and a row (or the header, every file) dragged as `@path`
|
||||
* attachments for a prompt box.
|
||||
* attachments for a prompt box. Files a subagent changed (its Agent
|
||||
* result's `edits`, read from its own transcript) are counted in the same
|
||||
* card, never as an artifact; so are the files a turn changed before the
|
||||
* loaded page began (`leadEdits`, the page's `turn_edits`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
@@ -127,4 +130,71 @@ describe("the Edited N files card", () => {
|
||||
JSON.parse(all["application/x-agenthub-attach"]!).map((a: { path: string }) => a.path)
|
||||
).toEqual(["@/w/a.ts", "@/w/b.json"]);
|
||||
});
|
||||
|
||||
it("counts the files a subagent changed, beside the turn's own edits", () => {
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
messages={[
|
||||
user("run the task"),
|
||||
call("1", "Edit", "/w/a.go"),
|
||||
result("1", { added: 1, removed: 0 }),
|
||||
{
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
timestamp: at,
|
||||
content: [{ type: "tool_use", id: "2", name: "Agent", input: { prompt: "do it" } }],
|
||||
} as TranscriptMessage,
|
||||
{
|
||||
type: "user",
|
||||
sender: "tool",
|
||||
timestamp: at,
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
id: "2",
|
||||
output: "done",
|
||||
agent: { id: "x1" },
|
||||
edits: [
|
||||
{ path: "/w/a.go", added: 4, removed: 2, counted: true },
|
||||
{ path: "/w/b_test.go", added: 9, removed: 0, counted: true },
|
||||
{ path: "/w/report.html", added: 5, removed: 0, counted: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
} as TranscriptMessage,
|
||||
user("thanks"),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const card = screen.getByTestId("edited-files");
|
||||
expect(card.textContent).toContain("Edited 3 files");
|
||||
expect(within(card).getByText("b_test.go")).toBeTruthy();
|
||||
expect(within(card).getByTitle("/w/a.go").textContent).toContain("+5"); // a.go: 1 + 4
|
||||
// A page only a subagent wrote is listed, not shown as an artifact card.
|
||||
expect(within(card).getByText("report.html")).toBeTruthy();
|
||||
expect(screen.queryByTestId("artifact-card")).toBeNull();
|
||||
});
|
||||
|
||||
it("adds what the cut-off first turn changed before the page to that turn's card", () => {
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
leadEdits={[{ path: "/w/rbac_test.go", added: 176, removed: 0, counted: true }]}
|
||||
messages={[
|
||||
call("1", "Write", "/w/handler_test.go"),
|
||||
result("1", { added: 113, removed: 0 }),
|
||||
user("what changed?"),
|
||||
call("2", "Edit", "/w/x.go"),
|
||||
result("2", { added: 1, removed: 0 }),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const [first, second] = screen.getAllByTestId("edited-files");
|
||||
expect(first!.textContent).toContain("Edited 2 files");
|
||||
expect(within(first!).getByText("rbac_test.go")).toBeTruthy();
|
||||
expect(within(first!).getByText("handler_test.go")).toBeTruthy();
|
||||
// The next turn's card is its own.
|
||||
expect(second!.textContent).toContain("Edited 1 file");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,7 +69,7 @@ const TOOLS: AiToolsView = {
|
||||
tool("codex", "Codex", { launcher: "codex", proxyCapable: true, prompt: null }),
|
||||
tool("gemini", "Gemini"),
|
||||
tool("aider", "Aider", { installed: false, path: null }),
|
||||
tool("grok", "Grok", { enabled: false }),
|
||||
tool("goose", "Goose", { enabled: false }),
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ const VIEW: AiToolsView = {
|
||||
tools: [
|
||||
tool("claude", "Claude Code"),
|
||||
tool("gemini", "Gemini"),
|
||||
tool("grok", "Grok", { enabled: false }),
|
||||
tool("goose", "Goose", { enabled: false }),
|
||||
tool("aider", "Aider", { installed: false, path: null }),
|
||||
],
|
||||
};
|
||||
@@ -155,9 +155,9 @@ describe("AiToolsSettings", () => {
|
||||
await user.click(within(gemini).getByText("Set default"));
|
||||
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { default: true });
|
||||
// A disabled tool cannot become the default.
|
||||
const grok = screen.getByTestId("ai-tool-grok");
|
||||
expect(within(grok).getByText("Set default")).toBeDisabled();
|
||||
expect(within(grok).getByRole("switch")).toHaveAttribute("aria-checked", "false");
|
||||
const goose = screen.getByTestId("ai-tool-goose");
|
||||
expect(within(goose).getByText("Set default")).toBeDisabled();
|
||||
expect(within(goose).getByRole("switch")).toHaveAttribute("aria-checked", "false");
|
||||
});
|
||||
|
||||
it("refreshes detection", async () => {
|
||||
@@ -179,7 +179,7 @@ describe("AiToolsSettings", () => {
|
||||
await user.click(screen.getByRole("menuitem", { name: "Disable" }));
|
||||
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { enabled: false });
|
||||
// A disabled tool cannot become the default from the menu either.
|
||||
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("ai-tool-grok") });
|
||||
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("ai-tool-goose") });
|
||||
expect(screen.getByRole("menuitem", { name: "Set default" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -301,11 +301,9 @@ describe("AiToolsSettings — install and update", () => {
|
||||
describe("AiToolIcon", () => {
|
||||
it("retries the icon when the same instance shows another tool", async () => {
|
||||
const { AiToolIcon } = await import("../../AiToolIcon");
|
||||
const { container, rerender } = render(
|
||||
<AiToolIcon tool={{ id: "opencode", label: "OpenCode" }} />
|
||||
);
|
||||
const { container, rerender } = render(<AiToolIcon tool={{ id: "aider", label: "Aider" }} />);
|
||||
fireEvent.error(container.querySelector("img")!);
|
||||
expect(screen.getByText("O")).toBeInTheDocument();
|
||||
expect(screen.getByText("A")).toBeInTheDocument();
|
||||
rerender(<AiToolIcon tool={{ id: "cursor", label: "Cursor CLI" }} />);
|
||||
expect(container.querySelector('img[src="/api/ai-tools/cursor/icon"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -34,7 +34,6 @@
|
||||
"download": "Download",
|
||||
"noPreview": "This file type cannot be shown here — download it to open it.",
|
||||
"install": "Install",
|
||||
"installs": "{{count}} installs",
|
||||
"installed": "Installed to {{path}}",
|
||||
"installedPlugin": "Installed {{name}} — start a new session (or /reload-plugins) to use it",
|
||||
"rename": "Edit",
|
||||
@@ -197,5 +196,32 @@
|
||||
"unowned.claimed": "Added to your library",
|
||||
"unowned.declined": "Removed from your list",
|
||||
"unowned.assigned": "Owner assigned",
|
||||
"unowned.empty": "Nothing without an owner."
|
||||
"unowned.empty": "Nothing without an owner.",
|
||||
"installs_one": "{{count}} install",
|
||||
"installs_other": "{{count}} installs",
|
||||
"private": "Private",
|
||||
"privateHint": "Not shared: only you (and admins) see it. Share puts it in the Hub.",
|
||||
"showMore": "Show {{count}} more",
|
||||
"view.label": "Layout",
|
||||
"view.list": "List view",
|
||||
"view.grid": "Grid view",
|
||||
"pack.title": "{{prefix}}-* pack",
|
||||
"pack.count_one": "{{count}} skill",
|
||||
"pack.count_other": "{{count}} skills",
|
||||
"pack.installAll": "Install all",
|
||||
"pack.installRest": "Install {{count}} more",
|
||||
"pack.installTitle": "Install {{count}} skills of {{prefix}}-*?",
|
||||
"pack.installBody": "Each one is written into your own account. A skill you already have under the same name is left as it is:",
|
||||
"pack.installed": "Installed {{ok}} · already there {{skipped}} · failed {{failed}}",
|
||||
"section.unshared": "Not shared by their owners",
|
||||
"section.unsharedHint": "Other users' private items — only admins see these",
|
||||
"update": "Update",
|
||||
"updateHint": "Its owner changed it since you installed it",
|
||||
"updateTitle": "Update “{{title}}”?",
|
||||
"updateBody": "Your copy of {{name}} is replaced by the newest version. The current one is moved to ~/.claude/agenthub-replaced-skills/, not deleted.",
|
||||
"updated": "Updated {{name}}",
|
||||
"pack.updateN": "Update {{count}}",
|
||||
"pack.updateTitle": "Update {{count}} skills of {{prefix}}-*?",
|
||||
"pack.updateBody": "Each copy is replaced by the newest version; the current ones are moved to ~/.claude/agenthub-replaced-skills/:",
|
||||
"pack.updated": "Updated {{ok}} · failed {{failed}}"
|
||||
}
|
||||
|
||||
@@ -197,5 +197,29 @@
|
||||
"unowned.claimed": "Đã nhận về thư viện của bạn",
|
||||
"unowned.declined": "Đã bỏ khỏi danh sách của bạn",
|
||||
"unowned.assigned": "Đã gán chủ",
|
||||
"unowned.empty": "Không có mục nào chưa có chủ."
|
||||
"unowned.empty": "Không có mục nào chưa có chủ.",
|
||||
"private": "Riêng tư",
|
||||
"privateHint": "Chưa chia sẻ: chỉ bạn (và admin) thấy. Bấm Chia sẻ để đưa lên Hub.",
|
||||
"showMore": "Xem thêm {{count}}",
|
||||
"view.label": "Cách hiển thị",
|
||||
"view.list": "Dạng danh sách",
|
||||
"view.grid": "Dạng lưới",
|
||||
"pack.title": "Bộ {{prefix}}-*",
|
||||
"pack.count": "{{count}} skill",
|
||||
"pack.installAll": "Cài tất cả",
|
||||
"pack.installRest": "Cài {{count}} còn lại",
|
||||
"pack.installTitle": "Cài {{count}} skill của bộ {{prefix}}-*?",
|
||||
"pack.installBody": "Mỗi skill được ghi vào tài khoản của bạn. Skill nào bạn đã có cùng tên thì giữ nguyên:",
|
||||
"pack.installed": "Đã cài {{ok}} · đã có sẵn {{skipped}} · lỗi {{failed}}",
|
||||
"section.unshared": "Chủ chưa chia sẻ",
|
||||
"section.unsharedHint": "Mục riêng tư của người khác — chỉ admin thấy",
|
||||
"update": "Cập nhật",
|
||||
"updateHint": "Người tạo đã sửa skill này từ khi bạn cài",
|
||||
"updateTitle": "Cập nhật “{{title}}”?",
|
||||
"updateBody": "Bản {{name}} của bạn sẽ được thay bằng phiên bản mới nhất. Bản hiện tại được chuyển sang ~/.claude/agenthub-replaced-skills/, không bị xóa.",
|
||||
"updated": "Đã cập nhật {{name}}",
|
||||
"pack.updateN": "Cập nhật {{count}}",
|
||||
"pack.updateTitle": "Cập nhật {{count}} skill của bộ {{prefix}}-*?",
|
||||
"pack.updateBody": "Mỗi bản sẽ được thay bằng phiên bản mới nhất; bản hiện tại được chuyển sang ~/.claude/agenthub-replaced-skills/:",
|
||||
"pack.updated": "Đã cập nhật {{ok}} · lỗi {{failed}}"
|
||||
}
|
||||
|
||||
@@ -3143,6 +3143,13 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ replace }),
|
||||
}),
|
||||
/** POST /api/artifacts/:id/install `{update: true}` — an installed skill's
|
||||
* copies replaced by its newest version (the old ones moved aside). */
|
||||
updateInstalled: (id: number) =>
|
||||
request<{ installed: string; replaced?: boolean }>(`/artifacts/${id}/install`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ update: true }),
|
||||
}),
|
||||
/** POST /api/artifacts/:id/uninstall — a skill moved aside, a plugin `claude plugin uninstall`. */
|
||||
uninstall: (id: number) =>
|
||||
request<{ uninstalled: string; paths: string[] }>(`/artifacts/${id}/uninstall`, {
|
||||
@@ -4423,6 +4430,9 @@ export interface Artifact {
|
||||
inLibrary: boolean;
|
||||
/** List rows only: a skill/plugin already in every account the viewer's sessions run as. */
|
||||
installed?: boolean;
|
||||
/** List rows only: an installed skill whose copy differs from its newest
|
||||
* version (its owner changed it since) — the list offers Update. */
|
||||
updateAvailable?: boolean;
|
||||
/** file: captured when its session stopped, not pinned by hand. */
|
||||
auto?: boolean;
|
||||
/** Server mode: nobody owns it yet. */
|
||||
|
||||
+12
-5
@@ -732,10 +732,9 @@ export interface Session {
|
||||
/** The AI coding CLI that produced the session: `"claude"` (the default —
|
||||
* Claude Code's hooks and imported transcripts), `"codex"` (Codex's rollout
|
||||
* journals and hooks), `"antigravity"` (agy's conversation files and
|
||||
* hooks), `"cursor"` (Cursor's agent transcripts, no tokens), `"omp"`,
|
||||
* `"opencode"` (their own session stores, with tokens and subagents) or
|
||||
* `"grok"` (Grok running the Claude hooks, no tokens). Maps to
|
||||
* `sessions.tool`. */
|
||||
* hooks), `"cursor"` (Cursor's agent transcripts, no tokens) or `"omp"`
|
||||
* (its own session journals, with tokens and subagents). Rows an older build
|
||||
* wrote may still say `"opencode"` or `"grok"`. Maps to `sessions.tool`. */
|
||||
tool?: string;
|
||||
/** The workspace (lane id) that owns the session — the one whose pane held
|
||||
* it; null for a session no workspace started.
|
||||
@@ -1217,7 +1216,7 @@ export interface CostResult {
|
||||
|
||||
/** GET /api/pricing/quota: plan quota spent per native tool (server/lib/quota.js). */
|
||||
export interface ToolQuota {
|
||||
id: "claude" | "codex" | "antigravity" | "cursor" | "grok" | "kimi" | "opencode";
|
||||
id: "claude" | "codex" | "antigravity" | "cursor";
|
||||
/** One per plan window: `5h`, `wk`, `mo`, …; `usedPercent` is 0–100.
|
||||
* `group` names the model pool a window belongs to (Antigravity's). */
|
||||
windows: Array<{
|
||||
@@ -3236,6 +3235,10 @@ export interface TranscriptContent {
|
||||
/** Present on a Write/Edit/MultiEdit "tool_result": the lines it added and
|
||||
* removed (from Claude Code's `structuredPatch`). */
|
||||
diff?: { added: number; removed: number };
|
||||
/** Present on an Agent/Task "tool_result" whose subagent (or one it started)
|
||||
* wrote or edited files: each file with its +/- lines (`counted`: the
|
||||
* results carried them). Read from the subagent's own transcript. */
|
||||
edits?: { path: string; added: number; removed: number; counted: boolean }[];
|
||||
/** Present on an AskUserQuestion "tool_result": question text → the option
|
||||
* the user picked, or what they typed. */
|
||||
answers?: Record<string, string>;
|
||||
@@ -3320,6 +3323,10 @@ export interface TranscriptResult {
|
||||
last_line: number;
|
||||
/** JSONL line number of the first message in this page. */
|
||||
first_line: number;
|
||||
/** Claude only, when the page starts in the middle of a turn: the files
|
||||
* that turn changed before the page's first message — the chat adds them
|
||||
* to the turn's "Edited N files" card. */
|
||||
turn_edits?: { path: string; added: number; removed: number; counted: boolean }[];
|
||||
}
|
||||
|
||||
/** One entry in a session's transcript picker (main agent, a subagent, or a
|
||||
|
||||
+780
-308
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,10 @@
|
||||
* the sandbox cannot show (Office, zip, PDF) offers Download only, the
|
||||
* Add menu closes on Escape or a click outside it, the front page is shelved
|
||||
* by kind until a category is picked, and a card opens its detail sheet.
|
||||
* Rows carry one primary action; Preview, Share and Edit live in the ⋯ menu.
|
||||
* A section shows 30 entries before "Show more", and three or more skills of
|
||||
* one owner sharing a name prefix are one pack row that installs them all.
|
||||
* An installed skill whose owner changed it shows Update (after a confirmation).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
@@ -33,6 +37,7 @@ vi.mock("../../lib/api", () => ({
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
install: vi.fn(),
|
||||
updateInstalled: vi.fn(),
|
||||
uninstall: vi.fn(),
|
||||
installs: vi.fn(),
|
||||
setInstallsEnabled: vi.fn(),
|
||||
@@ -113,6 +118,16 @@ const addMenu = async (user: ReturnType<typeof userEvent.setup>, label: string)
|
||||
await user.click(screen.getByRole("button", { name: label }));
|
||||
};
|
||||
|
||||
/** Run one item of the first (or the given) row's ⋯ menu. */
|
||||
async function menuAction(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
name: string,
|
||||
scope: HTMLElement = document.body
|
||||
) {
|
||||
await user.click(within(scope).getAllByRole("button", { name: "More actions" })[0]!);
|
||||
await user.click(within(screen.getByTestId("artifact-menu")).getByRole("menuitem", { name }));
|
||||
}
|
||||
|
||||
describe("Adding", () => {
|
||||
it("adds the plugins picked from a marketplace link", async () => {
|
||||
const user = userEvent.setup();
|
||||
@@ -218,7 +233,11 @@ describe("Artifacts", () => {
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
expect(await screen.findByRole("button", { name: "Hub" })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("button", { name: /^Share$/ }).length).toBe(2);
|
||||
await user.click(screen.getAllByRole("button", { name: "More actions" })[0]!);
|
||||
expect(
|
||||
within(screen.getByTestId("artifact-menu")).getByRole("menuitem", { name: "Share" })
|
||||
).toBeInTheDocument();
|
||||
await user.keyboard("{Escape}");
|
||||
// One merged list by default; Hub is a filter over it.
|
||||
expect(api.artifacts.list).toHaveBeenLastCalledWith(expect.objectContaining({ scope: "all" }));
|
||||
await user.click(screen.getByRole("button", { name: "Hub" }));
|
||||
@@ -271,20 +290,46 @@ describe("Artifacts", () => {
|
||||
expect(within(screen.getByTestId("half-mine.own")).getByText("my-page")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("pages a long section 12 cards at a time", async () => {
|
||||
it("shows a long section 30 entries at a time, then the rest on Show more", async () => {
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: Array.from({ length: 14 }, (_, i) =>
|
||||
items: Array.from({ length: 34 }, (_, i) =>
|
||||
item({ id: i + 1, kind: "page", title: `p${i + 1}`, files: [] })
|
||||
),
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("p1");
|
||||
expect(screen.queryByText("p13")).toBeNull();
|
||||
expect(screen.getByText("Showing 1-12 of 14")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Next" }));
|
||||
expect(screen.getByText("p13")).toBeInTheDocument();
|
||||
expect(screen.queryByText("p1")).toBeNull();
|
||||
expect(screen.getByText("p30")).toBeInTheDocument();
|
||||
expect(screen.queryByText("p31")).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "Show 4 more" }));
|
||||
expect(screen.getByText("p34")).toBeInTheDocument();
|
||||
expect(screen.getByText("p1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("folds three skills sharing a name prefix into one pack that installs them all", async () => {
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [
|
||||
item({ id: 1, kind: "skill", title: "sg-qa", source: "sg-qa" }),
|
||||
item({ id: 2, kind: "skill", title: "sg-ship", source: "sg-ship", installed: true }),
|
||||
item({ id: 3, kind: "skill", title: "sg-fix", source: "sg-fix" }),
|
||||
item({ id: 4, kind: "skill", title: "lone", source: "lone" }),
|
||||
],
|
||||
});
|
||||
vi.mocked(api.artifacts.install).mockResolvedValue({ installed: "/h/x" } as never);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
const pack = await screen.findByTestId("pack-sg");
|
||||
expect(within(pack).getByText("sg-* pack")).toBeInTheDocument();
|
||||
expect(within(pack).getByText("3 skills")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("artifact-1")).toBeNull();
|
||||
expect(screen.getByTestId("artifact-4")).toBeInTheDocument();
|
||||
await user.click(within(pack).getByRole("button", { expanded: false }));
|
||||
expect(within(pack).getByTestId("artifact-1")).toBeInTheDocument();
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
|
||||
await user.click(within(pack).getByRole("button", { name: "Install 2 more" }));
|
||||
await waitFor(() => expect(api.artifacts.install).toHaveBeenCalledTimes(2));
|
||||
expect(api.artifacts.install).toHaveBeenCalledWith(1);
|
||||
expect(api.artifacts.install).toHaveBeenCalledWith(3);
|
||||
});
|
||||
|
||||
it("lists who installed an artifact, when and where, from its install count", async () => {
|
||||
@@ -333,12 +378,38 @@ describe("Artifacts", () => {
|
||||
expect(within(card).getByText("Hub only")).toBeInTheDocument();
|
||||
expect(within(card).queryByRole("button", { name: "Delete" })).toBeNull();
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
|
||||
await user.click(within(card).getByRole("button", { name: "Remove from Hub" }));
|
||||
await menuAction(user, "Remove from Hub", card);
|
||||
await waitFor(() => expect(api.artifacts.update).toHaveBeenCalledWith(3, { shared: false }));
|
||||
const calls = vi.mocked(confirmDialog).mock.calls;
|
||||
expect(calls[calls.length - 1]?.[0].title).toMatch(/Remove “hub-only” from the Hub/);
|
||||
});
|
||||
|
||||
it("offers Update for an installed skill its owner changed, after a confirmation", async () => {
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [
|
||||
item({
|
||||
id: 2,
|
||||
kind: "skill",
|
||||
title: "deploy",
|
||||
source: "deploy",
|
||||
installed: true,
|
||||
updateAvailable: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
vi.mocked(api.artifacts.updateInstalled).mockResolvedValue({ installed: "/h/x" });
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
const card = await screen.findByTestId("artifact-2");
|
||||
expect(within(card).queryByTestId("artifact-installed")).toBeNull();
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
|
||||
await user.click(within(card).getByRole("button", { name: "Update" }));
|
||||
expect(api.artifacts.updateInstalled).not.toHaveBeenCalled();
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
|
||||
await user.click(within(card).getByRole("button", { name: "Update" }));
|
||||
await waitFor(() => expect(api.artifacts.updateInstalled).toHaveBeenCalledWith(2));
|
||||
});
|
||||
|
||||
it("shows Installed instead of Install for a skill already installed", async () => {
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [item({ id: 2, kind: "skill", title: "deploy", source: "deploy", installed: true })],
|
||||
@@ -436,7 +507,7 @@ describe("Artifacts", () => {
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("standup");
|
||||
await user.click(screen.getByRole("button", { name: "Preview" }));
|
||||
await menuAction(user, "Preview");
|
||||
expect(await screen.findByRole("heading", { name: "Daily standup" })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("skill-summary")).toHaveTextContent("/out/report.html");
|
||||
expect(screen.queryByTestId("artifact-preview")).toBeNull();
|
||||
@@ -464,7 +535,7 @@ describe("Artifacts", () => {
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("standup");
|
||||
await user.click(screen.getByRole("button", { name: "Edit" }));
|
||||
await menuAction(user, "Edit");
|
||||
const name = await screen.findByLabelText(/Skill name/);
|
||||
expect(name).toHaveValue("standup");
|
||||
expect(screen.getByLabelText(/Instructions/)).toHaveValue("Old body");
|
||||
|
||||
@@ -7244,7 +7244,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.8.23 (rebuild client to match)
|
||||
UI build v2.8.24 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+6
-5
File diff suppressed because one or more lines are too long
@@ -260,7 +260,6 @@ DASHBOARD_ANTIGRAVITY_POLL_MS=15000 # agy conversation files → Antigravity
|
||||
AGENTHUB_GEMINI_DIR= # where agy keeps its data (default ~/.gemini)
|
||||
DASHBOARD_CURSOR_POLL_MS=15000 # Cursor agent transcripts → Cursor sessions (no tokens; 60 s while idle)
|
||||
DASHBOARD_OMP_POLL_MS=15000 # omp session journals → Oh My Pi sessions/tokens/subagents (60 s while idle)
|
||||
DASHBOARD_OPENCODE_POLL_MS=15000 # opencode.db → OpenCode sessions/tokens/subagents (60 s while idle)
|
||||
AGENTHUB_CURSOR_DIR= # where Cursor keeps its data (default ~/.cursor)
|
||||
AGENTHUB_AGY_USAGE= # 0 never runs `agy --print /usage` for the Usage panel's Antigravity quota (default on; running agy may update it)
|
||||
|
||||
|
||||
+1
-1
@@ -1838,7 +1838,7 @@ agenthub stage <stage> [--evidence "..."] # Report the current stage
|
||||
|
||||
## Workspaces on any AI tool
|
||||
|
||||
A workspace can run any AI coding CLI in the AI Tools catalog (`server/data/ai-tools/*.json` — seven built in: Claude Code, Codex, Antigravity, OpenCode, Cursor CLI (`cursor-agent`), Grok Build (`grok`) and Oh My Pi —, plus any `*.json` in `DASHBOARD_AI_TOOLS_DIR`, which replaces a built-in file with the same `id` but may not set `launcher`). Settings → Agents → **AI tools** (`/settings/agents`; the old `/settings#ai-tools` redirects there) lists what is installed (found on PATH; **Refresh** re-reads the login shell's PATH first) with an enable switch, **Set default** and **Update** (for every tool whose catalog file names an updater — no version lookup, the updater itself reports "up to date"), and, folded behind a disclosure, what is available to install with a homepage link. Each installed, enabled tool's own settings (`/settings/agents/<id>`) are tabs on that page — **All tools** · Claude Code · Codex · … — rather than items in the left nav, and a click on a tool's row (or its name) opens its tab. The Add-workspace modal shows the chosen tool as one button (the default until changed); clicking it lists every installed, enabled tool as buttons to pick from; `agenthub workspace add --tool <id>` does the same from a terminal. The run form's **Model** picker lists the workspace tool's own models (`GET /api/ai-tools/:id/models`; Claude, Codex, and any catalog tool whose file declares `model: { flag }` — the id is validated and passed as `<flag> <id>`), plus **Custom…**, and starts on the tool's default model when one is set.
|
||||
A workspace can run any AI coding CLI in the AI Tools catalog (`server/data/ai-tools/*.json` — five built in: Claude Code, Codex, Antigravity, Cursor CLI (`cursor-agent`) and Oh My Pi —, plus any `*.json` in `DASHBOARD_AI_TOOLS_DIR`, which replaces a built-in file with the same `id` but may not set `launcher`). Settings → Agents → **AI tools** (`/settings/agents`; the old `/settings#ai-tools` redirects there) lists what is installed (found on PATH; **Refresh** re-reads the login shell's PATH first) with an enable switch, **Set default** and **Update** (for every tool whose catalog file names an updater — no version lookup, the updater itself reports "up to date"), and, folded behind a disclosure, what is available to install with a homepage link. Each installed, enabled tool's own settings (`/settings/agents/<id>`) are tabs on that page — **All tools** · Claude Code · Codex · … — rather than items in the left nav, and a click on a tool's row (or its name) opens its tab. The Add-workspace modal shows the chosen tool as one button (the default until changed); clicking it lists every installed, enabled tool as buttons to pick from; `agenthub workspace add --tool <id>` does the same from a terminal. The run form's **Model** picker lists the workspace tool's own models (`GET /api/ai-tools/:id/models`; Claude, Codex, and any catalog tool whose file declares `model: { flag }` — the id is validated and passed as `<flag> <id>`), plus **Custom…**, and starts on the tool's default model when one is set.
|
||||
|
||||
A catalog file:
|
||||
|
||||
|
||||
+4
-7
@@ -310,13 +310,10 @@ per model and one agent per subagent the `task` tool spawned (its journal sits i
|
||||
the folder named like the session's). An omp session can be resumed by id from
|
||||
its workspace (`omp --resume <id>`), like a Claude or Codex one.
|
||||
|
||||
**OpenCode** sessions are read from its SQLite store
|
||||
(`~/.local/share/opencode/opencode.db`, every `DASHBOARD_OPENCODE_POLL_MS` ms),
|
||||
with their tokens and cost per model and one agent per subagent, and are
|
||||
resumable by id (`opencode --session <id>`). **Grok Build** runs the Claude hooks in
|
||||
`~/.claude/settings.json` itself, so its sessions arrive through AgentHub's own
|
||||
hooks, recorded as Grok sessions (live status, no tokens); they resume with
|
||||
`grok --resume <id>`. Cursor sessions resume by id too (`--resume <id>`).
|
||||
Cursor sessions resume by id too (`--resume <id>`), and open as a chat like
|
||||
omp ones. OpenCode and Grok Build are no longer AgentHub tools: a Grok Build
|
||||
running the Claude hooks in `~/.claude/settings.json` is ignored, and sessions an
|
||||
older build recorded for either stay in the history.
|
||||
|
||||
**Pipelines are dormant.** AgentHub ships no pipeline template, so a workspace has no
|
||||
stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 2.8.23
|
||||
version: 2.8.24
|
||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||
contact:
|
||||
name: Nguyễn Ngọc Trí Vĩ
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.23",
|
||||
"version": "2.8.24",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.8.23",
|
||||
"version": "2.8.24",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.23",
|
||||
"version": "2.8.24",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -51,14 +51,12 @@ const env = {
|
||||
// No suite may fetch LiteLLM's price list (reset-pricing syncs at once).
|
||||
AGENTHUB_PRICING_SYNC: "0",
|
||||
// Nor run the developer's real agy (it may update itself) or send their
|
||||
// real Cursor, Grok, Kimi or OpenCode login for the quota.
|
||||
// real Cursor login for the quota.
|
||||
AGENTHUB_AGY_USAGE: "0",
|
||||
// Nor refresh (spend) a real Claude pool login: its refresh token is single use.
|
||||
DASHBOARD_POOL_REFRESH_MS: "0",
|
||||
XDG_CONFIG_HOME: tmuxDir,
|
||||
XDG_DATA_HOME: tmuxDir,
|
||||
GROK_HOME: tmuxDir,
|
||||
KIMI_CODE_HOME: tmuxDir,
|
||||
DASHBOARD_PIPELINES_DIR: path.join(dir, "fixtures", "pipelines"),
|
||||
// Never this machine's server-mode S3 install: its system unit would become
|
||||
// "the" service manager and its server.conf would send user changes to sudo.
|
||||
|
||||
+6
-17
@@ -655,23 +655,12 @@ An `antigravity` workspace carries no pipeline either.
|
||||
what it said so far; `GET /api/sessions/:id/transcript(s)` reads an omp
|
||||
session's journals from there. Under S3 each member's `~/.omp` is read only
|
||||
through `openSafeAsync`.
|
||||
- **OpenCode sessions:** `server/lib/opencode-usage.js` reads OpenCode's
|
||||
SQLite store (`~/.local/share/opencode/opencode.db`, under `XDG_DATA_HOME`
|
||||
when set) every `DASHBOARD_OPENCODE_POLL_MS` ms (default 15000, `0`
|
||||
disables), when its file or WAL changed: each root `session` row becomes a
|
||||
`sessions` row (`tool = 'opencode'`), its tree's assistant `message` tokens
|
||||
per `modelID` become `token_usage` (reasoning counted as output), and each
|
||||
child session (the `task` tool's) an `opencode-<id>` subagent row.
|
||||
`opencode_stores` holds each store's mtime and `opencode_sessions` each root
|
||||
session's newest `time_updated`; the transcript routes read the conversation
|
||||
(messages + parts) from the store itself. A member's store and WAL are copied
|
||||
through the guard before they are opened.
|
||||
- **Grok sessions:** Grok runs the hooks in `~/.claude/settings.json` (its
|
||||
Claude compatibility), so AgentHub's own hook reaches `POST /api/hooks/event`
|
||||
with no `tool`; `routes/hooks.js` tells it apart by the camelCase keys only
|
||||
Grok sends (`hookEventName` + `workspaceRoot`), records it as `tool = 'grok'`
|
||||
and drops its `transcript_path` (Grok's file, never parsed as a Claude
|
||||
transcript). No tokens: Grok's session files are not read.
|
||||
- **Grok hooks:** Grok is not an AgentHub tool, but it runs the hooks in
|
||||
`~/.claude/settings.json` (its Claude compatibility), so AgentHub's own hook
|
||||
can still reach `POST /api/hooks/event` with no `tool`; `routes/hooks.js`
|
||||
tells it apart by the camelCase keys only Grok sends (`hookEventName` +
|
||||
`workspaceRoot`) and drops it, rather than record a Claude session whose
|
||||
`transcript_path` is Grok's file.
|
||||
- **Quiet sessions:** agy fires no exit hook, so a session quiet for ten
|
||||
minutes (with hook events) or two (without) is settled to `completed`, its
|
||||
agents too, and sets its workspace idle. That workspace must be one the hooks left
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -45,7 +45,7 @@ before(async () => {
|
||||
builtin,
|
||||
tool("gem", [
|
||||
{ path: "~/.gem/settings.json" },
|
||||
{ path: "~/.gem/opencode.jsonc" },
|
||||
{ path: "~/.gem/tool.jsonc" },
|
||||
{ path: "~/.gem/GEMINI.md" },
|
||||
{ path: "~/.gem/missing.json" },
|
||||
])
|
||||
@@ -57,7 +57,7 @@ before(async () => {
|
||||
|
||||
fs.mkdirSync(path.join(home, ".gem"));
|
||||
fs.writeFileSync(path.join(home, ".gem/settings.json"), '{"theme":"dark"}\n', { mode: 0o600 });
|
||||
fs.writeFileSync(path.join(home, ".gem/opencode.jsonc"), '{\n // c\n "a": 1,\n}\n');
|
||||
fs.writeFileSync(path.join(home, ".gem/tool.jsonc"), '{\n // c\n "a": 1,\n}\n');
|
||||
fs.writeFileSync(path.join(home, ".gem/GEMINI.md"), "# rules\n");
|
||||
|
||||
const express = require("express");
|
||||
|
||||
@@ -26,8 +26,28 @@ for (const name of ["claude", "agy", "npm"]) {
|
||||
const origPath = process.env.PATH;
|
||||
process.env.PATH = BIN;
|
||||
|
||||
// An npm-installed tool, in the release-synced tier (only a release catalog may
|
||||
// name an installer): no built-in tool updates through npm.
|
||||
const REMOTE = path.join(ROOT, "remote-catalog");
|
||||
fs.mkdirSync(REMOTE);
|
||||
fs.writeFileSync(
|
||||
path.join(REMOTE, "npmtool.json"),
|
||||
JSON.stringify({
|
||||
id: "npmtool",
|
||||
label: "npm tool",
|
||||
detect: ["npmtool"],
|
||||
prompt: { mode: "flag", flag: "--prompt" },
|
||||
install: { kind: "npm", package: "npmtool" },
|
||||
update: { kind: "npm" },
|
||||
})
|
||||
);
|
||||
|
||||
const { createApp } = require("../index");
|
||||
require("../lib/ai-tools").refresh();
|
||||
require("../lib/ai-tools").__setCatalogDirs([
|
||||
path.join(__dirname, "..", "data", "ai-tools"),
|
||||
REMOTE,
|
||||
path.join(ROOT, "custom-catalog"),
|
||||
]);
|
||||
|
||||
let app;
|
||||
let base;
|
||||
@@ -59,7 +79,7 @@ describe("/api/ai-tools", () => {
|
||||
assert.equal(agy.path, path.join(BIN, "agy"));
|
||||
assert.equal(agy.resume, true);
|
||||
assert.equal(agy.bypass, true);
|
||||
assert.equal(body.tools.find((t) => t.id === "opencode").installed, false);
|
||||
assert.equal(body.tools.find((t) => t.id === "npmtool").installed, false);
|
||||
assert.equal("args" in agy, false);
|
||||
});
|
||||
|
||||
@@ -76,7 +96,7 @@ describe("/api/ai-tools", () => {
|
||||
|
||||
it("answers ENOTOOL / EBADDEFAULT / EBADFIELD", async () => {
|
||||
assert.equal((await request("PUT", "/api/ai-tools/nope", { enabled: false })).status, 404);
|
||||
const bad = await request("PUT", "/api/ai-tools/opencode", { default: true });
|
||||
const bad = await request("PUT", "/api/ai-tools/npmtool", { default: true });
|
||||
assert.equal(bad.status, 400);
|
||||
assert.equal(bad.body.error.code, "EBADDEFAULT");
|
||||
assert.equal((await request("PUT", "/api/ai-tools/claude", { enabled: "no" })).status, 400);
|
||||
@@ -112,10 +132,10 @@ describe("/api/ai-tools", () => {
|
||||
});
|
||||
|
||||
it("refresh re-detects", async () => {
|
||||
fs.writeFileSync(path.join(BIN, "opencode"), "#!/bin/sh\n");
|
||||
fs.chmodSync(path.join(BIN, "opencode"), 0o755);
|
||||
fs.writeFileSync(path.join(BIN, "npmtool"), "#!/bin/sh\n");
|
||||
fs.chmodSync(path.join(BIN, "npmtool"), 0o755);
|
||||
const r = await request("POST", "/api/ai-tools/refresh");
|
||||
assert.equal(r.body.tools.find((t) => t.id === "opencode").installed, true);
|
||||
assert.equal(r.body.tools.find((t) => t.id === "npmtool").installed, true);
|
||||
});
|
||||
|
||||
it("/api/run/binary resolves through the catalog", async () => {
|
||||
@@ -132,9 +152,9 @@ describe("lanes on catalog tools", () => {
|
||||
fs.mkdirSync(d, { recursive: true });
|
||||
return d;
|
||||
};
|
||||
let r = await request("POST", "/api/lanes", { cwd: dir("a"), title: "a", tool: "opencode" });
|
||||
let r = await request("POST", "/api/lanes", { cwd: dir("a"), title: "a", tool: "npmtool" });
|
||||
assert.equal(r.status, 201);
|
||||
assert.equal(r.body.lane.tool, "opencode");
|
||||
assert.equal(r.body.lane.tool, "npmtool");
|
||||
r = await request("POST", "/api/lanes", { cwd: dir("b"), title: "b", tool: "omp" });
|
||||
assert.equal(r.status, 201, "a tool that is not installed may still get a lane");
|
||||
r = await request("POST", "/api/lanes", { cwd: dir("c"), title: "c", tool: "nope" });
|
||||
@@ -212,8 +232,8 @@ describe("install / update / icons", () => {
|
||||
const notInstalled = await request("POST", "/api/ai-tools/codex/update");
|
||||
assert.equal(notInstalled.status, 409);
|
||||
assert.equal(notInstalled.body.error.code, "ENOTINSTALLED");
|
||||
// opencode was put on PATH by the refresh test, outside any npm prefix.
|
||||
const notNpm = await request("POST", "/api/ai-tools/opencode/update");
|
||||
// npmtool was put on PATH by the refresh test, outside any npm prefix.
|
||||
const notNpm = await request("POST", "/api/ai-tools/npmtool/update");
|
||||
assert.equal(notNpm.status, 409);
|
||||
assert.equal(notNpm.body.error.code, "ENOTNPM");
|
||||
const cross = await request("POST", "/api/ai-tools/codex/install", undefined, {
|
||||
@@ -241,7 +261,7 @@ describe("install / update / icons", () => {
|
||||
assert.deepEqual(spawned.at(-1), [path.join(BIN, "claude"), "update"]);
|
||||
const v = await request("GET", "/api/ai-tools/versions");
|
||||
assert.equal(v.status, 200);
|
||||
assert.equal(v.body.opencode.npmManaged, false);
|
||||
assert.equal(v.body.npmtool.npmManaged, false);
|
||||
});
|
||||
|
||||
it("update-all answers ENOTHING when nothing has an update", async () => {
|
||||
|
||||
@@ -10,8 +10,10 @@
|
||||
* junk skipped, nothing stored when one fails) or written in the dialog, a
|
||||
* file uploaded, sharing refused in local mode, visibility for a server-mode
|
||||
* developer, the admin's installs switch, same-origin writes, Claude Docs
|
||||
* docs captured at their birth, documents captured at a session's stop, and
|
||||
* unowned rows claimed, declined and assigned.
|
||||
* docs captured at their birth, documents captured at a session's stop,
|
||||
* unowned rows claimed, declined and assigned, and a skill that follows its
|
||||
* source folder (a newer change wins over a Hub edit) with Update offered to
|
||||
* an installed copy that differs.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -338,6 +340,69 @@ describe("skills", () => {
|
||||
assert.equal(empty.status, 400);
|
||||
});
|
||||
|
||||
it("follows its source folder and offers Update to an installed copy", async () => {
|
||||
const src = path.join(ws, ".claude", "skills", "notes");
|
||||
fs.mkdirSync(src, { recursive: true });
|
||||
fs.writeFileSync(path.join(src, "SKILL.md"), "---\nname: notes\ndescription: v1\n---\nOne.\n");
|
||||
const r = await request("POST", "/api/artifacts/skill", { laneId: lane.id, name: "notes" });
|
||||
assert.equal(r.status, 201, r.text);
|
||||
const id = r.body.item.id;
|
||||
const listed = async () =>
|
||||
(await request("GET", "/api/artifacts?kind=skill")).body.items.find((i) => i.id === id);
|
||||
const raw = async () => (await request("GET", `/api/artifacts/${id}/raw?path=SKILL.md`)).text;
|
||||
assert.equal((await request("POST", `/api/artifacts/${id}/install`, {})).status, 200);
|
||||
let item = await listed();
|
||||
assert.equal(item.installed, true);
|
||||
assert.equal(item.updateAvailable, false);
|
||||
const installs = item.installs;
|
||||
|
||||
// Unchanged folder: nothing taken.
|
||||
assert.deepEqual(artifacts.syncSkillSources({ force: true }), []);
|
||||
// The owner changes it: a new snapshot, and the installed copy is behind.
|
||||
const snapBefore = db.prepare("SELECT snapshot FROM artifacts WHERE id = ?").get(id).snapshot;
|
||||
fs.writeFileSync(path.join(src, "SKILL.md"), "---\nname: notes\ndescription: v2\n---\nTwo.\n");
|
||||
fs.writeFileSync(path.join(src, "extra.md"), "more\n");
|
||||
const changed = artifacts.syncSkillSources({ force: true });
|
||||
assert.deepEqual(
|
||||
changed.map((c) => c.id),
|
||||
[id]
|
||||
);
|
||||
assert.match(await raw(), /Two\./);
|
||||
assert.ok(!fs.existsSync(path.join(process.env.DASHBOARD_DATA_DIR, "artifacts", snapBefore)));
|
||||
item = await listed();
|
||||
assert.equal(item.description, "v2");
|
||||
assert.ok(item.files.some((f) => f.path === "extra.md"));
|
||||
assert.equal(item.updateAvailable, true);
|
||||
|
||||
// Update: the copy is replaced (old one moved aside), not counted again.
|
||||
const up = await request("POST", `/api/artifacts/${id}/install`, { update: true });
|
||||
assert.equal(up.status, 200, up.text);
|
||||
const target = path.join(process.env.CLAUDE_HOME, "skills", "notes");
|
||||
assert.match(fs.readFileSync(path.join(target, "SKILL.md"), "utf8"), /Two\./);
|
||||
item = await listed();
|
||||
assert.equal(item.updateAvailable, false);
|
||||
assert.equal(item.installs, installs);
|
||||
|
||||
// A Hub edit is not undone by the unchanged folder; a newer folder change wins.
|
||||
const edit = await request("PATCH", `/api/artifacts/${id}`, {
|
||||
skill: { name: "notes", description: "hub", content: "Edited in the Hub." },
|
||||
});
|
||||
assert.equal(edit.status, 200, edit.text);
|
||||
artifacts.syncSkillSources({ force: true });
|
||||
assert.match(await raw(), /Edited in the Hub\./);
|
||||
fs.writeFileSync(
|
||||
path.join(src, "SKILL.md"),
|
||||
"---\nname: notes\ndescription: v3\n---\nThree.\n"
|
||||
);
|
||||
artifacts.syncSkillSources({ force: true });
|
||||
assert.match(await raw(), /Three\./);
|
||||
|
||||
// Update needs an installed copy.
|
||||
await request("POST", `/api/artifacts/${id}/uninstall`, {});
|
||||
const none = await request("POST", `/api/artifacts/${id}/install`, { update: true });
|
||||
assert.equal(none.body.error.code, "ENOTINSTALLED");
|
||||
});
|
||||
|
||||
it("refuses a skill holding a symlink or a secret-looking file", async () => {
|
||||
const bad = path.join(ws, ".claude", "skills", "leaky");
|
||||
fs.mkdirSync(bad, { recursive: true });
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "grok",
|
||||
"label": "Grok",
|
||||
"detect": ["grok"],
|
||||
"id": "septool",
|
||||
"label": "Separator tool",
|
||||
"detect": ["septool"],
|
||||
"prompt": {
|
||||
"mode": "argv",
|
||||
"separator": "--"
|
||||
@@ -2,10 +2,10 @@
|
||||
* @file Tests POST /api/hooks/event for a hook Grok Build fired through the
|
||||
* Claude hooks in `~/.claude/settings.json` (Grok's Claude compatibility): it
|
||||
* arrives with no `tool`, is told apart by the camelCase keys only Grok sends
|
||||
* (`hookEventName`, `workspaceRoot`), and lands as a `tool = 'grok'` session
|
||||
* on the grok workspace — never the claude one sharing the directory — with
|
||||
* its `transcript_path` dropped. A Claude hook is left Claude's. The DB lives
|
||||
* in a temp dir.
|
||||
* (`hookEventName`, `workspaceRoot`), and dropped — Grok is not an AgentHub
|
||||
* tool, and taken as Claude's it would bind the claude workspace and name a
|
||||
* Grok file as a Claude transcript. A Claude hook is left Claude's. The DB
|
||||
* lives in a temp dir.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -58,23 +58,18 @@ const grokData = (id, extra = {}) => ({
|
||||
});
|
||||
|
||||
describe("Grok hooks through the Claude settings", () => {
|
||||
it("records a grok session, on the grok workspace, with no transcript path", async () => {
|
||||
it("drops a Grok hook: no session, no event, no workspace bound", async () => {
|
||||
const claudeLane = lanes.createLane({ title: "c", cwd: CWD, tool: "claude" });
|
||||
const grokLane = lanes.createLane({ title: "g", cwd: CWD, tool: "grok" });
|
||||
const id = "0199f0aa-1111-7222-8333-444455556666";
|
||||
await post({ hook_type: "SessionStart", data: grokData(id) });
|
||||
const r = await post({ hook_type: "SessionStart", data: grokData(id) });
|
||||
assert.equal(r.ignored, true);
|
||||
await post({
|
||||
hook_type: "UserPromptSubmit",
|
||||
data: grokData(id, { hookEventName: "user_prompt_submit", prompt: "fix the bug" }),
|
||||
});
|
||||
const s = db.prepare("SELECT * FROM sessions WHERE id = ?").get(id);
|
||||
assert.equal(s.tool, "grok");
|
||||
assert.equal(s.transcript_path, null);
|
||||
const events = db.prepare("SELECT data FROM events WHERE session_id = ?").all(id);
|
||||
assert.ok(events.length >= 1);
|
||||
for (const e of events) assert.equal(JSON.parse(e.data).transcript_path, undefined);
|
||||
assert.equal(db.prepare("SELECT * FROM sessions WHERE id = ?").get(id), undefined);
|
||||
assert.equal(db.prepare("SELECT COUNT(*) n FROM events WHERE session_id = ?").get(id).n, 0);
|
||||
assert.equal(lanes.getLane(claudeLane.id).session_id, null);
|
||||
assert.equal(lanes.getLane(grokLane.id).session_id, id);
|
||||
});
|
||||
|
||||
it("leaves a Claude hook Claude's", async () => {
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
/**
|
||||
* @file Tests server/lib/opencode-usage.js and the OpenCode branch of the
|
||||
* transcript routes — OpenCode sessions from its SQLite store: a root session
|
||||
* created as `tool = 'opencode'` with its title, cwd, model and per-model
|
||||
* tokens (its subagent session's included, reasoning counted as output), a
|
||||
* subagent row per child session, an unchanged store never re-read, a
|
||||
* session the user deleted staying deleted until it is updated, and the
|
||||
* conversation (text, reasoning, tool call + result) in the Claude
|
||||
* transcript shape. The store is a temp file with the tables and columns
|
||||
* opencode 1.19 writes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const os = require("node:os");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-opencode-usage-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
|
||||
process.env.AGENTHUB_OPENCODE_DIR = path.join(ROOT, "opencode");
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { db, Database } = require("../db");
|
||||
const oc = require("../lib/opencode-usage");
|
||||
const { createApp, startServer } = require("../index");
|
||||
|
||||
const ID = "ses_eac8393b4ffeEz26VGbeyq8sir";
|
||||
const CHILD = "ses_eac8393b4fffChildSession01";
|
||||
const CWD = path.join(fs.realpathSync(ROOT), "work");
|
||||
const STORE = path.join(process.env.AGENTHUB_OPENCODE_DIR, "opencode.db");
|
||||
const T0 = Date.parse("2026-10-19T09:27:05.000Z");
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
after(() => {
|
||||
server?.close();
|
||||
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function withStore(fn) {
|
||||
const conn = new Database(STORE);
|
||||
try {
|
||||
fn(conn);
|
||||
} finally {
|
||||
conn.close();
|
||||
}
|
||||
}
|
||||
|
||||
const assistant = (model, tokens) => JSON.stringify({ role: "assistant", modelID: model, tokens });
|
||||
const tokens = (input, output, reasoning = 0, read = 0, write = 0) => ({
|
||||
input,
|
||||
output,
|
||||
reasoning,
|
||||
cache: { read, write },
|
||||
});
|
||||
|
||||
before(async () => {
|
||||
fs.mkdirSync(path.dirname(STORE), { recursive: true });
|
||||
withStore((c) => {
|
||||
c.exec(`
|
||||
CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT,
|
||||
slug TEXT NOT NULL, directory TEXT NOT NULL, title TEXT NOT NULL, version TEXT NOT NULL,
|
||||
model TEXT, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, time_archived INTEGER);
|
||||
CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, time_created INTEGER NOT NULL,
|
||||
time_updated INTEGER NOT NULL, data TEXT NOT NULL);
|
||||
CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT NOT NULL, session_id TEXT NOT NULL,
|
||||
time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, data TEXT NOT NULL);
|
||||
`);
|
||||
const s = c.prepare(
|
||||
"INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, model, time_created, time_updated) VALUES (?, 'global', ?, 'x', ?, ?, '1.19.3', ?, ?, ?)"
|
||||
);
|
||||
s.run(ID, null, CWD, "Fix the login bug", '{"id":"claude-sonnet-4-5","providerID":"anthropic"}', T0, T0 + 5000);
|
||||
s.run(CHILD, ID, CWD, "Explore auth (@explore subagent)", null, T0 + 1000, T0 + 3000);
|
||||
const m = c.prepare("INSERT INTO message VALUES (?, ?, ?, ?, ?)");
|
||||
m.run("msg_1", ID, T0 + 10, T0 + 10, JSON.stringify({ role: "user" }));
|
||||
m.run("msg_2", ID, T0 + 20, T0 + 20, assistant("claude-sonnet-4-5", tokens(100, 20, 5, 1000, 50)));
|
||||
m.run("msg_3", CHILD, T0 + 1100, T0 + 1100, JSON.stringify({ role: "user" }));
|
||||
m.run("msg_4", CHILD, T0 + 1200, T0 + 1200, assistant("claude-haiku-4-5", tokens(10, 4)));
|
||||
const p = c.prepare("INSERT INTO part VALUES (?, ?, ?, ?, ?, ?)");
|
||||
p.run("prt_1", "msg_1", ID, T0 + 11, T0 + 11, JSON.stringify({ type: "text", text: "Fix the login bug" }));
|
||||
p.run("prt_2", "msg_2", ID, T0 + 21, T0 + 21, JSON.stringify({ type: "reasoning", text: "Look first" }));
|
||||
p.run(
|
||||
"prt_3",
|
||||
"msg_2",
|
||||
ID,
|
||||
T0 + 22,
|
||||
T0 + 22,
|
||||
JSON.stringify({
|
||||
type: "tool",
|
||||
tool: "read",
|
||||
callID: "call_1",
|
||||
state: { status: "completed", input: { filePath: "auth.ts" }, output: "export {}" },
|
||||
})
|
||||
);
|
||||
p.run("prt_4", "msg_2", ID, T0 + 23, T0 + 23, JSON.stringify({ type: "step-start" }));
|
||||
p.run("prt_5", "msg_3", CHILD, T0 + 1101, T0 + 1101, JSON.stringify({ type: "text", text: "Explore auth" }));
|
||||
p.run("prt_6", "msg_4", CHILD, T0 + 1201, T0 + 1201, JSON.stringify({ type: "text", text: "Found it" }));
|
||||
});
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
});
|
||||
|
||||
const sessionRow = () => db.prepare("SELECT * FROM sessions WHERE id = ?").get(ID);
|
||||
|
||||
describe("opencode usage ingest", () => {
|
||||
it("creates the root session with its title, cwd, model and its tree's tokens", async () => {
|
||||
const r = await oc.ingestOpencodeUsage({ now: T0 + 600_000 });
|
||||
assert.equal(r.files, 1);
|
||||
const s = sessionRow();
|
||||
assert.equal(s.tool, "opencode");
|
||||
assert.equal(s.name, "Fix the login bug");
|
||||
assert.equal(s.cwd, CWD);
|
||||
assert.equal(s.model, "claude-sonnet-4-5");
|
||||
assert.equal(s.status, "completed");
|
||||
assert.equal(db.prepare("SELECT 1 FROM sessions WHERE id = ?").get(CHILD), undefined);
|
||||
const usage = db
|
||||
.prepare(
|
||||
"SELECT model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens FROM token_usage WHERE session_id = ? ORDER BY model"
|
||||
)
|
||||
.all(ID);
|
||||
assert.deepEqual(usage, [
|
||||
{ model: "claude-haiku-4-5", input_tokens: 10, output_tokens: 4, cache_read_tokens: 0, cache_write_tokens: 0 },
|
||||
{ model: "claude-sonnet-4-5", input_tokens: 100, output_tokens: 25, cache_read_tokens: 1000, cache_write_tokens: 50 },
|
||||
]);
|
||||
const agent = db.prepare("SELECT * FROM agents WHERE id = ?").get(`opencode-${CHILD}`);
|
||||
assert.equal(agent.session_id, ID);
|
||||
assert.equal(agent.type, "subagent");
|
||||
assert.equal(agent.name, "Explore auth (@explore subagent)");
|
||||
assert.equal(agent.status, "completed");
|
||||
});
|
||||
|
||||
it("leaves an unchanged store unread", async () => {
|
||||
const r = await oc.ingestOpencodeUsage({ now: T0 + 600_000 });
|
||||
assert.equal(r.files, 0);
|
||||
});
|
||||
|
||||
it("maps the store to the transcript shape, and lists the subagent's", async () => {
|
||||
const list = await (await fetch(`${BASE}/api/sessions/${ID}/transcripts`)).json();
|
||||
assert.deepEqual(
|
||||
list.transcripts.map((t) => [t.id, t.type, t.db_agent_id]),
|
||||
[
|
||||
["main", "main", null],
|
||||
[CHILD, "subagent", `opencode-${CHILD}`],
|
||||
]
|
||||
);
|
||||
const body = await (await fetch(`${BASE}/api/sessions/${ID}/transcript`)).json();
|
||||
assert.deepEqual(
|
||||
body.messages.map((m) => [m.type, m.sender, m.content.map((c) => c.type).join("+")]),
|
||||
[
|
||||
["user", "user", "text"],
|
||||
["assistant", "assistant", "thinking+tool_use"],
|
||||
["user", "tool", "tool_result"],
|
||||
]
|
||||
);
|
||||
assert.equal(body.messages[2].content[0].output, "export {}");
|
||||
assert.equal(body.last_line, 3);
|
||||
const tail = await (await fetch(`${BASE}/api/sessions/${ID}/transcript?after=2`)).json();
|
||||
assert.equal(tail.messages.length, 1);
|
||||
const sub = await (
|
||||
await fetch(`${BASE}/api/sessions/${ID}/transcript?agent_id=${CHILD}`)
|
||||
).json();
|
||||
assert.deepEqual(
|
||||
sub.messages.map((m) => [m.sender, m.content[0].text]),
|
||||
[
|
||||
["orchestrator", "Explore auth"],
|
||||
["assistant", "Found it"],
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a deleted session deleted until OpenCode updates it", async () => {
|
||||
db.prepare("DELETE FROM sessions WHERE id = ?").run(ID);
|
||||
const bump = (t) => {
|
||||
withStore((c) => c.prepare("UPDATE session SET title = title WHERE id = ?").run(ID));
|
||||
const at = new Date(t);
|
||||
fs.utimesSync(STORE, at, at);
|
||||
};
|
||||
bump(Date.now() + 5000);
|
||||
await oc.ingestOpencodeUsage({ now: T0 + 600_000 });
|
||||
assert.equal(sessionRow(), undefined);
|
||||
withStore((c) => c.prepare("UPDATE session SET time_updated = ? WHERE id = ?").run(T0 + 9000, ID));
|
||||
fs.utimesSync(STORE, new Date(Date.now() + 10_000), new Date(Date.now() + 10_000));
|
||||
await oc.ingestOpencodeUsage({ now: T0 + 600_000 });
|
||||
assert.equal(sessionRow()?.tool, "opencode");
|
||||
});
|
||||
});
|
||||
@@ -733,7 +733,7 @@ describe("pty-run", () => {
|
||||
let savedPath;
|
||||
before(() => {
|
||||
fs.mkdirSync(BIN, { recursive: true });
|
||||
for (const name of ["agy", "grok", "goose", "command-code", "omp"]) {
|
||||
for (const name of ["agy", "septool", "goose", "command-code", "omp"]) {
|
||||
fs.writeFileSync(path.join(BIN, name), "#!/bin/sh\n");
|
||||
fs.chmodSync(path.join(BIN, name), 0o755);
|
||||
}
|
||||
@@ -854,7 +854,7 @@ describe("pty-run", () => {
|
||||
);
|
||||
assert.deepEqual(argvFor("antigravity"), [at("agy"), "-i", " -x y"]);
|
||||
// Behind a `--` separator the prompt is already a positional, kept as typed.
|
||||
assert.deepEqual(argvFor("grok"), [at("grok"), "--", "-x y"]);
|
||||
assert.deepEqual(argvFor("septool"), [at("septool"), "--", "-x y"]);
|
||||
assert.deepEqual(argvFor("command-code"), [at("command-code"), "--trust", " -x y"]);
|
||||
assert.deepEqual(argvFor("antigravity", { bypass: false }), [at("agy"), "-i", " -x y"]);
|
||||
});
|
||||
|
||||
@@ -6,9 +6,7 @@
|
||||
* with its own token only while it is valid, never refreshed, and never shown
|
||||
* with the usage of a token that belongs to a different account; each
|
||||
* tool's plan name; the Cursor CLI's login asked of cursor.com only while
|
||||
* valid; Antigravity's `agy --print /usage` pools, never on an old agy; and
|
||||
* the Grok, Kimi Code and OpenCode Go logins, each sent only to its own host
|
||||
* while valid, never refreshed.
|
||||
* valid; and Antigravity's `agy --print /usage` pools, never on an old agy.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -22,12 +20,6 @@ process.env.XDG_CONFIG_HOME = require("node:fs").mkdtempSync(
|
||||
);
|
||||
// Nor run the machine's real agy: only the Antigravity test's fake one runs.
|
||||
process.env.AGENTHUB_AGY_USAGE = "0";
|
||||
// Nor its real Grok, Kimi or OpenCode logins.
|
||||
for (const k of ["GROK_HOME", "KIMI_CODE_HOME", "XDG_DATA_HOME"]) {
|
||||
process.env[k] = require("node:fs").mkdtempSync(
|
||||
require("node:path").join(require("node:os").tmpdir(), "quota-home-")
|
||||
);
|
||||
}
|
||||
|
||||
test("windowLabel names the plan windows", () => {
|
||||
assert.strictEqual(windowLabel(300), "5h");
|
||||
@@ -415,172 +407,3 @@ function stubFetch(answers) {
|
||||
};
|
||||
return { asked, restore: () => (global.fetch = prev) };
|
||||
}
|
||||
|
||||
test("Grok: the CLI's own auth.x.ai session asks cli-chat-proxy for credits + plan, never past expiry", async () => {
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { quotas, __resetCache } = require("../lib/quota");
|
||||
const auth = path.join(process.env.GROK_HOME, "auth.json");
|
||||
const f = stubFetch({
|
||||
"https://cli-chat-proxy.grok.com/v1/billing?format=credits": [
|
||||
200,
|
||||
{
|
||||
config: {
|
||||
creditUsagePercent: 12,
|
||||
currentPeriod: { end: "2099-01-08T00:00:00Z" },
|
||||
subscriptionTier: "SuperGrok",
|
||||
monthlyLimit: { val: "200" },
|
||||
used: { val: "50" },
|
||||
billingPeriodEnd: "2099-02-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const grok = async () => (await quotas()).tools.find((t) => t.id === "grok");
|
||||
try {
|
||||
__resetCache();
|
||||
assert.strictEqual(await grok(), undefined);
|
||||
const later = new Date(Date.now() + 3600_000).toISOString();
|
||||
fs.writeFileSync(
|
||||
auth,
|
||||
JSON.stringify({
|
||||
"https://other.example": { key: "tok-other", expires_at: later },
|
||||
"https://auth.x.ai": { key: "tok-x", user_id: "u1", email: "g@x.ai", expires_at: later },
|
||||
})
|
||||
);
|
||||
__resetCache();
|
||||
assert.deepStrictEqual(await grok(), {
|
||||
id: "grok",
|
||||
active: true,
|
||||
account: "g@x.ai",
|
||||
plan: "SuperGrok",
|
||||
expiresAt: later,
|
||||
windows: [
|
||||
{ label: "wk", usedPercent: 12, resetsAt: "2099-01-08T00:00:00.000Z" },
|
||||
{ label: "mo", usedPercent: 25, resetsAt: "2099-02-01T00:00:00.000Z" },
|
||||
],
|
||||
});
|
||||
assert.deepStrictEqual(
|
||||
f.asked.filter((a) => a.includes("grok.com")),
|
||||
["https://cli-chat-proxy.grok.com/v1/billing?format=credits Bearer tok-x"]
|
||||
);
|
||||
fs.writeFileSync(
|
||||
auth,
|
||||
JSON.stringify({ "https://auth.x.ai": { key: "tok-x", expires_at: "2000-01-01T00:00:00Z" } })
|
||||
);
|
||||
__resetCache();
|
||||
assert.strictEqual((await grok()).error, "ETOKENEXPIRED");
|
||||
assert.strictEqual(f.asked.filter((a) => a.includes("grok.com")).length, 1);
|
||||
} finally {
|
||||
f.restore();
|
||||
fs.rmSync(auth, { force: true });
|
||||
__resetCache();
|
||||
}
|
||||
});
|
||||
|
||||
test("Kimi Code: the CLI's own token asks api.kimi.com/coding for 5h, week and month", async () => {
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { quotas, __resetCache } = require("../lib/quota");
|
||||
const dir = path.join(process.env.KIMI_CODE_HOME, "credentials");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const file = path.join(dir, "kimi-code.json");
|
||||
const f = stubFetch({
|
||||
"https://api.kimi.com/coding/v1/usages": [
|
||||
200,
|
||||
{
|
||||
usages: {
|
||||
limit_5h: { used_ratio: 0.3, reset_time: "2099-01-01T05:00:00Z" },
|
||||
limit_7d: { used_ratio: "0.2", reset_time: "2099-01-07T00:00:00Z" },
|
||||
limit_month_total: { used_ratio: 0.4, reset_time: "2099-02-01T00:00:00Z" },
|
||||
limit_month_code: { used_ratio: 0.25, reset_time: "2099-02-01T00:00:00Z" },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const kimi = async () => (await quotas()).tools.find((t) => t.id === "kimi");
|
||||
try {
|
||||
__resetCache();
|
||||
assert.strictEqual(await kimi(), undefined);
|
||||
const exp = Math.floor(Date.now() / 1000) + 600;
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({ access_token: "tok-k", refresh_token: "r", expires_at: exp })
|
||||
);
|
||||
__resetCache();
|
||||
const row = await kimi();
|
||||
assert.strictEqual(row.expiresAt, new Date(exp * 1000).toISOString());
|
||||
assert.deepStrictEqual(
|
||||
row.windows.map((w) => [w.label, Math.round(w.usedPercent)]),
|
||||
[
|
||||
["5h", 30],
|
||||
["wk", 20],
|
||||
["mo", 40],
|
||||
]
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
f.asked.filter((a) => a.includes("kimi")),
|
||||
["https://api.kimi.com/coding/v1/usages Bearer tok-k"]
|
||||
);
|
||||
fs.writeFileSync(file, JSON.stringify({ access_token: "tok-k", expires_at: 1 }));
|
||||
__resetCache();
|
||||
assert.strictEqual((await kimi()).error, "ETOKENEXPIRED");
|
||||
assert.strictEqual(f.asked.filter((a) => a.includes("kimi")).length, 1);
|
||||
} finally {
|
||||
f.restore();
|
||||
fs.rmSync(file, { force: true });
|
||||
__resetCache();
|
||||
}
|
||||
});
|
||||
|
||||
test("OpenCode Go: opencode's own `opencode-go` key asks opencode.ai for its meters", async () => {
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { quotas, __resetCache } = require("../lib/quota");
|
||||
const dir = path.join(process.env.XDG_DATA_HOME, "opencode");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const file = path.join(dir, "auth.json");
|
||||
const f = stubFetch({
|
||||
"https://opencode.ai/zen/go/v1/usage": [
|
||||
200,
|
||||
{
|
||||
usage: {
|
||||
rolling: { percent: 10, resetsAt: "2099-01-01T05:00:00Z" },
|
||||
weekly: { percent: 55.5, resetsAt: "2099-01-07T00:00:00Z" },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const go = async () => (await quotas()).tools.find((t) => t.id === "opencode");
|
||||
try {
|
||||
__resetCache();
|
||||
// A login with no Go key (Copilot through opencode, say) is no row.
|
||||
fs.writeFileSync(file, JSON.stringify({ "github-copilot": { type: "oauth", access: "a" } }));
|
||||
assert.strictEqual(await go(), undefined);
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
"opencode-go": { type: "api", key: "sk-go" },
|
||||
other: { type: "api", key: "x" },
|
||||
})
|
||||
);
|
||||
__resetCache();
|
||||
assert.deepStrictEqual(await go(), {
|
||||
id: "opencode",
|
||||
active: true,
|
||||
plan: "Go",
|
||||
windows: [
|
||||
{ label: "5h", usedPercent: 10, resetsAt: "2099-01-01T05:00:00.000Z" },
|
||||
{ label: "wk", usedPercent: 55.5, resetsAt: "2099-01-07T00:00:00.000Z" },
|
||||
],
|
||||
});
|
||||
assert.deepStrictEqual(
|
||||
f.asked.filter((a) => a.includes("opencode")),
|
||||
["https://opencode.ai/zen/go/v1/usage Bearer sk-go"]
|
||||
);
|
||||
} finally {
|
||||
f.restore();
|
||||
fs.rmSync(file, { force: true });
|
||||
__resetCache();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @file server-s3-quota.test.js
|
||||
* @description S3 plan, P4: plan quota per member home. `quotas({home})`
|
||||
* reads Claude, Codex, Cursor, Grok, Kimi Code and OpenCode Go from that
|
||||
* reads Claude, Codex and Cursor from that
|
||||
* Linux account's home only — never the host's logins, never agy — and only
|
||||
* files the member owns (review fix F3: a FIFO or another account's file is
|
||||
* not read). `GET /api/pricing/quota` gives a developer or an analyst their
|
||||
@@ -32,16 +32,12 @@ process.env.CLAUDE_HOME = path.join(HOST, ".claude");
|
||||
process.env.CODEX_HOME = path.join(HOST, ".codex");
|
||||
process.env.XDG_CONFIG_HOME = path.join(HOST, ".config");
|
||||
process.env.AGENTHUB_CURSOR_DIR = path.join(HOST, ".cursor");
|
||||
process.env.GROK_HOME = path.join(HOST, ".grok");
|
||||
process.env.KIMI_CODE_HOME = path.join(HOST, ".kimi-code");
|
||||
process.env.XDG_DATA_HOME = path.join(HOST, ".local", "share");
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const auth = require("../lib/auth");
|
||||
const linux = require("../lib/linux-accounts");
|
||||
const runAs = require("../lib/run-as");
|
||||
const { quotas, __resetCache } = require("../lib/quota");
|
||||
const { Database } = require("../db");
|
||||
|
||||
const LATER = Date.now() + 3600_000;
|
||||
const write = (file, body) => {
|
||||
@@ -76,26 +72,6 @@ function seedHome(home, who, { codexPercent }) {
|
||||
write(path.join(home, ".cursor", "cli-config.json"), {
|
||||
authInfo: { email: `${who}@cursor`, userId: "u1" },
|
||||
});
|
||||
write(path.join(home, ".grok", "auth.json"), {
|
||||
"https://auth.x.ai": { key: `${who}-grok`, expires_at: new Date(LATER).toISOString() },
|
||||
});
|
||||
write(path.join(home, ".kimi-code", "credentials", "kimi-code.json"), {
|
||||
access_token: `${who}-kimi`,
|
||||
expires_at: Math.floor(LATER / 1000),
|
||||
});
|
||||
}
|
||||
|
||||
/** OpenCode 2 keeps its key in `opencode.db` (WAL), not `auth.json`. */
|
||||
function seedOpenCodeDb(home, key) {
|
||||
const dir = path.join(home, ".local", "share", "opencode");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const db = new Database(path.join(dir, "opencode.db"));
|
||||
db.pragma?.("journal_mode = WAL");
|
||||
db.exec(
|
||||
"CREATE TABLE credential (integration_id TEXT, value TEXT, active INT, time_created INT)"
|
||||
);
|
||||
db.prepare("INSERT INTO credential VALUES ('opencode-go', ?, 1, 1)").run(JSON.stringify({ key }));
|
||||
db.close();
|
||||
}
|
||||
|
||||
let asked = [];
|
||||
@@ -125,11 +101,7 @@ const setUid = (uid) => {
|
||||
|
||||
before(async () => {
|
||||
seedHome(HOST, "host", { codexPercent: 77 });
|
||||
write(path.join(HOST, ".local", "share", "opencode", "auth.json"), {
|
||||
"opencode-go": { type: "api", key: "host-opencode" },
|
||||
});
|
||||
seedHome(MIA, "mia", { codexPercent: 21 });
|
||||
seedOpenCodeDb(MIA, "mia-opencode");
|
||||
setUid(process.getuid());
|
||||
fs.writeFileSync(
|
||||
process.env.AGENTHUB_SERVER_CONF,
|
||||
@@ -159,27 +131,16 @@ describe("quotas({home}) reads a member's own logins", () => {
|
||||
__resetCache();
|
||||
asked = [];
|
||||
const { tools } = await quotas({ home: MIA });
|
||||
assert.deepEqual(tools.map((t) => t.id).sort(), [
|
||||
"claude",
|
||||
"codex",
|
||||
"cursor",
|
||||
"grok",
|
||||
"kimi",
|
||||
"opencode",
|
||||
]);
|
||||
assert.deepEqual(tools.map((t) => t.id).sort(), ["claude", "codex", "cursor"]);
|
||||
const codex = tools.find((t) => t.id === "codex");
|
||||
assert.equal(codex.account, "mia@codex");
|
||||
assert.equal(codex.windows[0].usedPercent, 21);
|
||||
assert.equal(tools.find((t) => t.id === "cursor").account, "mia@cursor");
|
||||
assert.ok(asked.length >= 6, asked.join(","));
|
||||
assert.ok(asked.length >= 3, asked.join(","));
|
||||
assert.ok(
|
||||
asked.every((a) => a.includes("mia-")),
|
||||
`only mia's tokens leave: ${asked.join(",")}`
|
||||
);
|
||||
assert.ok(
|
||||
asked.some((a) => a.includes("mia-opencode")),
|
||||
"OpenCode 2's db is read"
|
||||
);
|
||||
assert.ok(tools.every((t) => t.label === undefined || t.label === null));
|
||||
});
|
||||
|
||||
@@ -195,8 +156,10 @@ describe("quotas({home}) reads a member's own logins", () => {
|
||||
});
|
||||
|
||||
it("reads no file the member does not own, and never blocks on a FIFO", async () => {
|
||||
fs.mkdirSync(path.join(BOB, ".grok"), { recursive: true });
|
||||
require("node:child_process").execFileSync("mkfifo", [path.join(BOB, ".grok", "auth.json")]);
|
||||
fs.mkdirSync(path.join(BOB, ".config", "cursor"), { recursive: true });
|
||||
require("node:child_process").execFileSync("mkfifo", [
|
||||
path.join(BOB, ".config", "cursor", "auth.json"),
|
||||
]);
|
||||
__resetCache();
|
||||
assert.deepEqual((await quotas({ home: BOB })).tools, []);
|
||||
// mia's files now belong to another uid: none is read.
|
||||
|
||||
@@ -37,11 +37,11 @@ describe("what a publish copies", () => {
|
||||
|
||||
it("a file in an npm package: the package folder (scoped too)", () => {
|
||||
const plain = write(
|
||||
path.join(TMP, "npm", "lib", "node_modules", "opencode-ai", "bin", "oc.js"),
|
||||
path.join(TMP, "npm", "lib", "node_modules", "some-cli", "bin", "oc.js"),
|
||||
"#!/usr/bin/env node\n"
|
||||
);
|
||||
assert.deepEqual(shared.planFor(plain), {
|
||||
src: path.join(TMP, "npm", "lib", "node_modules", "opencode-ai"),
|
||||
src: path.join(TMP, "npm", "lib", "node_modules", "some-cli"),
|
||||
entry: path.join("bin", "oc.js"),
|
||||
});
|
||||
const scoped = write(
|
||||
|
||||
@@ -13,7 +13,7 @@ id follows, as one more argv element — `pty-run.js` emits them in place of
|
||||
`resume` when a start carries `resumeSessionId`, and only for an id that starts
|
||||
with a letter or digit. A tool without it (and without a `launcher`) refuses a
|
||||
resume by id (`EUNSUPPORTED`) rather than start fresh under a "resume".
|
||||
Built-in: omp, Grok and Cursor (`--resume`), OpenCode (`--session`). omp always gets `--allow-home`: started in its account's home (a workspace there) it would otherwise move to a temp dir, and its session would name that folder, not the workspace's. A session id may carry `_` (OpenCode's `ses_…`).
|
||||
Built-in: omp and Cursor (`--resume`). omp always gets `--allow-home`: started in its account's home (a workspace there) it would otherwise move to a temp dir, and its session would name that folder, not the workspace's. A session id may carry `_`.
|
||||
|
||||
## Install / update (A2)
|
||||
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"id": "grok",
|
||||
"label": "Grok Build",
|
||||
"detect": [
|
||||
"grok"
|
||||
],
|
||||
"prompt": {
|
||||
"mode": "argv",
|
||||
"separator": "--"
|
||||
},
|
||||
"model": {
|
||||
"flag": "--model"
|
||||
},
|
||||
"resume": [
|
||||
"--continue"
|
||||
],
|
||||
"resumeById": [
|
||||
"--resume"
|
||||
],
|
||||
"bypass": [
|
||||
"--permission-mode",
|
||||
"bypassPermissions"
|
||||
],
|
||||
"homepageUrl": "https://x.ai/cli",
|
||||
"faviconDomain": "x.ai",
|
||||
"install": {
|
||||
"kind": "script",
|
||||
"url": "https://x.ai/cli/install.sh"
|
||||
},
|
||||
"update": {
|
||||
"kind": "self",
|
||||
"args": [
|
||||
"update"
|
||||
]
|
||||
},
|
||||
"config": [
|
||||
{
|
||||
"path": "~/.grok/config.toml"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"id": "opencode",
|
||||
"label": "OpenCode",
|
||||
"detect": [
|
||||
"opencode"
|
||||
],
|
||||
"prompt": {
|
||||
"mode": "flag",
|
||||
"flag": "--prompt"
|
||||
},
|
||||
"model": {
|
||||
"flag": "--model"
|
||||
},
|
||||
"resume": [
|
||||
"--continue"
|
||||
],
|
||||
"resumeById": [
|
||||
"--session"
|
||||
],
|
||||
"homepageUrl": "https://opencode.ai/docs/cli/",
|
||||
"install": {
|
||||
"kind": "npm",
|
||||
"package": "opencode-ai"
|
||||
},
|
||||
"update": {
|
||||
"kind": "npm"
|
||||
},
|
||||
"config": [
|
||||
{
|
||||
"path": "~/.config/opencode/opencode.json",
|
||||
"format": "jsonc"
|
||||
},
|
||||
{
|
||||
"path": "~/.config/opencode/opencode.jsonc"
|
||||
}
|
||||
]
|
||||
}
|
||||
+6
-17
@@ -964,9 +964,15 @@ try {
|
||||
// (`captureOutputs`), not pinned by hand. `suggested_owner_id`: for a row with
|
||||
// no owner, the user whose folder the session ran in — they may claim it or
|
||||
// say it is not theirs (`claim`/`decline`); an admin assigns any (`assign`).
|
||||
// `version`: a skill snapshot's content hash; `source_hash`: its source
|
||||
// folder's hash when last taken — a skill follows its owner's folder and an
|
||||
// installer whose copy differs from `version` is offered Update
|
||||
// (`syncSkillSources`/`updateCheck`). NULL until first computed.
|
||||
for (const [col, ddl] of [
|
||||
["auto", "auto INTEGER NOT NULL DEFAULT 0"],
|
||||
["suggested_owner_id", "suggested_owner_id INTEGER"],
|
||||
["version", "version TEXT"],
|
||||
["source_hash", "source_hash TEXT"],
|
||||
]) {
|
||||
try {
|
||||
db.prepare(`SELECT ${col} FROM artifacts LIMIT 1`).get();
|
||||
@@ -1486,23 +1492,6 @@ db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_omp_sessions_session ON omp_sessions(session_id);
|
||||
`);
|
||||
|
||||
// Which OpenCode stores have been read at what mtime, and per root session the
|
||||
// newest `time_updated` written (server/lib/opencode-usage.js) — the same
|
||||
// "deleted stays deleted" rule.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS opencode_stores (
|
||||
path TEXT PRIMARY KEY,
|
||||
mtime_ms INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS opencode_sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
store TEXT NOT NULL,
|
||||
time_updated INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
`);
|
||||
|
||||
// Which Cursor agent transcripts have been read, at what mtime
|
||||
// (server/lib/cursor-usage.js) — the same "deleted stays deleted" rule.
|
||||
db.exec(`
|
||||
|
||||
+5
-11
@@ -793,17 +793,11 @@ function startBackgroundServices() {
|
||||
} catch (err) {
|
||||
console.warn("cursor usage poll failed to start:", err.message);
|
||||
}
|
||||
// Oh My Pi and OpenCode sessions, subagents and token usage, from each CLI's
|
||||
// own session store (omp-usage.js, opencode-usage.js).
|
||||
for (const [name, mod, start] of [
|
||||
["omp", "./lib/omp-usage", "startOmpUsagePoll"],
|
||||
["opencode", "./lib/opencode-usage", "startOpencodeUsagePoll"],
|
||||
]) {
|
||||
try {
|
||||
require(mod)[start](broadcast);
|
||||
} catch (err) {
|
||||
console.warn(`${name} usage poll failed to start:`, err.message);
|
||||
}
|
||||
// Oh My Pi sessions, subagents and token usage, from its own session journals.
|
||||
try {
|
||||
require("./lib/omp-usage").startOmpUsagePoll(broadcast);
|
||||
} catch (err) {
|
||||
console.warn("omp usage poll failed to start:", err.message);
|
||||
}
|
||||
// Codex/Antigravity hooks the user installed forward through a script whose
|
||||
// content names this build; point it here (the command in the tool's
|
||||
|
||||
@@ -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
@@ -226,7 +226,11 @@ function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } =
|
||||
).all(...params);
|
||||
const owners = new Map();
|
||||
const installed = installedCheck(req);
|
||||
const out = rows.map((r) => ({ ...view(r, req, owners), installed: installed(r) }));
|
||||
const updatable = updateCheck(req);
|
||||
const out = rows.map((r) => {
|
||||
const isIn = installed(r);
|
||||
return { ...view(r, req, owners), installed: isIn, updateAvailable: isIn && updatable(r) };
|
||||
});
|
||||
return scope === "mine" && req?.user ? out.filter((a) => a.mine || a.installed) : out;
|
||||
}
|
||||
|
||||
@@ -753,7 +757,7 @@ function walkSkill(dir) {
|
||||
total += st.size;
|
||||
if (files.length >= SKILL_MAX_FILES) throw fail("ETOOBIG", "the skill has too many files");
|
||||
if (total > SKILL_MAX_BYTES) throw fail("ETOOBIG", "the skill is larger than 5 MB");
|
||||
files.push({ abs: childAbs, path: childRel, size: st.size });
|
||||
files.push({ abs: childAbs, path: childRel, size: st.size, mtimeMs: st.mtimeMs });
|
||||
}
|
||||
};
|
||||
walk(dir, "", 0);
|
||||
@@ -822,6 +826,7 @@ function storeSkill(req, { name, files, title, description, laneId = null }) {
|
||||
const snap = newSnapshotDir();
|
||||
let total = 0;
|
||||
const recorded = [];
|
||||
const hashed = [];
|
||||
try {
|
||||
for (const f of files) {
|
||||
const data = typeof f.data === "function" ? f.data() : f.data;
|
||||
@@ -831,6 +836,7 @@ function storeSkill(req, { name, files, title, description, laneId = null }) {
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(target, data, { mode: 0o600 });
|
||||
recorded.push({ path: f.path, size: data.length });
|
||||
hashed.push({ path: f.path, data });
|
||||
}
|
||||
} catch (err) {
|
||||
dropSnapshot(snap.id);
|
||||
@@ -845,20 +851,30 @@ function storeSkill(req, { name, files, title, description, laneId = null }) {
|
||||
} catch {
|
||||
/* no frontmatter */
|
||||
}
|
||||
return view(
|
||||
insert({
|
||||
kind: "skill",
|
||||
title: cleanTitle(title, name),
|
||||
description: cleanDescription(description) || summary.slice(0, DESCRIPTION_MAX),
|
||||
owner_id: req?.user ? req.user.id : null,
|
||||
lane_id: laneId,
|
||||
source: name,
|
||||
snapshot: snap.id,
|
||||
files: JSON.stringify(recorded),
|
||||
size: total,
|
||||
}),
|
||||
req
|
||||
);
|
||||
const row = insert({
|
||||
kind: "skill",
|
||||
title: cleanTitle(title, name),
|
||||
description: cleanDescription(description) || summary.slice(0, DESCRIPTION_MAX),
|
||||
owner_id: req?.user ? req.user.id : null,
|
||||
lane_id: laneId,
|
||||
source: name,
|
||||
snapshot: snap.id,
|
||||
files: JSON.stringify(recorded),
|
||||
size: total,
|
||||
version: contentHash(hashed),
|
||||
});
|
||||
// The owner's folder as it is now: only a LATER change to it is taken
|
||||
// (`syncOne`), so an upload is never replaced by an older local copy.
|
||||
try {
|
||||
const h = sourceHash(row);
|
||||
if (h) {
|
||||
sql("UPDATE artifacts SET source_hash = ? WHERE id = ?").run(h, row.id);
|
||||
row.source_hash = h;
|
||||
}
|
||||
} catch {
|
||||
/* no folder */
|
||||
}
|
||||
return view(row, req);
|
||||
}
|
||||
|
||||
// ── skill: written or uploaded ────────────────────────────────────────────
|
||||
@@ -1569,10 +1585,22 @@ function editSkill(req, row, { name, description, content }) {
|
||||
throw err;
|
||||
}
|
||||
const desc = frontmatterField(text, "description");
|
||||
// The source folder as it is now, so this edit is not undone by it (`syncOne`).
|
||||
const folder = sourceHash({ ...row, source: n });
|
||||
sql(
|
||||
`UPDATE artifacts SET source = ?, snapshot = ?, files = ?, size = ?, description = ?,
|
||||
version = ?, source_hash = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
|
||||
).run(n, snap.id, JSON.stringify(next), total, cleanDescription(desc), row.id);
|
||||
).run(
|
||||
n,
|
||||
snap.id,
|
||||
JSON.stringify(next),
|
||||
total,
|
||||
cleanDescription(desc),
|
||||
contentHash(all),
|
||||
folder,
|
||||
row.id
|
||||
);
|
||||
dropSnapshot(row.snapshot);
|
||||
}
|
||||
|
||||
@@ -1756,6 +1784,237 @@ async function installSkill(req, row, { replace = false } = {}) {
|
||||
return { installed: paths.join(", "), paths, replaced: taken.length > 0 };
|
||||
}
|
||||
|
||||
// ── skill versions: follow the owner's folder, offer installers an Update ──
|
||||
|
||||
/** A skill's content id: sha256 over its files (path + bytes), path order. */
|
||||
function contentHash(files) {
|
||||
const h = crypto.createHash("sha256");
|
||||
for (const f of [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))) {
|
||||
h.update(f.path);
|
||||
h.update("\0");
|
||||
h.update(crypto.createHash("sha256").update(f.data).digest());
|
||||
}
|
||||
return h.digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
/** A skill row's snapshot version (`artifacts.version`), computed once for an older row. */
|
||||
function snapshotVersion(row) {
|
||||
if (row.version) return row.version;
|
||||
if (row.kind !== "skill" || !row.snapshot || !SNAPSHOT_RE.test(row.snapshot)) return null;
|
||||
try {
|
||||
const files = (row.files ? JSON.parse(row.files) : []).map((f) => ({
|
||||
path: f.path,
|
||||
data: fs.readFileSync(path.join(root(), row.snapshot, ...f.path.split("/"))),
|
||||
}));
|
||||
const v = contentHash(files);
|
||||
sql("UPDATE artifacts SET version = ? WHERE id = ?").run(v, row.id);
|
||||
row.version = v;
|
||||
return v;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// realpath → { sig, hash }: a folder is read again only once a file's name,
|
||||
// size or mtime changed (a stat walk is cheap; reading 5 MB per poll is not).
|
||||
const folderCache = new Map();
|
||||
const FOLDER_CACHE_MAX = 2000;
|
||||
|
||||
/**
|
||||
* A skill folder as it is on disk now — `{ real, hash, files, read() }` — or
|
||||
* null when it is missing, has no SKILL.md, is a link out of `top`, or breaks
|
||||
* a snapshot rule (`walkSkill`: no symlink, no secret, ≤ 5 MB). `read()`
|
||||
* answers its files' bytes, a member's only as theirs (`readFileSafe`).
|
||||
*/
|
||||
function folderState(dir, top) {
|
||||
let real;
|
||||
let topReal;
|
||||
try {
|
||||
real = fs.realpathSync(dir);
|
||||
topReal = fs.realpathSync(top);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!inside(topReal, real) || path.basename(real) !== path.basename(dir)) return null;
|
||||
let walked;
|
||||
try {
|
||||
walked = walkSkill(real);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!walked.files.some((f) => f.path === "SKILL.md")) return null;
|
||||
const sig = walked.files
|
||||
.map((f) => `${f.path}:${f.size}:${f.mtimeMs}`)
|
||||
.sort()
|
||||
.join("|");
|
||||
const read = () => {
|
||||
const runAs = require("./run-as");
|
||||
return walked.files.map((f) => ({
|
||||
path: f.path,
|
||||
data: runAs.readFileSafe(f.abs, null, { max: SKILL_MAX_BYTES }),
|
||||
}));
|
||||
};
|
||||
const hit = folderCache.get(real);
|
||||
if (hit && hit.sig === sig) return { real, hash: hit.hash, files: walked.files, read };
|
||||
let hash;
|
||||
try {
|
||||
hash = contentHash(read());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
folderCache.delete(real);
|
||||
folderCache.set(real, { sig, hash });
|
||||
if (folderCache.size > FOLDER_CACHE_MAX) folderCache.delete(folderCache.keys().next().value);
|
||||
return { real, hash, files: walked.files, read };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a skill row came from: `{dir, top}` — its workspace's
|
||||
* `.claude/skills/<name>` when it was taken from one, else its OWNER's
|
||||
* `~/.claude/skills/<name>` (the host's in local mode). Null for a row with no
|
||||
* owner in server mode, or an owner with no Claude config of their own.
|
||||
*/
|
||||
function sourceDirOf(row) {
|
||||
if (row.kind !== "skill" || !row.source || !SKILL_NAME_RE.test(row.source)) return null;
|
||||
if (row.lane_id != null) {
|
||||
const lane = sql("SELECT cwd FROM lanes WHERE id = ?").get(row.lane_id);
|
||||
if (!lane?.cwd) return null;
|
||||
return { dir: path.join(lane.cwd, ".claude", "skills", row.source), top: lane.cwd };
|
||||
}
|
||||
let claudeHome;
|
||||
if (row.owner_id == null) {
|
||||
if (isServer()) return null;
|
||||
claudeHome = getClaudeHome();
|
||||
} else {
|
||||
const user = sql("SELECT id, username, role FROM users WHERE id = ?").get(row.owner_id);
|
||||
if (!user) return null;
|
||||
try {
|
||||
claudeHome = ownAccount({ user }).claudeHome;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return { dir: path.join(claudeHome, "skills", row.source), top: claudeHome };
|
||||
}
|
||||
|
||||
/** The hash of a row's source folder now, or null when it has none. */
|
||||
function sourceHash(row) {
|
||||
const src = sourceDirOf(row);
|
||||
return src ? (folderState(src.dir, src.top)?.hash ?? null) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a skill row's NEW source folder as its snapshot, when the folder
|
||||
* changed since it was last taken (`source_hash`) and differs from the
|
||||
* snapshot. A change made in the Hub (`editSkill`) records the folder as it
|
||||
* was then, so it is not undone until the folder itself changes again: the
|
||||
* newer change wins. A new snapshot directory, then the old one dropped —
|
||||
* never a write into the one being read. Answers whether the row changed.
|
||||
*/
|
||||
function syncOne(row) {
|
||||
const src = sourceDirOf(row);
|
||||
if (!src) return false;
|
||||
const st = folderState(src.dir, src.top);
|
||||
if (!st || st.hash === row.source_hash) return false;
|
||||
if (st.hash === snapshotVersion(row)) {
|
||||
sql("UPDATE artifacts SET source_hash = ? WHERE id = ?").run(st.hash, row.id);
|
||||
return false;
|
||||
}
|
||||
let files;
|
||||
try {
|
||||
files = st.read();
|
||||
checkSkillFiles(files);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const snap = newSnapshotDir();
|
||||
let total = 0;
|
||||
const next = [];
|
||||
try {
|
||||
for (const f of files) {
|
||||
const target = path.join(snap.dir, ...f.path.split("/"));
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(target, f.data, { mode: 0o600 });
|
||||
total += f.data.length;
|
||||
next.push({ path: f.path, size: f.data.length });
|
||||
}
|
||||
} catch {
|
||||
dropSnapshot(snap.id);
|
||||
return false;
|
||||
}
|
||||
// The description follows SKILL.md's only while it still says what the old one did.
|
||||
const md = (list) => list.find((f) => f.path === "SKILL.md");
|
||||
let oldSummary = "";
|
||||
try {
|
||||
oldSummary = frontmatterField(
|
||||
fs.readFileSync(path.join(root(), row.snapshot, "SKILL.md"), "utf8"),
|
||||
"description"
|
||||
).slice(0, DESCRIPTION_MAX);
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
const newSummary = frontmatterField(md(files)?.data.toString("utf8") ?? "", "description");
|
||||
const description =
|
||||
!row.description || row.description === oldSummary
|
||||
? cleanDescription(newSummary) || row.description
|
||||
: row.description;
|
||||
sql(
|
||||
`UPDATE artifacts SET snapshot = ?, files = ?, size = ?, version = ?, source_hash = ?,
|
||||
description = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
|
||||
).run(snap.id, JSON.stringify(next), total, st.hash, st.hash, description, row.id);
|
||||
if (row.snapshot && SNAPSHOT_RE.test(row.snapshot)) dropSnapshot(row.snapshot);
|
||||
return true;
|
||||
}
|
||||
|
||||
const SYNC_EVERY_MS = 15 * 1000;
|
||||
let lastSync = 0;
|
||||
|
||||
/**
|
||||
* Bring every skill row up to its source folder (`syncOne`), at most once per
|
||||
* `SYNC_EVERY_MS` unless `force`. Runs when the Hub is listed. Answers the
|
||||
* rows that changed, for the route to announce.
|
||||
*/
|
||||
function syncSkillSources({ force = false } = {}) {
|
||||
if (!force && Date.now() - lastSync < SYNC_EVERY_MS) return [];
|
||||
lastSync = Date.now();
|
||||
const changedRows = [];
|
||||
for (const row of sql(
|
||||
"SELECT * FROM artifacts WHERE kind = 'skill' AND snapshot IS NOT NULL"
|
||||
).all()) {
|
||||
try {
|
||||
if (syncOne(row)) changedRows.push(sql("SELECT * FROM artifacts WHERE id = ?").get(row.id));
|
||||
} catch {
|
||||
/* one bad row never stops the rest */
|
||||
}
|
||||
}
|
||||
return changedRows;
|
||||
}
|
||||
|
||||
/**
|
||||
* `row => boolean`: an installed skill whose copy in the viewer's
|
||||
* `~/.claude/skills/<name>` (any account it runs as) differs from the row's
|
||||
* newest snapshot — the list offers **Update** for it. Pure reads.
|
||||
*/
|
||||
function updateCheck(req) {
|
||||
let accounts = null;
|
||||
return (row) => {
|
||||
if (row.kind !== "skill" || !row.source) return false;
|
||||
const version = snapshotVersion(row);
|
||||
if (!version) return false;
|
||||
if (accounts === null) {
|
||||
try {
|
||||
accounts = installAccounts(req);
|
||||
} catch {
|
||||
accounts = [];
|
||||
}
|
||||
}
|
||||
return accounts.some((a) => {
|
||||
const st = folderState(path.join(a.claudeHome, "skills", row.source), a.claudeHome);
|
||||
return !!st && st.hash !== version;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
let execImpl = promisify(require("node:child_process").execFile);
|
||||
function __setExecImpl(fn) {
|
||||
execImpl = fn;
|
||||
@@ -1815,6 +2074,16 @@ async function installPlugin(req, row) {
|
||||
async function install(req, id, opts = {}) {
|
||||
const row = load(req, id);
|
||||
installable(req, row);
|
||||
// Update: an installed skill's copies replaced by the newest snapshot (the
|
||||
// old ones moved aside like any replace). Not another install: no count, no
|
||||
// second row in who-installed.
|
||||
if (opts.update) {
|
||||
if (row.kind !== "skill") throw fail("EBADKIND", "only a skill is updated here");
|
||||
if (!installedCheck(req)(row)) throw fail("ENOTINSTALLED", `${row.source} is not installed`);
|
||||
const result = await installSkill(req, row, { replace: true });
|
||||
if (req?.user && row.owner_id !== req.user.id) audit(req, row, "updated");
|
||||
return result;
|
||||
}
|
||||
const result =
|
||||
row.kind === "skill" ? await installSkill(req, row, opts) : await installPlugin(req, row);
|
||||
sql("UPDATE artifacts SET installs = installs + 1 WHERE id = ?").run(row.id);
|
||||
@@ -1947,6 +2216,7 @@ module.exports = {
|
||||
update,
|
||||
remove,
|
||||
snapshotFile,
|
||||
syncSkillSources,
|
||||
install,
|
||||
uninstall,
|
||||
installsEnabled,
|
||||
|
||||
@@ -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
@@ -34,21 +34,6 @@
|
||||
* names no account and no plan. Cached like Claude; a failed run shows
|
||||
* the last figure with its `asOf`. Running agy may let it update itself;
|
||||
* `AGENTHUB_AGY_USAGE=0` never runs it.
|
||||
* - **Grok Build**: the CLI's `auth.x.ai` session in `$GROK_HOME/auth.json`
|
||||
* (default `~/.grok`), sent only to `cli-chat-proxy.grok.com`'s
|
||||
* `GET /v1/billing?format=credits`: `wk` from `creditUsagePercent`, `mo`
|
||||
* from `used / monthlyLimit`, plan `subscriptionTier`.
|
||||
* - **Kimi Code**: `$KIMI_CODE_HOME/credentials/kimi-code.json` (default
|
||||
* `~/.kimi-code`), sent only to `api.kimi.com`'s `GET /coding/v1/usages`:
|
||||
* `5h`, `wk`, `mo` from `limit_5h`/`limit_7d`/`limit_month_total`.
|
||||
* - **OpenCode Go**: opencode's own `opencode-go` API key — its `auth.json`
|
||||
* (OpenCode 1) else its `opencode.db` `credential` table (OpenCode 2),
|
||||
* under `$XDG_DATA_HOME/opencode` (default `~/.local/share`) — sent only to
|
||||
* `opencode.ai`'s `GET /zen/go/v1/usage`: `5h`, `wk`, `mo` meters. No Go
|
||||
* key (another provider's login) means no row.
|
||||
* Grok's and Kimi's tokens are used only while valid and never refreshed
|
||||
* (Kimi rotates its refresh token: a refresh here would sign the CLI out).
|
||||
* These three are Orca's readers (github.com/stablyai/orca, MIT), trimmed.
|
||||
*
|
||||
* Each tool also carries its signed-in `account` (an email), its `plan` (the
|
||||
* subscription's name as the tool reports it: Claude's `subscriptionType`,
|
||||
@@ -59,9 +44,8 @@
|
||||
*
|
||||
* Server mode with S3 (`quotas({home})`): a member's plan is read from THEIR
|
||||
* Linux account's home only — `.claude`, `.codex`, `.config/cursor` +
|
||||
* `.cursor`, `.grok`, `.kimi-code`, `.local/share/opencode`, never the
|
||||
* host's env overrides — through `run-as`'s owner-checked reads (F3). No
|
||||
* saved Claude profiles (the Profiles modal is the host's) and no agy: its
|
||||
* `.cursor`, never the host's env overrides — through `run-as`'s
|
||||
* owner-checked reads (F3). No saved Claude profiles (the Profiles modal is the host's) and no agy: its
|
||||
* `/usage` needs the self-updating binary, so it stays host-only.
|
||||
*
|
||||
* Only percentages, window lengths, reset/expiry times, the plan's name and
|
||||
@@ -80,9 +64,7 @@ const { readActiveAuth, readProfile, listProfiles } = require("./cc-profiles");
|
||||
const { fetchUsage, fetchOwner } = require("./cc-usage");
|
||||
const { allRolloutFiles } = require("./codex-ingest");
|
||||
const { getCursorDir } = require("./cursor-usage");
|
||||
const { Database } = require("../db");
|
||||
const { readFileSafe, openSafe, ownerForRead } = require("./run-as");
|
||||
const { s3Enabled } = require("./linux-accounts");
|
||||
const { readFileSafe, openSafe } = require("./run-as");
|
||||
|
||||
const CLAUDE_TTL_MS = 5 * 60_000;
|
||||
const FORCE_MIN_MS = 30_000; // a Refresh click still cannot hammer the endpoint
|
||||
@@ -91,9 +73,6 @@ const TAIL_BYTES = 512 * 1024;
|
||||
const CODEX_FILES = 5;
|
||||
const CURSOR_USAGE_URL = "https://cursor.com/api/usage-summary";
|
||||
const TIMEOUT_MS = 10_000;
|
||||
const GROK_BILLING_URL = "https://cli-chat-proxy.grok.com/v1/billing?format=credits";
|
||||
const KIMI_USAGE_URL = "https://api.kimi.com/coding/v1/usages";
|
||||
const OPENCODE_GO_URL = "https://opencode.ai/zen/go/v1/usage";
|
||||
const AGY_TIMEOUT_MS = 60_000;
|
||||
const AGY_MIN_VERSION = [1, 1, 11];
|
||||
const AGY_WINDOW = { "5h": "5h", weekly: "wk" };
|
||||
@@ -364,9 +343,6 @@ function toolDirs(home) {
|
||||
codex: home ? path.join(home, ".codex") : getCodexHome(),
|
||||
cursorConfig: path.join(env("XDG_CONFIG_HOME") || path.join(h, ".config"), "cursor"),
|
||||
cursor: home ? path.join(home, ".cursor") : getCursorDir(),
|
||||
grok: env("GROK_HOME") || path.join(h, ".grok"),
|
||||
kimi: env("KIMI_CODE_HOME") || path.join(h, ".kimi-code"),
|
||||
opencode: path.join(env("XDG_DATA_HOME") || path.join(h, ".local", "share"), "opencode"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -480,160 +456,6 @@ async function antigravityQuota() {
|
||||
}
|
||||
}
|
||||
|
||||
const isoOf = (v) => {
|
||||
const ms = Date.parse(v);
|
||||
return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
|
||||
};
|
||||
|
||||
/** A Bearer GET to a tool's own host: its windows, or the row's error. */
|
||||
async function bearerWindows(who, url, token, headers, toWindows) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { Accept: "application/json", Authorization: `Bearer ${token}`, ...headers },
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const expired = res.status === 401 || res.status === 403;
|
||||
return { ...who, windows: [], error: expired ? "ETOKENEXPIRED" : "EUPSTREAM" };
|
||||
}
|
||||
return { ...who, ...toWindows(await res.json()) };
|
||||
} catch {
|
||||
return { ...who, windows: [], error: "EUPSTREAM" };
|
||||
}
|
||||
}
|
||||
|
||||
/** Grok Build's `auth.x.ai` session and its billing view. */
|
||||
async function grokQuota(dirs = toolDirs()) {
|
||||
const sessions = readJson(path.join(dirs.grok, "auth.json"));
|
||||
if (!sessions || typeof sessions !== "object") return null;
|
||||
const xai = Object.entries(sessions)
|
||||
.filter(([issuer, s]) => /^https:\/\/auth\.x\.ai(::|$)/.test(issuer) && s?.key)
|
||||
.map(([, s]) => s);
|
||||
const live = (s) => !s.expires_at || fresh(Date.parse(s.expires_at));
|
||||
const s = xai.find(live) || xai[0];
|
||||
if (!s) return null;
|
||||
const who = {
|
||||
id: "grok",
|
||||
active: true,
|
||||
account: s.email || null,
|
||||
plan: null,
|
||||
expiresAt: s.expires_at ? isoOf(s.expires_at) : null,
|
||||
};
|
||||
if (!live(s)) return { ...who, windows: [], error: "ETOKENEXPIRED" };
|
||||
const headers = {
|
||||
"X-XAI-Token-Auth": "xai-grok-cli",
|
||||
...(s.user_id && { "x-userid": String(s.user_id) }),
|
||||
};
|
||||
return bearerWindows(who, GROK_BILLING_URL, s.key, headers, (body) => {
|
||||
const c = body?.config || body || {};
|
||||
const windows = [];
|
||||
// ponytail: a reply with no percent shows no week; Orca infers 0 from
|
||||
// the period fields — add that if a real account ever answers so.
|
||||
if (typeof c.creditUsagePercent === "number") {
|
||||
windows.push({
|
||||
label: "wk",
|
||||
usedPercent: c.creditUsagePercent,
|
||||
resetsAt: isoOf(c.currentPeriod?.end ?? c.billingPeriodEnd),
|
||||
});
|
||||
}
|
||||
const limit = Number(c.monthlyLimit?.val);
|
||||
const used = Number(c.used?.val);
|
||||
if (limit > 0 && Number.isFinite(used)) {
|
||||
windows.push({
|
||||
label: "mo",
|
||||
usedPercent: (used / limit) * 100,
|
||||
resetsAt: isoOf(c.billingPeriodEnd),
|
||||
});
|
||||
}
|
||||
return { plan: c.subscriptionTier || null, windows };
|
||||
});
|
||||
}
|
||||
|
||||
/** Kimi Code's OAuth token and its managed-plan usage. */
|
||||
async function kimiQuota(dirs = toolDirs()) {
|
||||
const cred = readJson(path.join(dirs.kimi, "credentials", "kimi-code.json"));
|
||||
if (typeof cred?.access_token !== "string" || !cred.access_token) return null;
|
||||
const exp = Number(cred.expires_at) * 1000;
|
||||
const who = { id: "kimi", active: true, expiresAt: exp ? iso(exp) : null };
|
||||
if (exp && !fresh(exp)) return { ...who, windows: [], error: "ETOKENEXPIRED" };
|
||||
return bearerWindows(who, KIMI_USAGE_URL, cred.access_token, {}, (body) => ({
|
||||
windows: [
|
||||
["5h", body?.usages?.limit_5h],
|
||||
["wk", body?.usages?.limit_7d],
|
||||
["mo", body?.usages?.limit_month_total],
|
||||
]
|
||||
.filter(([, u]) => u && Number.isFinite(Number(u.used_ratio)))
|
||||
.map(([label, u]) => ({
|
||||
label,
|
||||
usedPercent: Number(u.used_ratio) * 100,
|
||||
resetsAt: isoOf(u.reset_time),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* A member's `opencode.db`, copied (with its WAL) from owner-checked reads
|
||||
* into a private temp dir — SQLite opens by NAME, which the member could swap
|
||||
* between a check and the open. The host's own is opened in place.
|
||||
*/
|
||||
function openCodeDbPath(dir, tmp) {
|
||||
const db = path.join(dir, "opencode.db");
|
||||
if (!s3Enabled() || !ownerForRead(db)) return db;
|
||||
const copy = path.join(tmp(), "opencode.db");
|
||||
fs.writeFileSync(copy, readFileSafe(db, null, { max: 256 << 20 })); // ponytail: whole file in memory, capped
|
||||
try {
|
||||
fs.writeFileSync(`${copy}-wal`, readFileSafe(`${db}-wal`, null, { max: 256 << 20 }));
|
||||
} catch {
|
||||
/* no WAL: everything is in the main file */
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
/** opencode's `opencode-go` key: `auth.json` (v1), else `opencode.db` (v2). */
|
||||
function openCodeGoKey(dirs = toolDirs()) {
|
||||
const dir = dirs.opencode;
|
||||
const entry = readJson(path.join(dir, "auth.json"))?.["opencode-go"];
|
||||
if (entry?.type === "api" && typeof entry.key === "string" && entry.key) return entry.key;
|
||||
let conn;
|
||||
let tmpDir = null;
|
||||
const tmp = () => (tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-opencode-")));
|
||||
try {
|
||||
const file = openCodeDbPath(dir, tmp);
|
||||
// The private copy is writable so SQLite can replay its WAL.
|
||||
conn = new Database(file, { readonly: !tmpDir, fileMustExist: true });
|
||||
const rows = conn
|
||||
.prepare(
|
||||
"SELECT value FROM credential WHERE integration_id = 'opencode-go' ORDER BY active DESC, time_created DESC LIMIT 8"
|
||||
)
|
||||
.all();
|
||||
for (const r of rows) {
|
||||
const v = JSON.parse(r.value);
|
||||
if (typeof v?.key === "string" && v.key) return v.key;
|
||||
}
|
||||
} catch {
|
||||
/* no OpenCode 2 database, or no credential table (OpenCode 1) */
|
||||
} finally {
|
||||
conn?.close();
|
||||
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function openCodeGoQuota(dirs = toolDirs()) {
|
||||
const key = openCodeGoKey(dirs);
|
||||
if (!key) return null;
|
||||
const who = { id: "opencode", active: true, plan: "Go" };
|
||||
return bearerWindows(who, OPENCODE_GO_URL, key, {}, (body) => ({
|
||||
windows: [
|
||||
["5h", body?.usage?.rolling],
|
||||
["wk", body?.usage?.weekly],
|
||||
["mo", body?.usage?.monthly],
|
||||
]
|
||||
.filter(([, m]) => typeof m?.percent === "number")
|
||||
.map(([label, m]) => ({ label, usedPercent: m.percent, resetsAt: isoOf(m.resetsAt) })),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every tool's quota row. `home`: a member's Linux home (S3) — that account's
|
||||
* logins only, and no agy; omitted, the host's.
|
||||
@@ -641,23 +463,12 @@ async function openCodeGoQuota(dirs = toolDirs()) {
|
||||
async function quotas({ force = false, home = null } = {}) {
|
||||
const dirs = toolDirs(home);
|
||||
const key = (k) => (home ? `${home}|${k}` : k);
|
||||
const [claude, cursor, antigravity, grok, kimi, opencode] = await Promise.all([
|
||||
const [claude, cursor, antigravity] = await Promise.all([
|
||||
claudeQuotas(force, home),
|
||||
cached(key("cursor"), force, () => cursorQuota(dirs)),
|
||||
home ? null : cached("antigravity", force, antigravityQuota),
|
||||
cached(key("grok"), force, () => grokQuota(dirs)),
|
||||
cached(key("kimi"), force, () => kimiQuota(dirs)),
|
||||
cached(key("opencode"), force, () => openCodeGoQuota(dirs)),
|
||||
]);
|
||||
const tools = [
|
||||
...claude,
|
||||
codexQuota(dirs.codex),
|
||||
antigravity,
|
||||
cursor,
|
||||
grok,
|
||||
kimi,
|
||||
opencode,
|
||||
].filter(Boolean);
|
||||
const tools = [...claude, codexQuota(dirs.codex), antigravity, cursor].filter(Boolean);
|
||||
return { tools, checkedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,9 @@ router.get(
|
||||
"/",
|
||||
wrap((req, res) => {
|
||||
const q = req.query;
|
||||
// A skill follows its owner's folder: take what changed there first
|
||||
// (throttled), and tell every open Hub page.
|
||||
for (const row of artifacts.syncSkillSources()) changed(row);
|
||||
res.json({
|
||||
items: artifacts.list(req, {
|
||||
scope: q.scope,
|
||||
@@ -358,6 +361,7 @@ router.post(
|
||||
wrap(async (req, res) => {
|
||||
const result = await artifacts.install(req, req.params.id, {
|
||||
replace: req.body?.replace === true,
|
||||
update: req.body?.update === true,
|
||||
});
|
||||
res.json(result);
|
||||
})
|
||||
|
||||
+12
-23
@@ -1473,12 +1473,13 @@ function antigravityHookInput(hookType, data) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A Grok Build (`grok`) hook. Grok runs the hooks in `~/.claude/settings.json`
|
||||
* itself (its Claude compatibility, on by default), so AgentHub's own Claude
|
||||
* hook reaches here with no `tool` — told apart by the camelCase keys only
|
||||
* Grok sends beside Claude's snake_case aliases (`hookEventName`,
|
||||
* `workspaceRoot`). Its `transcript_path` is Grok's file, never a Claude
|
||||
* transcript, so it is dropped before anything could parse it.
|
||||
* A Grok Build hook. Grok is not an AgentHub tool, but it runs the hooks in
|
||||
* `~/.claude/settings.json` itself (its Claude compatibility, on by default),
|
||||
* so AgentHub's own Claude hook still reaches here from a Grok the user
|
||||
* installed, with no `tool` — told apart by the camelCase keys only Grok sends
|
||||
* beside Claude's snake_case aliases (`hookEventName`, `workspaceRoot`) and
|
||||
* dropped, or it would land as a Claude session whose `transcript_path` is
|
||||
* Grok's file.
|
||||
*/
|
||||
const isGrokPayload = (data) =>
|
||||
!!data &&
|
||||
@@ -1486,18 +1487,9 @@ const isGrokPayload = (data) =>
|
||||
typeof data.hookEventName === "string" &&
|
||||
typeof data.workspaceRoot === "string";
|
||||
|
||||
function grokHookInput(hookType, data) {
|
||||
if (typeof data.session_id !== "string" || !data.session_id) return null;
|
||||
const d = { ...data };
|
||||
delete d.transcript_path;
|
||||
delete d.agent_transcript_path;
|
||||
return { hookType, data: d };
|
||||
}
|
||||
|
||||
const HOOK_TOOLS = {
|
||||
codex: codexHookInput,
|
||||
antigravity: antigravityHookInput,
|
||||
grok: grokHookInput,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1564,14 +1556,11 @@ function ingestHook(body, pushSource = null, peer = { kind: "host" }) {
|
||||
}
|
||||
const ignored = { status: 200, body: { ok: true, ignored: true } };
|
||||
// Which CLI sent it: `codex` from Codex's hooks (codex-hooks.js),
|
||||
// `antigravity` from agy's (antigravity-hooks.js), `grok` for Grok running
|
||||
// the Claude hooks (by its payload's own keys), anything else is Claude
|
||||
// Code — an allowlist, never a value stored as sent.
|
||||
const tool = Object.hasOwn(HOOK_TOOLS, body.tool)
|
||||
? body.tool
|
||||
: isGrokPayload(data)
|
||||
? "grok"
|
||||
: "claude";
|
||||
// `antigravity` from agy's (antigravity-hooks.js), anything else is Claude
|
||||
// Code — an allowlist, never a value stored as sent. Grok running the
|
||||
// Claude hooks is no AgentHub tool: dropped.
|
||||
const tool = Object.hasOwn(HOOK_TOOLS, body.tool) ? body.tool : "claude";
|
||||
if (tool === "claude" && isGrokPayload(data)) return ignored;
|
||||
if (tool !== "claude") {
|
||||
if (!data || typeof data !== "object") return ignored;
|
||||
require("../lib/hook-auth").dropForeignPaths(peer, data);
|
||||
|
||||
+16
-71
@@ -843,25 +843,6 @@ router.get("/:id/transcripts", async (req, res) => {
|
||||
: [];
|
||||
return res.json({ transcripts: list });
|
||||
}
|
||||
// OpenCode: a subagent is a child session of the store, tied to the agent
|
||||
// row its poller made.
|
||||
if (session.tool === "opencode") {
|
||||
const list = [
|
||||
{ id: "main", name: "Main Agent", type: "main", has_transcript: true, db_agent_id: null },
|
||||
];
|
||||
for (const sub of require("../lib/opencode-usage").subagentSessions(req.params.id)) {
|
||||
list.push({
|
||||
id: sub.id,
|
||||
name: sub.name,
|
||||
type: "subagent",
|
||||
subagent_type: "task",
|
||||
has_transcript: true,
|
||||
db_agent_id: stmts.getAgent.get(sub.rowId) ? sub.rowId : null,
|
||||
});
|
||||
}
|
||||
return res.json({ transcripts: list });
|
||||
}
|
||||
|
||||
const result = [];
|
||||
|
||||
// Query database agent list for db_agent_id association
|
||||
@@ -1101,20 +1082,6 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
const isCursor = session.tool === "cursor";
|
||||
// An agy conversation's step log (`transcript_full.jsonl`), found by antigravity-usage.js.
|
||||
const isAgy = session.tool === "antigravity";
|
||||
// An OpenCode conversation lives in its SQLite store, not a JSONL file: read
|
||||
// whole (one session's rows), then paged like the files below.
|
||||
if (session.tool === "opencode") {
|
||||
let all = null;
|
||||
try {
|
||||
all = require("../lib/opencode-usage").transcriptMessages(
|
||||
req.params.id,
|
||||
isSubagentFile ? agentId : null
|
||||
);
|
||||
} catch {
|
||||
all = null;
|
||||
}
|
||||
return res.json(pageMessages(all || [], { limit, afterLine, beforeLine, offset }));
|
||||
}
|
||||
if (isOmp) {
|
||||
jsonlPath = require("../lib/omp-usage").journalPath(req.params.id, agentId || "");
|
||||
} else if (isCursor) {
|
||||
@@ -1177,6 +1144,9 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
: (e, n) => parseMessage(e, n);
|
||||
|
||||
const parseMessage = createClaudeLineParser(isSubagentFile);
|
||||
const claudeFile = !isCodex && !isOmp && !isCursor && !isAgy;
|
||||
// The edits of the turn the page starts inside of (agent-edits.js).
|
||||
const carry = claudeFile ? require("../lib/agent-edits").turnCarry() : null;
|
||||
|
||||
if (afterLine !== null) {
|
||||
// Incremental mode: skip lines until after afterLine, collect up to limit, then stop.
|
||||
@@ -1232,7 +1202,8 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
messages.push(message);
|
||||
// Sliding window: only keep the last `limit` messages
|
||||
if (messages.length > limit) {
|
||||
messages.shift();
|
||||
const dropped = messages.shift();
|
||||
carry?.drop(dropped);
|
||||
}
|
||||
}
|
||||
if (total > limit) hasMore = true;
|
||||
@@ -1283,13 +1254,22 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
messages.push(message);
|
||||
// Sliding window: only keep the last `limit` messages in memory
|
||||
if (messages.length > limit) {
|
||||
messages.shift();
|
||||
const dropped = messages.shift();
|
||||
carry?.drop(dropped);
|
||||
}
|
||||
}
|
||||
// If we shifted any messages out, there are more
|
||||
hasMore = total > limit;
|
||||
}
|
||||
|
||||
// A Claude subagent's own Write/Edit calls are in ITS transcript: put the
|
||||
// files on its Agent result, so the turn's "Edited N files" counts them.
|
||||
let turnEdits = null;
|
||||
if (claudeFile) {
|
||||
await require("../lib/agent-edits").attachAgentEdits(messages, req.params.id, session.cwd);
|
||||
turnEdits = await carry.result(messages, req.params.id, session.cwd);
|
||||
}
|
||||
|
||||
const lastLine = messages.length > 0 ? messages[messages.length - 1].line : 0;
|
||||
const firstLine = messages.length > 0 ? messages[0].line : 0;
|
||||
|
||||
@@ -1304,6 +1284,7 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
has_more: hasMore,
|
||||
last_line: lastLine,
|
||||
first_line: firstLine,
|
||||
...(turnEdits ? { turn_edits: turnEdits } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
res.json({ messages: [], total: 0, has_more: false, last_line: 0, first_line: 0 });
|
||||
@@ -1578,42 +1559,6 @@ function agyMessage(entry, num) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The transcript route's paging over a conversation already in memory (an
|
||||
* OpenCode store): message N is "line" N, so `after`/`before`/`offset` and the
|
||||
* default latest-`limit` window answer exactly as they do over a file.
|
||||
*/
|
||||
function pageMessages(all, { limit, afterLine, beforeLine, offset }) {
|
||||
const lined = all.map((m, i) => ({ ...m, line: i + 1 }));
|
||||
let picked;
|
||||
let hasMore;
|
||||
if (afterLine !== null) {
|
||||
const rest = lined.filter((m) => m.line > afterLine);
|
||||
picked = rest.slice(0, limit);
|
||||
hasMore = rest.length > limit;
|
||||
} else if (beforeLine !== null) {
|
||||
const older = lined.filter((m) => m.line < beforeLine);
|
||||
picked = older.slice(-limit);
|
||||
hasMore = older.length > limit;
|
||||
} else if (offset > 0) {
|
||||
picked = lined.slice(offset, offset + limit);
|
||||
hasMore = lined.length > offset + limit;
|
||||
} else {
|
||||
picked = lined.slice(-limit);
|
||||
hasMore = lined.length > limit;
|
||||
}
|
||||
const lastLine = picked.length ? picked[picked.length - 1].line : 0;
|
||||
const firstLine = picked.length ? picked[0].line : 0;
|
||||
for (const m of picked) delete m.line;
|
||||
return {
|
||||
messages: picked,
|
||||
total: lined.length,
|
||||
has_more: hasMore,
|
||||
last_line: lastLine,
|
||||
first_line: firstLine,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
module.exports.sessionStats = sessionStats;
|
||||
// Exported for unit tests — sender attribution is correctness-critical.
|
||||
|
||||
Reference in New Issue
Block a user