Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a6dc6a29e | |||
| c042519bc9 | |||
| 383b9ae231 | |||
| aee4fc190e | |||
| 0547488f13 |
@@ -1,13 +1,13 @@
|
||||
# Claude Code Working Guide
|
||||
|
||||
_Last synced: v2.8.12 (2026-10-19) — omp/OpenCode sessions and tokens polled, Grok hooks tagged `grok`, Kimi/Copilot dropped; Files → Clone repository; Bypass may be an account default._ 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.14 (2026-10-21) — Artifacts: Uninstall for an installed skill (moved aside, never deleted) or plugin (`claude plugin uninstall --keep-data`) beside Reinstall._ 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 `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, 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`, `lib/opencode-usage.js` (omp's journals, OpenCode's `opencode.db` → `tool = 'omp'|'opencode'` 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`. 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.
|
||||
@@ -50,20 +50,21 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which
|
||||
- **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`.
|
||||
- **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; an npm update is refused (`ENOTNPM`) for a tool whose binary is not under `npm prefix -g` (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. Icons are raster only — an SVG served from the dashboard's origin runs script when opened. Do not add an install source that a browser or a custom catalog file can name.
|
||||
- **An AI Tools install runs only what a RELEASE catalog file names.** `install`/`update` (like `launcher`) are accepted in built-in files and in the synced copy of the newest release's catalog, never in `DASHBOARD_AI_TOOLS_DIR`, and no route takes a package, URL or argv from a request. Jobs are argv-only (`ai-tool-jobs.js`), stdin closed, one at a time; npm is resolved on the login shell's OWN PATH first (`shellPath()`), because `hydrateShellPath` appends, and the pinned Node's npm would install where the user's PATH never looks; a global prefix the account cannot write (a system-wide Node, root's `/usr/local`) makes the install `--prefix ~/.local` instead, never sudo; an npm update is refused (`ENOTNPM`) for a tool whose binary is under neither `npm prefix -g` nor `~/.local/lib/node_modules` (a standalone Codex, say) — it would install a shadowed second copy. A script URL must be https on the homepage's site and is re-checked after redirects. 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 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. 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.)
|
||||
- **A lane can hold up to ten tmux sessions (slots 0–9; the Workspace groups them into tabs of up to four split panes) — and only slot 0 is the lane's run.** `agenthub-lane-<id>` is slot 0 and the only one `run_id`/`status` ever record; `agenthub-lane-<id>-<1-9>` are extra sessions on the same lane (`MAX_PANE_SLOT` in `server/lib/pty-run.js`; the `-[1-9]` run id pattern is repeated in `pty-attach.js`, `websocket.js`, `routes/hooks.js` and the client — widen them together). Anything that must stop a lane's agents (`stop`, `reset`, `remove`) goes through `laneRunIds` so no slot survives it; anything that names "the lane's pane" (`agenthub lanes shell`) means slot 0. A client that wants "a new tab" sends `slot: "auto"` and the SERVER picks the first free slot under the lane lock (`409 ERUNLIVE` when all ten are busy) — never a slot computed from the client's own list, which may already be stale.
|
||||
- **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) — and a move/copy never crosses accounts (`sameOwner`, `EFOREIGN`). A taken name is settled by `conflict` (`fail` → 409 with `names`, `skip`, `keep`, `replace` = the old one to the trash first). Delete is a move into the account's trash (member: `~/.local/share/agenthub/trash/`, host: `<data dir>/trash/<user id>/`), restorable 7 days. `write-as` is root-installed: a change to it needs `agenthub server install-system` again (`EUPGRADE` until then). A user's HOME is browsed through the same module inside `inHome` (`/api/homes/:who/fs/*`, `server/lib/homes.js` picks the folder): every dot entry at the TOP of a home stays hidden and refused — through a symlink, a download, a zip or as a copy source too — and another user's home is an admin's READ-ONLY, audited view (`EREADONLY`) — except its TRASH, whose restore/purge an admin may run (`TRASH_W` in `server/routes/homes.js`, the owner's trash via `trashKey`, audited with the item id); keep that exception to the trash routes only. A home's Trash is its USER's one recycle bin (`listUserTrash`: home-account store + host store `<data dir>/trash/<user id>/`, items tagged `store`). Do not add a delete that skips the trash, an overwrite without `conflict`, or a request path that is not run through `locate`/`folder`.
|
||||
- **The File Explorer (`server/lib/file-explorer.js`, `/api/lanes/:id/fs/*`) reuses the read path's guards and never replaces silently.** Every path is `lanePath` + the PARENT `resolveInLane`-confined with the last segment kept as a name (so a symlink is moved/deleted as a link, never followed); never in or under `.git`, never a secret, never the workspace folder itself. Writes run as the folder's account — a member's only through `write-as` (`move`/`copy` modes; `writeAsAsync` streams an upload to its stdin; a batch upload of many small files is ONE `extract` of a tar the server writes itself — folders and regular files only, names it checked, `tar -k`) — and a move/copy never crosses accounts (`sameOwner`, `EFOREIGN`). A taken name is settled by `conflict` (`fail` → 409 with `names`, `skip`, `keep`, `replace` = the old one to the trash first). Delete is a move into the account's trash (member: `~/.local/share/agenthub/trash/`, host: `<data dir>/trash/<user id>/`), restorable 7 days. `write-as` is root-installed: a change to it needs `agenthub server install-system` again (`EUPGRADE` until then). A user's HOME is browsed through the same module inside `inHome` (`/api/homes/:who/fs/*`, `server/lib/homes.js` picks the folder): every dot entry at the TOP of a home stays hidden and refused — through a symlink, a download, a zip or as a copy source too — and another user's home is an admin's READ-ONLY, audited view (`EREADONLY`) — except its TRASH, whose restore/purge an admin may run (`TRASH_W` in `server/routes/homes.js`, the owner's trash via `trashKey`, audited with the item id); keep that exception to the trash routes only. A home's Trash is its USER's one recycle bin (`listUserTrash`: home-account store + host store `<data dir>/trash/<user id>/`, items tagged `store`). Do not add a delete that skips the trash, an overwrite without `conflict`, or a request path that is not run through `locate`/`folder`.
|
||||
- **The whole-file editor and the Files tab's upload are the two non-git writes on the lane file surface, and both live outside `git-ops.js` on purpose.** `git-ops.js` only touches paths `git status` reports as changed; `lane-file-write.js` may save any existing text file, so it must reuse the READ path's guards (`lanePath`, `resolveInLane`, `isSecretOnDisk`) and never be weaker than them, and it saves only over the `baseSha` it was opened at — do not let it create files or touch a binary. `lane-file-transfer.js`'s upload (`POST /api/lanes/:id/git/upload`, issue #78) is the opposite and only that: it CREATES one file (any bytes, ≤ 25 MB) under the same guards, into an existing folder, refuses a taken name (`EEXISTS`, a symlink included) and anything into or named `.git` (a hook there runs on the dashboard's next git call), and writes as the folder's member through `write-as` under S3 — do not let it overwrite, make folders or take a path from anything but `dir` + one plain name. Its `GET …/git/download` twin streams any file through `openSafe` as a `sandbox`ed attachment. The tree, expand-all and quick-open read the disk (`walkFolder`) for a folder that is not a repo.
|
||||
- **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 — `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 the CALLER's `~/.claude/skills/` (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`; 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 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.
|
||||
- **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.
|
||||
- **Opening a session never resumes it — only SENDING a message to it does.** The sidebar and a workspace home ATTACH pane 0 to a session's live tmux pane (`/workspace?lane=<id>&run=<runId>`, `attachRequest` on `LaneConsolePane`) or, for an ended one, open it in its workspace like Claude Desktop (`/workspace?lane=<id>&session=<id>`, `client/src/components/lanes/SessionReplay.tsx`: its conversation read-only plus a prompt box; the console is only hidden, CSS). A resume re-reads the whole transcript into context, so it happens only on an explicit action: a message SENT from that replay (`resumeSessionId` + `prompt`, first free slot), the transcript page's **Resume**/**Fork** (`SessionResumeButton.tsx`, behind a warning), or the ticked sessions of the **Resume interrupted sessions?** dialog (`InterruptedSessionsPrompt.tsx`, `GET /api/run/interrupted` — runs whose pane died under them; opening it resumes nothing). The analysis page `/sessions/<id>` is reached from the session view's **Analyst** button (beside Files/Changes in the right panel). Do not add a path that resumes on a click. A resume is refused while a live pane holds the session (`409 ESESSIONLIVE` with that pane's `run_id`) — open that pane instead; a fork makes a new session and is allowed.
|
||||
- **A chat artifact runs in its own sandbox, never on the dashboard's origin.** The artifact panel (`client/src/components/run/ChatFilePreview.tsx`, `client/src/lib/artifactView.ts`) runs model-written HTML/Mermaid/React in an `<iframe srcdoc sandbox="allow-scripts …">` and an SVG in `sandbox=""` — never add `allow-same-origin` (the script would read the dashboard's cookies and call its API as the user), never render one with `dangerouslySetInnerHTML`, and keep its CDN libraries pinned to exact versions. Versions are rebuilt from Write/Edit inputs (kept up to 256 KB by `server/lib/transcript-message.js`); no copy is stored, and Save to Artifacts uses the Hub's own `pinFile`/upload routes.
|
||||
- **A `cwd` can back at most one lane per tool, not one lane overall.** Uniqueness is the composite `idx_lanes_cwd_tool` (`cwd`, `tool`), not `cwd` alone — Repo/Adopt mode (`POST /api/lanes/ensure`) lets the same directory carry a `claude` lane and a `codex` lane side by side, shown as two separate cards. `resolveLaneByCwd(cwd, tool)` takes an optional `tool` filter for exactly this; hook ingestion (`touchLaneFromHook`) pins it to the hook's own tool — `claude`, `codex` for a hook posted with `tool: "codex"`, or `antigravity` for one posted with `tool: "antigravity"` (the Codex and Antigravity hook rules above) — so a hook event never touches the other tool's lane sharing the path, and stage detection (`resolveLaneForWork` + `detect`) runs for `claude` only. Worktree mode is unaffected — it already allocates a unique directory per lane.
|
||||
|
||||
See `docs/LANES.md` for full guide: stage reporting, custom templates, lane actions, and the five node states.
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
chooseRunOption,
|
||||
interruptRun,
|
||||
readRunMenu,
|
||||
stepRunMenu,
|
||||
submitToRun,
|
||||
type PaneMenu,
|
||||
} from "../../lib/panePaste";
|
||||
@@ -505,6 +506,9 @@ export function PetChat({
|
||||
if (answerRunMenu(chat.runId, menu, text)) setMenu(null);
|
||||
else setError(t("sagi.chat.notSent"));
|
||||
}}
|
||||
onStep={(dir) => {
|
||||
if (!stepRunMenu(chat.runId, dir)) setError(t("sagi.chat.notSent"));
|
||||
}}
|
||||
onCancel={() => {
|
||||
if (interruptRun(chat.runId)) setMenu(null);
|
||||
else setError(t("sagi.chat.notSent"));
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* and tick the ones to add; or type `name@marketplace` by hand, which is
|
||||
* also where a link the server cannot read (ssh, a private repo) ends up.
|
||||
* - `FileUploadPanel` — one file from the user's computer (≤ 25 MB).
|
||||
* - `SkillNameField`/`splitSkillMd` — also used by the page's skill editor.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -24,8 +25,8 @@ import { api, type MarketplaceInfo } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { toast } from "../../lib/toast";
|
||||
|
||||
const field = "w-full rounded border border-border bg-surface-1 px-2 py-1 text-sm text-fg-primary";
|
||||
const label = "block text-[11px] font-medium text-fg-secondary";
|
||||
const field = "input w-full";
|
||||
const label = "block text-xs text-fg-secondary";
|
||||
const hint = "text-[11px] text-fg-muted";
|
||||
|
||||
/** The server's skill folder rule (server/lib/artifacts.js `skillSlug`). */
|
||||
@@ -155,8 +156,19 @@ function Tabs<T extends string>({
|
||||
);
|
||||
}
|
||||
|
||||
/** SKILL.md split for editing: its frontmatter `name`/`description` and the body. */
|
||||
export function splitSkillMd(text: string): { name: string; description: string; body: string } {
|
||||
const clean = text.replace(/^\uFEFF/, "");
|
||||
const fm = /^---\r?\n[\s\S]*?\r?\n---[^\n]*(\r?\n|$)/.exec(clean);
|
||||
return {
|
||||
name: frontmatterField(clean, "name"),
|
||||
description: frontmatterField(clean, "description"),
|
||||
body: (fm ? clean.slice(fm[0].length) : clean).replace(/^\s+/, ""),
|
||||
};
|
||||
}
|
||||
|
||||
/** The name field with the folder/command it becomes. */
|
||||
function SkillNameField({
|
||||
export function SkillNameField({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
@@ -175,7 +187,7 @@ function SkillNameField({
|
||||
{t("skill.name")}
|
||||
{required ? " *" : ""}
|
||||
<input
|
||||
className={`${field} mt-0.5`}
|
||||
className={`${field} mt-1`}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
placeholder="release-notes"
|
||||
@@ -296,7 +308,7 @@ export function SkillAddPanel({
|
||||
<label className={label}>
|
||||
{t("skill.description")}
|
||||
<textarea
|
||||
className={`${field} mt-0.5`}
|
||||
className={`${field} mt-1`}
|
||||
rows={2}
|
||||
value={description}
|
||||
disabled={many}
|
||||
@@ -310,7 +322,7 @@ export function SkillAddPanel({
|
||||
<label className={label}>
|
||||
{t("form.title")}
|
||||
<input
|
||||
className={`${field} mt-0.5`}
|
||||
className={`${field} mt-1`}
|
||||
value={title}
|
||||
disabled={many}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
@@ -452,7 +464,10 @@ export function SkillAddPanel({
|
||||
/>
|
||||
)}
|
||||
{meta}
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
|
||||
{t("form.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary text-sm"
|
||||
@@ -489,7 +504,7 @@ export function SkillAddPanel({
|
||||
<label className={label}>
|
||||
{t("skill.description")} *
|
||||
<textarea
|
||||
className={`${field} mt-0.5`}
|
||||
className={`${field} mt-1`}
|
||||
rows={2}
|
||||
value={description}
|
||||
placeholder={t("skill.descriptionHint")}
|
||||
@@ -499,7 +514,7 @@ export function SkillAddPanel({
|
||||
<label className={label}>
|
||||
{t("skill.content")} *
|
||||
<textarea
|
||||
className={`${field} mt-0.5 font-mono text-xs`}
|
||||
className={`${field} mt-1 font-mono text-xs`}
|
||||
rows={10}
|
||||
value={content}
|
||||
placeholder={t("skill.contentHint")}
|
||||
@@ -509,12 +524,15 @@ export function SkillAddPanel({
|
||||
<label className={label}>
|
||||
{t("form.title")}
|
||||
<input
|
||||
className={`${field} mt-0.5`}
|
||||
className={`${field} mt-1`}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
|
||||
{t("form.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary text-sm"
|
||||
@@ -534,7 +552,7 @@ export function SkillAddPanel({
|
||||
{mode === "existing" && (
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
className={field}
|
||||
className="select w-full"
|
||||
aria-label={t("form.workspace")}
|
||||
value={laneId ?? ""}
|
||||
onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)}
|
||||
@@ -696,7 +714,7 @@ export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onCl
|
||||
>
|
||||
<label className={label}>
|
||||
{t("plugin.link")}
|
||||
<div className="mt-0.5 flex gap-2">
|
||||
<div className="mt-1 flex gap-2">
|
||||
<input
|
||||
className={field}
|
||||
value={link}
|
||||
@@ -777,7 +795,15 @@ export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onCl
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-sm"
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("form.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary text-sm"
|
||||
@@ -853,7 +879,10 @@ export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onCl
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
|
||||
{t("form.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary text-sm"
|
||||
@@ -902,7 +931,7 @@ export function FileUploadPanel({ onAdded }: { onAdded: () => void }) {
|
||||
<input
|
||||
type="file"
|
||||
data-testid="file-upload"
|
||||
className="mt-0.5 block w-full text-xs text-fg-secondary"
|
||||
className="mt-1 block w-full text-xs text-fg-secondary file:mr-3 file:cursor-pointer file:rounded-lg file:border file:border-border-light file:bg-surface-2 file:px-3 file:py-1.5 file:text-xs file:text-fg-primary hover:file:bg-surface-4"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* @file AgentDetails.tsx
|
||||
* @description What the chat shows of a subagent, like Claude Desktop: an
|
||||
* Agent/Task call's facts (type, model, status, time, tokens, tool calls),
|
||||
* the task it was given and the report it returned — read off the call, its
|
||||
* result (`tool_result.agent`) and, for a background agent, the
|
||||
* `<task-notification>` that closed it — and, one click further, the
|
||||
* subagent's own conversation as a small chat right there in the step, every
|
||||
* step of it. The chat's owner (ConversationView) supplies the links through
|
||||
* `AgentContext`: which agent transcript a call launched, the notifications
|
||||
* seen, and how to render one agent's conversation (a nested
|
||||
* ConversationView — passed in, so this module never imports it back).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckCircle2, ChevronRight, CircleSlash, Loader2, XCircle } from "lucide-react";
|
||||
import type { TranscriptContent, TranscriptMessage } from "../../lib/types";
|
||||
import { fmt, formatModelName, formatMs } from "../../lib/format";
|
||||
import { MarkdownContent } from "./MarkdownContent";
|
||||
|
||||
export type AgentStatus = "running" | "done" | "error" | "stopped";
|
||||
|
||||
/** One subagent as the chat knows it from the call that launched it. */
|
||||
export interface AgentRef {
|
||||
/** The Agent/Task `tool_use` id. */
|
||||
toolUseId: string;
|
||||
/** Its own transcript's `agent_id`; null while nothing names it yet. */
|
||||
agentId: string | null;
|
||||
type: string;
|
||||
description: string;
|
||||
model: string | null;
|
||||
status: AgentStatus;
|
||||
background: boolean;
|
||||
prompt: string | null;
|
||||
report: string | null;
|
||||
durationMs: number | null;
|
||||
tokens: number | null;
|
||||
toolUses: number | null;
|
||||
}
|
||||
|
||||
/** What a `<task-notification>` said about a background agent. */
|
||||
export interface AgentNotice {
|
||||
status: string;
|
||||
result: string | null;
|
||||
durationMs: number | null;
|
||||
tokens: number | null;
|
||||
toolUses: number | null;
|
||||
}
|
||||
|
||||
export interface AgentLinks {
|
||||
/** tool_use id → agent transcript id, from the session's transcript list. */
|
||||
ids: Map<string, string>;
|
||||
/** tool_use id → the notification that closed that background agent. */
|
||||
notices: Map<string, AgentNotice>;
|
||||
/** The session runs in a pane: a call with no result yet still works. */
|
||||
live: boolean;
|
||||
/** One agent's own conversation, as a chat that fills its box. */
|
||||
render?: (agent: AgentRef) => React.ReactNode;
|
||||
}
|
||||
|
||||
export const AgentContext = createContext<AgentLinks>({
|
||||
ids: new Map(),
|
||||
notices: new Map(),
|
||||
live: false,
|
||||
});
|
||||
|
||||
const AGENT_TOOLS = new Set(["Agent", "Task"]);
|
||||
export const isAgentCall = (b: TranscriptContent) =>
|
||||
b.type === "tool_use" && AGENT_TOOLS.has(b.name ?? "");
|
||||
|
||||
// The result names the agent's own transcript (`agent-<id>.jsonl`).
|
||||
const AGENT_ID = /agentId:\s*([\w-]+)/;
|
||||
const USAGE = /<usage>([\s\S]*?)<\/usage>/;
|
||||
|
||||
const tag = (text: string, name: string) =>
|
||||
text.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`))?.[1]?.trim() ?? null;
|
||||
|
||||
/** The time, tokens and tool calls of a `<usage>` block, in either form
|
||||
* Claude Code writes it: `<total_tokens>N</…>` tags or `total_tokens: N`. */
|
||||
export function parseUsage(text: string): Pick<AgentNotice, "durationMs" | "tokens" | "toolUses"> {
|
||||
const body = text.match(USAGE)?.[1] ?? "";
|
||||
const num = (...names: string[]) => {
|
||||
for (const n of names) {
|
||||
const v = body.match(new RegExp(`${n}(?:>|:\\s*)(\\d+)`))?.[1];
|
||||
if (v) return Number(v);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return {
|
||||
durationMs: num("duration_ms"),
|
||||
tokens: num("subagent_tokens", "total_tokens"),
|
||||
toolUses: num("tool_uses"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Each `<task-notification>` in the transcript, by the call it closes. */
|
||||
export function agentNotices(messages: TranscriptMessage[]): Map<string, AgentNotice> {
|
||||
const out = new Map<string, AgentNotice>();
|
||||
for (const msg of messages) {
|
||||
if (msg.type !== "user") continue;
|
||||
for (const c of msg.content) {
|
||||
if (c.type !== "text" || !c.text?.includes("<task-notification>")) continue;
|
||||
for (const part of c.text.split("<task-notification>").slice(1)) {
|
||||
const id = tag(part, "tool-use-id");
|
||||
if (!id) continue;
|
||||
out.set(id, {
|
||||
status: tag(part, "status") ?? "completed",
|
||||
result: tag(part, "result"),
|
||||
...parseUsage(part),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A report without the footer Claude Code appends for the orchestrator. */
|
||||
function stripFooter(text: string): string {
|
||||
return text
|
||||
.replace(USAGE, "")
|
||||
.replace(/^agentId:.*$/m, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Everything the chat can say about the subagent `block` launched. */
|
||||
export function agentRef(
|
||||
block: TranscriptContent,
|
||||
result: TranscriptContent | null | undefined,
|
||||
links: AgentLinks
|
||||
): AgentRef {
|
||||
const raw = block.input as Record<string, unknown> | undefined;
|
||||
const input = raw && !("_truncated" in raw) ? raw : {};
|
||||
const str = (v: unknown) => (typeof v === "string" && v ? v : null);
|
||||
const id = block.id ?? "";
|
||||
const facts = result?.agent;
|
||||
const output = result?.output ?? "";
|
||||
const background = facts?.status === "async_launched" || output.includes("Async agent launched");
|
||||
const notice = background ? links.notices.get(id) : undefined;
|
||||
const usage = !background && output ? parseUsage(output) : null;
|
||||
|
||||
let status: AgentStatus;
|
||||
if (result?.is_error) status = "error";
|
||||
else if (!result || (background && !notice)) status = links.live ? "running" : "stopped";
|
||||
else if (notice && notice.status !== "completed")
|
||||
status = notice.status === "failed" ? "error" : "stopped";
|
||||
else status = "done";
|
||||
|
||||
return {
|
||||
toolUseId: id,
|
||||
agentId: facts?.id ?? output.match(AGENT_ID)?.[1] ?? links.ids.get(id) ?? null,
|
||||
type: str(input.subagent_type) ?? "Agent",
|
||||
description: str(input.description) ?? "",
|
||||
model: facts?.model ?? str(input.model),
|
||||
status,
|
||||
background,
|
||||
prompt: str(input.prompt),
|
||||
report: background ? (notice?.result ?? null) : output ? stripFooter(output) || null : null,
|
||||
durationMs: facts?.duration_ms ?? notice?.durationMs ?? usage?.durationMs ?? null,
|
||||
tokens: facts?.tokens ?? notice?.tokens ?? usage?.tokens ?? null,
|
||||
toolUses: facts?.tool_uses ?? notice?.toolUses ?? usage?.toolUses ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const STATUS_ICON: Record<AgentStatus, React.ReactNode> = {
|
||||
running: <Loader2 className="h-3.5 w-3.5 flex-shrink-0 animate-spin text-accent" />,
|
||||
done: <CheckCircle2 className="h-3.5 w-3.5 flex-shrink-0 text-status-success" />,
|
||||
error: <XCircle className="h-3.5 w-3.5 flex-shrink-0 text-status-danger" />,
|
||||
stopped: <CircleSlash className="h-3.5 w-3.5 flex-shrink-0 text-fg-muted" />,
|
||||
};
|
||||
|
||||
/** One muted line: its status as an icon, then type · model · time ·
|
||||
* tokens · tool calls, as far as they are known. */
|
||||
export function AgentFacts({ agent }: { agent: AgentRef }) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const parts = [
|
||||
agent.type,
|
||||
agent.model && formatModelName(agent.model),
|
||||
agent.background && t("conversation.agent.background"),
|
||||
agent.durationMs != null && formatMs(agent.durationMs),
|
||||
agent.tokens != null && t("conversation.agentContext", { tokens: fmt(agent.tokens) }),
|
||||
agent.toolUses != null && t("conversation.agent.toolUses", { count: agent.toolUses }),
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<div
|
||||
data-testid="agent-facts"
|
||||
title={t(`conversation.agent.status.${agent.status}`)}
|
||||
className="flex min-w-0 items-center gap-1.5 text-xs text-fg-muted"
|
||||
>
|
||||
{STATUS_ICON[agent.status]}
|
||||
<span className="sr-only">{t(`conversation.agent.status.${agent.status}`)}</span>
|
||||
<span className="truncate">{parts.join(" · ")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const linkClass =
|
||||
"inline-flex items-center gap-0.5 text-xs text-fg-muted transition-colors hover:text-fg-secondary";
|
||||
|
||||
/** The subagent's own conversation, a thread hung under its step by a line
|
||||
* on the left — as tall as it is, up to 24rem, then it scrolls. */
|
||||
export function AgentChatBox({ agent }: { agent: AgentRef }) {
|
||||
const links = useContext(AgentContext);
|
||||
return (
|
||||
<div data-testid="agent-chat" className="mt-2 border-l-2 border-border pl-3">
|
||||
{links.render?.(agent)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** An Agent/Task step opened in the chat: one line of facts, its report as
|
||||
* prose, and "View conversation ›", which puts the agent's own thread (its
|
||||
* task, every step, its reply) in the report's place. With no transcript
|
||||
* to show, the task it was given opens on its own instead. */
|
||||
export function AgentStepBody({
|
||||
block,
|
||||
result,
|
||||
}: {
|
||||
block: TranscriptContent;
|
||||
result: TranscriptContent | null;
|
||||
}) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const links = useContext(AgentContext);
|
||||
const agent = agentRef(block, result, links);
|
||||
const canOpen = !!agent.agentId && !!links.render;
|
||||
const [thread, setThread] = useState(false);
|
||||
const [task, setTask] = useState(false);
|
||||
return (
|
||||
<div data-testid="agent-step" className="space-y-1.5">
|
||||
<AgentFacts agent={agent} />
|
||||
{thread ? (
|
||||
<AgentChatBox agent={agent} />
|
||||
) : (
|
||||
<>
|
||||
{!canOpen && agent.prompt && task && (
|
||||
<div className="max-h-60 overflow-auto border-l-2 border-border pl-3 text-fg-secondary">
|
||||
<MarkdownContent text={agent.prompt} dense />
|
||||
</div>
|
||||
)}
|
||||
{agent.report && (
|
||||
<div
|
||||
className={`max-h-60 overflow-auto ${agent.status === "error" ? "text-status-danger" : "text-fg-secondary"}`}
|
||||
>
|
||||
<MarkdownContent text={agent.report} dense />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{canOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="agent-open"
|
||||
aria-expanded={thread}
|
||||
onClick={() => setThread(!thread)}
|
||||
className={linkClass}
|
||||
>
|
||||
{t(thread ? "conversation.agent.hide" : "conversation.agent.open")}
|
||||
<ChevronRight className={`h-3 w-3 transition-transform ${thread ? "-rotate-90" : ""}`} />
|
||||
</button>
|
||||
) : (
|
||||
agent.prompt && (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={task}
|
||||
onClick={() => setTask(!task)}
|
||||
className={linkClass}
|
||||
>
|
||||
{t("conversation.agent.prompt")}
|
||||
<ChevronRight className={`h-3 w-3 transition-transform ${task ? "rotate-90" : ""}`} />
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -73,6 +73,7 @@ import { api } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
|
||||
import { MessageList, promptTokens } from "./MessageList";
|
||||
import { agentNotices, type AgentLinks, type AgentRef } from "./AgentDetails";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { useOnScreen, isShown } from "../../hooks/useOnScreen";
|
||||
import { throttle } from "../../lib/throttle";
|
||||
@@ -95,6 +96,9 @@ const TRANSCRIPTS_REFRESH_MS = 15000;
|
||||
// at most this often: the first event still fetches at once, the last is never
|
||||
// lost, and a long transcript is not re-read for every single frame.
|
||||
const EVENT_FETCH_MS = 1500;
|
||||
// The server saw this session's transcript grow (`transcript_changed`, itself
|
||||
// at most every 250 ms): the new lines are on disk, so read them nearly at once.
|
||||
const PUSH_FETCH_MS = 300;
|
||||
|
||||
interface ConversationViewProps {
|
||||
sessionId: string;
|
||||
@@ -102,8 +106,15 @@ interface ConversationViewProps {
|
||||
/** Fill the parent as a chat (a workspace's session view): no toolbar, no
|
||||
* page-height cap — the parent sizes it and puts a composer under it. */
|
||||
fill?: boolean;
|
||||
/** A subagent's conversation inside its step: no toolbar, as tall as its
|
||||
* content up to 24rem, then it scrolls. */
|
||||
compact?: boolean;
|
||||
/** Opens a file named in a turn's "Edited N files" card. */
|
||||
onOpenFile?: (path: string) => void;
|
||||
/** Opens an artifact card's file in the chat's artifact panel. */
|
||||
onOpenArtifact?: (path: string) => void;
|
||||
/** The messages on screen (for an artifact's versions), as they change. */
|
||||
onMessages?: (messages: TranscriptMessage[]) => void;
|
||||
/** The session runs in a pane: show the step in progress. */
|
||||
live?: boolean;
|
||||
/** Changed (a chat's send): back to the end, and stick to it. */
|
||||
@@ -127,7 +138,10 @@ function ConversationViewPane({
|
||||
sessionId,
|
||||
initialTranscriptId,
|
||||
fill = false,
|
||||
compact = false,
|
||||
onOpenFile,
|
||||
onOpenArtifact,
|
||||
onMessages,
|
||||
live = false,
|
||||
follow = 0,
|
||||
waiting = false,
|
||||
@@ -139,19 +153,21 @@ function ConversationViewPane({
|
||||
}: ConversationViewProps) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||
const shown = messages;
|
||||
// A live pane's session has no row until its first hook — and Claude Code
|
||||
// runs no hook before the folder is trusted, so a trust prompt (or any
|
||||
// other question before the session starts) waits unseen in the terminal.
|
||||
const [notStarted, setNotStarted] = useState(false);
|
||||
useEffect(() => onNotStarted?.(notStarted), [notStarted, onNotStarted]);
|
||||
useEffect(() => onMessages?.(shown), [shown, onMessages]);
|
||||
useEffect(() => {
|
||||
if (!onContext) return;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]!;
|
||||
for (let i = shown.length - 1; i >= 0; i--) {
|
||||
const m = shown[i]!;
|
||||
if (m.type === "assistant" && m.usage)
|
||||
return onContext(promptTokens(m.usage), m.model ?? null);
|
||||
}
|
||||
}, [messages, onContext]);
|
||||
}, [shown, onContext]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||
@@ -186,9 +202,31 @@ function ConversationViewPane({
|
||||
// Refresh-button spinner state - separate from initial `loading` so the
|
||||
// existing skeleton doesn't blink during a manual refresh.
|
||||
|
||||
// A subagent's own conversation opens inside its step as a small chat: this
|
||||
// same view, on that agent's transcript.
|
||||
const renderAgent = useCallback(
|
||||
(a: AgentRef) => (
|
||||
<ConversationView
|
||||
key={a.agentId}
|
||||
sessionId={sessionId}
|
||||
initialTranscriptId={a.agentId}
|
||||
compact
|
||||
live={live && a.status === "running"}
|
||||
ended={a.status !== "running"}
|
||||
/>
|
||||
),
|
||||
[sessionId, live]
|
||||
);
|
||||
const agentLinks = useMemo<AgentLinks>(() => {
|
||||
const ids = new Map<string, string>();
|
||||
for (const tr of transcripts) if (tr.tool_use_id) ids.set(tr.tool_use_id, tr.id);
|
||||
return { ids, notices: agentNotices(shown), live, render: renderAgent };
|
||||
}, [transcripts, shown, live, renderAgent]);
|
||||
|
||||
// Load available transcript list (also rescanned on a short interval so
|
||||
// newly-spawned subagents appear in the dropdown without a page reload).
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
let cancelled = false;
|
||||
async function loadTranscripts() {
|
||||
try {
|
||||
@@ -228,6 +266,12 @@ function ConversationViewPane({
|
||||
firstLineRef.current = 0;
|
||||
|
||||
async function load() {
|
||||
// No session yet: no history.
|
||||
if (!sessionId) {
|
||||
setMessages([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
@@ -271,6 +315,7 @@ function ConversationViewPane({
|
||||
// parsed message we've seen. The server already de-overlaps via
|
||||
// afterLine, so we can safely append.
|
||||
const fetchNewMessages = useCallback(async () => {
|
||||
if (!sessionId) return;
|
||||
if (fetchingRef.current) {
|
||||
// Coalesce: remember a trigger arrived during this fetch and re-run
|
||||
// exactly once when the in-flight request settles.
|
||||
@@ -332,7 +377,12 @@ function ConversationViewPane({
|
||||
() => throttle(() => void fetchNewMessages(), EVENT_FETCH_MS),
|
||||
[fetchNewMessages]
|
||||
);
|
||||
const fetchPushed = useMemo(
|
||||
() => throttle(() => void fetchNewMessages(), PUSH_FETCH_MS),
|
||||
[fetchNewMessages]
|
||||
);
|
||||
useEffect(() => () => fetchSoon.cancel(), [fetchSoon]);
|
||||
useEffect(() => () => fetchPushed.cancel(), [fetchPushed]);
|
||||
useEffect(() => {
|
||||
const unsubscribe = eventBus.subscribe((msg: WSMessage) => {
|
||||
// Hidden tab or hidden pane: catching up once on show is enough.
|
||||
@@ -341,13 +391,14 @@ function ConversationViewPane({
|
||||
fetchSoon();
|
||||
return;
|
||||
}
|
||||
if (msg.type !== "new_event") return;
|
||||
if (msg.type !== "new_event" && msg.type !== "transcript_changed") return;
|
||||
const data = msg.data as { session_id?: string };
|
||||
if (data.session_id !== sessionId) return;
|
||||
fetchSoon();
|
||||
if (msg.type === "transcript_changed") fetchPushed();
|
||||
else fetchSoon();
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [sessionId, fetchSoon, onScreen]);
|
||||
}, [sessionId, fetchSoon, fetchPushed, onScreen]);
|
||||
|
||||
onShowRef.current = () => {
|
||||
if (document.visibilityState === "visible") void fetchNewMessages();
|
||||
@@ -509,7 +560,7 @@ function ConversationViewPane({
|
||||
|
||||
// Auto-scroll to bottom after initial load
|
||||
useEffect(() => {
|
||||
if (!loading && messages.length > 0) {
|
||||
if (!loading && shown.length > 0) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}, [loading, scrollToBottom]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
@@ -518,7 +569,7 @@ function ConversationViewPane({
|
||||
<div className={`relative flex flex-col ${fill ? "h-full" : ""}`} style={{ minHeight: 0 }}>
|
||||
{/* Toolbar - always rendered after the initial load so users can
|
||||
refresh even when no messages have streamed yet. */}
|
||||
{!loading && !fill && (
|
||||
{!loading && !fill && !compact && (
|
||||
<div className="flex items-center gap-3 mb-3 flex-shrink-0">
|
||||
{transcripts.length > 1 && (
|
||||
<div className="relative">
|
||||
@@ -566,7 +617,13 @@ function ConversationViewPane({
|
||||
ref={scrollContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto [scrollbar-gutter:stable]"
|
||||
style={fill ? { minHeight: 0 } : { maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
|
||||
style={
|
||||
compact
|
||||
? { maxHeight: "24rem" }
|
||||
: fill
|
||||
? { minHeight: 0 }
|
||||
: { maxHeight: "calc(100vh - 320px)", minHeight: 200 }
|
||||
}
|
||||
>
|
||||
{/* A chat keeps its first turn clear of the pane's top-right controls
|
||||
(the outline's file button floats there). */}
|
||||
@@ -589,10 +646,12 @@ function ConversationViewPane({
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
|
||||
<div
|
||||
className={`flex items-center justify-center text-fg-muted text-sm ${compact ? "py-3" : "py-12"}`}
|
||||
>
|
||||
{t("conversation.loading")}
|
||||
</div>
|
||||
) : messages.length === 0 && notStarted ? (
|
||||
) : shown.length === 0 && notStarted ? (
|
||||
<div className="mx-auto max-w-md py-12 text-center">
|
||||
<p className="text-sm text-fg-secondary">{t("conversation.notStarted")}</p>
|
||||
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
|
||||
@@ -608,7 +667,7 @@ function ConversationViewPane({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
) : shown.length === 0 ? (
|
||||
<div className="mx-auto max-w-md py-12 text-center">
|
||||
<p className="text-sm text-fg-secondary">{t("conversation.empty")}</p>
|
||||
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
|
||||
@@ -617,13 +676,16 @@ function ConversationViewPane({
|
||||
</div>
|
||||
) : (
|
||||
<MessageList
|
||||
messages={messages}
|
||||
messages={shown}
|
||||
loading={false}
|
||||
onOpenFile={onOpenFile}
|
||||
onOpenArtifact={onOpenArtifact}
|
||||
live={live}
|
||||
waiting={waiting}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
stopped={stopped}
|
||||
agentLinks={agentLinks}
|
||||
compact={compact}
|
||||
sessionId={
|
||||
selectedTranscript && selectedTranscript !== "main" ? undefined : sessionId
|
||||
}
|
||||
@@ -653,7 +715,7 @@ function ConversationViewPane({
|
||||
)}
|
||||
|
||||
{/* New messages indicator */}
|
||||
{showNewMsg && !fill && (
|
||||
{showNewMsg && !fill && !compact && (
|
||||
<button
|
||||
onClick={() => {
|
||||
scrollToBottom();
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* subset of CommonMark + GFM that actually appears in Claude Code transcripts:
|
||||
* fenced code blocks, ATX headings, ordered/unordered lists, task lists, blockquotes,
|
||||
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
|
||||
* and auto-linked URLs. A file path — absolute, or relative with a folder and
|
||||
* and auto-linked URLs. A file path — absolute (POSIX or a Windows `E:\…`), or relative with a folder and
|
||||
* an extension (`server/lib/x.js`), with or without a `:line` suffix, bare, in
|
||||
* inline code or as a markdown link's target — opens through `OpenFileContext`
|
||||
* when the page provides one (the page resolves a relative one against its own
|
||||
@@ -272,8 +272,14 @@ const PATH_RE = /^(?:\/[^\s/<>()'"`,;:]*[^\s/<>()'"`,;:.!?]){2,}(?::\d+(?::\d+)?
|
||||
// (`github.com/x.js`), not a folder — a leading one (`.claude/…`) is a folder.
|
||||
const REL_PATH_RE =
|
||||
/^(?:\.{1,2}\/)*(?:\.?[\w@-][\w@-]*\/)(?:[\w.@-]+\/)*[\w.@-]*[\w@-]\.[A-Za-z][A-Za-z0-9]{0,9}(?::\d+(?::\d+)?)?(?![\p{L}\p{N}_/])/u;
|
||||
// A Windows absolute path (`E:\notes\a.xlsx`, `C:/x/y.ts:9`): a drive letter and one segment at least.
|
||||
const WIN_PATH_RE =
|
||||
/^[A-Za-z]:[\\/](?:[^\s\\/<>()'"`,;:|*?]+[\\/])*[^\s\\/<>()'"`,;:|*?]*[^\s\\/<>()'"`,;:|*?.!](?::\d+(?::\d+)?)?/;
|
||||
// In inline code the whole text is the path, so a Windows one may hold spaces.
|
||||
const WIN_CODE_PATH_RE = /^[A-Za-z]:[\\/][^<>"|?*\n]*[^\s<>"|?*\\/.]$/;
|
||||
/** A path at the start of `text`, absolute or relative, or null. */
|
||||
const matchPath = (text: string) => PATH_RE.exec(text)?.[0] ?? REL_PATH_RE.exec(text)?.[0] ?? null;
|
||||
const matchPath = (text: string) =>
|
||||
PATH_RE.exec(text)?.[0] ?? WIN_PATH_RE.exec(text)?.[0] ?? REL_PATH_RE.exec(text)?.[0] ?? null;
|
||||
/** `a/b.ts:12:5` → `a/b.ts`: what is opened. */
|
||||
export const stripLine = (p: string) => p.replace(/(?::\d+){1,2}$/, "");
|
||||
// A markdown link target that is a page, not a file.
|
||||
@@ -393,7 +399,13 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
{codeM[1]}
|
||||
</code>
|
||||
);
|
||||
push(matchPath(codeM[1]!) === codeM[1] ? <PathLink path={codeM[1]!}>{code}</PathLink> : code);
|
||||
push(
|
||||
matchPath(codeM[1]!) === codeM[1] || WIN_CODE_PATH_RE.test(codeM[1]!) ? (
|
||||
<PathLink path={codeM[1]!}>{code}</PathLink>
|
||||
) : (
|
||||
code
|
||||
)
|
||||
);
|
||||
i += codeM[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* @file AgentDetails.test.tsx
|
||||
* @description Verifies what the chat shows of a subagent: an Agent step's
|
||||
* facts, task and report (from its result, or the task-notification that
|
||||
* closed a background one), and that "View conversation" opens that
|
||||
* agent's own conversation in place, rendered by the chat's owner.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { MessageList } from "../MessageList";
|
||||
import { agentNotices, agentRef, parseUsage, type AgentLinks } from "../AgentDetails";
|
||||
import type { TranscriptContent, TranscriptMessage } from "../../../lib/types";
|
||||
|
||||
const at = new Date().toISOString();
|
||||
const m = (p: Partial<TranscriptMessage>): TranscriptMessage =>
|
||||
({ type: "user", timestamp: at, content: [], ...p }) as TranscriptMessage;
|
||||
|
||||
const call: TranscriptContent = {
|
||||
type: "tool_use",
|
||||
id: "t1",
|
||||
name: "Agent",
|
||||
input: { description: "Map the hooks", subagent_type: "Explore", prompt: "Read **server/**" },
|
||||
};
|
||||
const links = (p: Partial<AgentLinks> = {}): AgentLinks => ({
|
||||
ids: new Map(),
|
||||
notices: new Map(),
|
||||
live: false,
|
||||
...p,
|
||||
});
|
||||
|
||||
describe("agentRef", () => {
|
||||
it("reads a foreground agent's facts and report, without the orchestrator's footer", () => {
|
||||
const a = agentRef(
|
||||
call,
|
||||
{
|
||||
type: "tool_result",
|
||||
id: "t1",
|
||||
output: "The hooks live in server/routes/hooks.js.\n\nagentId: a1b2 (for resuming)",
|
||||
agent: {
|
||||
id: "a1b2",
|
||||
model: "claude-opus-5-5",
|
||||
status: "completed",
|
||||
duration_ms: 65_000,
|
||||
tokens: 12_000,
|
||||
tool_uses: 7,
|
||||
},
|
||||
},
|
||||
links()
|
||||
);
|
||||
expect(a).toMatchObject({
|
||||
agentId: "a1b2",
|
||||
type: "Explore",
|
||||
status: "done",
|
||||
background: false,
|
||||
prompt: "Read **server/**",
|
||||
report: "The hooks live in server/routes/hooks.js.",
|
||||
durationMs: 65_000,
|
||||
tokens: 12_000,
|
||||
toolUses: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it("closes a background agent with its notification's report and usage", () => {
|
||||
const notice = m({
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "<task-notification>\n<tool-use-id>t1</tool-use-id>\n<status>completed</status>\n<result>All done</result>\n<usage><subagent_tokens>88007</subagent_tokens><tool_uses>31</tool_uses><duration_ms>132350</duration_ms></usage>\n</task-notification>",
|
||||
},
|
||||
],
|
||||
});
|
||||
const launched: TranscriptContent = {
|
||||
type: "tool_result",
|
||||
id: "t1",
|
||||
output: "Async agent launched successfully.\nagentId: a9b4",
|
||||
};
|
||||
const running = agentRef(call, launched, links({ live: true }));
|
||||
expect(running).toMatchObject({ status: "running", background: true, report: null });
|
||||
const done = agentRef(call, launched, links({ notices: agentNotices([notice]) }));
|
||||
expect(done).toMatchObject({
|
||||
agentId: "a9b4",
|
||||
status: "done",
|
||||
report: "All done",
|
||||
tokens: 88007,
|
||||
toolUses: 31,
|
||||
durationMs: 132350,
|
||||
});
|
||||
});
|
||||
|
||||
it("finds the transcript from the session's list when the result names none", () => {
|
||||
const a = agentRef(call, null, links({ ids: new Map([["t1", "c3d4"]]) }));
|
||||
expect(a).toMatchObject({ agentId: "c3d4", status: "stopped" });
|
||||
});
|
||||
|
||||
it("parses the line form of a usage block", () => {
|
||||
expect(parseUsage("<usage>total_tokens: 500\ntool_uses: 3\nduration_ms: 900</usage>")).toEqual({
|
||||
tokens: 500,
|
||||
toolUses: 3,
|
||||
durationMs: 900,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("an Agent step in the chat", () => {
|
||||
it("shows the agent's facts and report, and opens its conversation in place", () => {
|
||||
const render_ = vi.fn((a: { agentId: string | null }) => <p>chat of {a.agentId}</p>);
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
agentLinks={links({ render: render_ })}
|
||||
messages={[
|
||||
m({ sender: "user", content: [{ type: "text", text: "go" }] }),
|
||||
m({ type: "assistant", sender: "assistant", stop_reason: "tool_use", content: [call] }),
|
||||
m({
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
id: "t1",
|
||||
output: "Found **3** hooks",
|
||||
agent: {
|
||||
id: "a1b2",
|
||||
model: null,
|
||||
status: "completed",
|
||||
duration_ms: null,
|
||||
tokens: null,
|
||||
tool_uses: 4,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /ran an agent/i }));
|
||||
expect(screen.getByTestId("agent-facts").textContent).toContain("Explore");
|
||||
expect(screen.getByTestId("agent-facts").textContent).toContain("done");
|
||||
expect(screen.getByTestId("agent-facts").textContent).toContain("4 tool calls");
|
||||
expect(screen.getByTestId("agent-step").textContent).toContain("Found 3 hooks");
|
||||
expect(screen.queryByTestId("agent-chat")).toBeNull();
|
||||
fireEvent.click(screen.getByTestId("agent-open"));
|
||||
expect(screen.getByTestId("agent-chat").textContent).toBe("chat of a1b2");
|
||||
// The thread takes the report's place.
|
||||
expect(screen.getByTestId("agent-step").textContent).not.toContain("Found 3 hooks");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @file ConversationView.pushed.test.tsx
|
||||
* @description The server says a session's transcript grew
|
||||
* (`transcript_changed`): the chat showing that session reads the new lines
|
||||
* at once, and again within a short window after a burst — not at the hook
|
||||
* throttle's 1.5 s pace — while another session's notice fetches nothing.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ConversationView } from "../ConversationView";
|
||||
import { api } from "../../../lib/api";
|
||||
import { eventBus } from "../../../lib/eventBus";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
sessions: {
|
||||
transcripts: vi.fn(async () => ({ transcripts: [] })),
|
||||
transcript: vi.fn(async () => ({
|
||||
messages: [
|
||||
{
|
||||
type: "user",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
timestamp: "2026-10-21T00:00:00Z",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
has_more: false,
|
||||
first_line: 1,
|
||||
last_line: 1,
|
||||
})),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const pushed = (session_id: string) =>
|
||||
act(() => eventBus.publish({ type: "transcript_changed", data: { session_id } } as never));
|
||||
const calls = () => vi.mocked(api.sessions.transcript).mock.calls.length;
|
||||
|
||||
describe("ConversationView on transcript_changed", () => {
|
||||
it("reads its own session's new lines at once, and a burst within 300 ms", async () => {
|
||||
render(<ConversationView sessionId="s1" fill live />);
|
||||
await screen.findByText("hello");
|
||||
vi.useFakeTimers();
|
||||
const before = calls();
|
||||
pushed("s1");
|
||||
expect(calls()).toBe(before + 1);
|
||||
pushed("s1");
|
||||
pushed("s1");
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(350);
|
||||
});
|
||||
expect(calls()).toBe(before + 2);
|
||||
});
|
||||
|
||||
it("ignores another session's transcript", async () => {
|
||||
render(<ConversationView sessionId="s1" fill live />);
|
||||
await screen.findByText("hello");
|
||||
const before = calls();
|
||||
pushed("s2");
|
||||
await act(async () => {});
|
||||
expect(calls()).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,20 @@ describe("file links", () => {
|
||||
expect(open.mock.calls).toEqual([["/a/b/c.ts"], ["docs/API.md"]]);
|
||||
});
|
||||
|
||||
it("opens a Windows path, bare or in inline code", () => {
|
||||
const open = withOpen(
|
||||
"File: `E:\\Jx2-2017\\notes\\HE-SO-REWARD.xlsx`. Also C:/src/a.ts:9, and `D:\\My Docs\\b.md`"
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "E:\\Jx2-2017\\notes\\HE-SO-REWARD.xlsx" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "C:/src/a.ts:9" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "D:\\My Docs\\b.md" }));
|
||||
expect(open.mock.calls).toEqual([
|
||||
["E:\\Jx2-2017\\notes\\HE-SO-REWARD.xlsx"],
|
||||
["C:/src/a.ts"],
|
||||
["D:\\My Docs\\b.md"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("opens a markdown link to a file instead of navigating", () => {
|
||||
const open = withOpen("[the panel](client/src/A%20B.tsx#L12) and [web](https://x.dev)");
|
||||
fireEvent.click(screen.getByRole("button", { name: "the panel" }));
|
||||
|
||||
@@ -46,7 +46,7 @@ describe("the Edited N files card", () => {
|
||||
user("fix it"),
|
||||
call("1", "Edit", "/w/a.ts"),
|
||||
result("1", { added: 3, removed: 1 }),
|
||||
call("2", "Write", "/w/b.md"),
|
||||
call("2", "Write", "/w/b.json"),
|
||||
result("2", { added: 10, removed: 0 }),
|
||||
call("3", "Edit", "/w/a.ts"),
|
||||
result("3", { added: 2, removed: 2 }),
|
||||
@@ -65,8 +65,35 @@ describe("the Edited N files card", () => {
|
||||
expect(within(card).getByText("+15")).toBeTruthy();
|
||||
expect(within(card).getByText("+5")).toBeTruthy(); // a.ts: 3+2 added
|
||||
expect(within(card).queryByText("c.ts")).toBeNull(); // failed
|
||||
fireEvent.click(within(card).getByTitle("/w/b.md"));
|
||||
expect(onOpenFile).toHaveBeenCalledWith("/w/b.md");
|
||||
fireEvent.click(within(card).getByTitle("/w/b.json"));
|
||||
expect(onOpenFile).toHaveBeenCalledWith("/w/b.json");
|
||||
});
|
||||
|
||||
it("shows a page or document the turn made as an artifact card, opened in the chat's panel", () => {
|
||||
const onOpenFile = vi.fn();
|
||||
const onOpenArtifact = vi.fn();
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
onOpenFile={onOpenFile}
|
||||
onOpenArtifact={onOpenArtifact}
|
||||
messages={[
|
||||
user("make a page"),
|
||||
call("1", "Write", "/w/page.html"),
|
||||
result("1", { added: 20, removed: 0 }),
|
||||
call("2", "Edit", "/w/App.tsx"),
|
||||
result("2", { added: 1, removed: 1 }),
|
||||
user("thanks"),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const cards = screen.getAllByTestId("artifact-card");
|
||||
// A .tsx it only edited is project code, not an artifact.
|
||||
expect(cards).toHaveLength(1);
|
||||
expect(cards[0]!.textContent).toContain("page");
|
||||
fireEvent.click(cards[0]!);
|
||||
expect(onOpenArtifact).toHaveBeenCalledWith("/w/page.html");
|
||||
expect(within(screen.getByTestId("edited-files")).getByTitle("/w/App.tsx")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("drags a row as its @path attachment, and the header as every file", () => {
|
||||
@@ -77,7 +104,7 @@ describe("the Edited N files card", () => {
|
||||
user("fix it"),
|
||||
call("1", "Edit", "/w/a.ts"),
|
||||
result("1", { added: 1, removed: 0 }),
|
||||
call("2", "Write", "/w/b.md"),
|
||||
call("2", "Write", "/w/b.json"),
|
||||
result("2", { added: 2, removed: 0 }),
|
||||
]}
|
||||
/>
|
||||
@@ -90,14 +117,14 @@ describe("the Edited N files card", () => {
|
||||
});
|
||||
return data;
|
||||
};
|
||||
const one = drag(within(card).getByTitle("/w/b.md"));
|
||||
const one = drag(within(card).getByTitle("/w/b.json"));
|
||||
expect(JSON.parse(one["application/x-agenthub-attach"]!)).toEqual([
|
||||
{ path: "@/w/b.md", name: "b.md" },
|
||||
{ path: "@/w/b.json", name: "b.json" },
|
||||
]);
|
||||
expect(one["text/plain"]).toBe("/w/b.md");
|
||||
expect(one["text/plain"]).toBe("/w/b.json");
|
||||
const all = drag(within(card).getByText("Edited 2 files"));
|
||||
expect(
|
||||
JSON.parse(all["application/x-agenthub-attach"]!).map((a: { path: string }) => a.path)
|
||||
).toEqual(["@/w/a.ts", "@/w/b.md"]);
|
||||
).toEqual(["@/w/a.ts", "@/w/b.json"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -138,7 +138,28 @@ describe("MessageList — sender attribution", () => {
|
||||
]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/↓ 123/)).toBeTruthy();
|
||||
// In the hover bar's tooltip, beside the model and time.
|
||||
expect(screen.getByTestId("chat-actions").getAttribute("title")).toMatch(/↓ 123/);
|
||||
});
|
||||
|
||||
it("shows a local command as one line with its output, never its caveat", () => {
|
||||
const line = (text: string, sender: "user" | "system" = "user") =>
|
||||
msg({ type: "user", sender, content: [{ type: "text", text }] });
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
messages={[
|
||||
line("<local-command-caveat>Caveat: the command below…</local-command-caveat>", "system"),
|
||||
line("<command-name>/model</command-name><command-args>opus</command-args>"),
|
||||
line("<local-command-stdout>Set model to Opus</local-command-stdout>"),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const row = screen.getByTestId("chat-command");
|
||||
expect(row).toHaveTextContent("/model opus");
|
||||
expect(row).toHaveTextContent("Set model to Opus");
|
||||
expect(screen.queryByText(/Caveat/)).toBeNull();
|
||||
expect(screen.queryByTestId("chat-user")).toBeNull();
|
||||
});
|
||||
|
||||
it("ends a live chat with the step still running", () => {
|
||||
@@ -168,7 +189,9 @@ describe("MessageList — sender attribution", () => {
|
||||
const row = screen.getByTestId("chat-running-step");
|
||||
expect(row).toHaveTextContent("Run the full client suite");
|
||||
// Like the terminal: the turn's time since the prompt, and its output.
|
||||
expect(row).toHaveTextContent(/\(1m \ds · ↑ 3\.5K tokens\)/);
|
||||
// (the output shows on hover).
|
||||
expect(row).toHaveTextContent(/1m \ds/);
|
||||
expect(row).toHaveTextContent(/↑ 3\.5K tokens/);
|
||||
// Its result arrived: nothing is running any more.
|
||||
rerender(
|
||||
<MessageList
|
||||
@@ -341,6 +364,14 @@ describe("MessageList — sender attribution", () => {
|
||||
content: [{ type: "text", text: "[Request interrupted by user]" }],
|
||||
});
|
||||
expect(state([prompt, call, result, stopped])).toBeNull();
|
||||
// A local command (`/clear`) is answered by the CLI itself: no turn.
|
||||
const local = (text: string) =>
|
||||
msg({ type: "user", sender: "user", timestamp: at, content: [{ type: "text", text }] });
|
||||
const clear = local("<command-name>/clear</command-name>");
|
||||
const out = local("<local-command-stdout></local-command-stdout>");
|
||||
expect(state([clear, out])).toBeNull();
|
||||
// A prompt command (a skill) is still answered by the model.
|
||||
expect(state([clear])).toEqual(thinking);
|
||||
// Silent for 15 minutes with nothing running: the turn died.
|
||||
expect(workingOn([prompt], new Map(), now + 20 * 60_000)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -95,6 +95,44 @@ import { CloneHereDialog } from "./CloneHereDialog";
|
||||
|
||||
/** One upload batch at most (the server caps one file the same). */
|
||||
export const FX_BATCH_MAX_BYTES = 500 * 1024 * 1024;
|
||||
/** Files up to this size travel together, many per request (`uploadBatch`):
|
||||
* their cost is per FILE (a request, a path walk, a sudo), not per byte. */
|
||||
const SMALL_FILE = 4 * 1024 * 1024;
|
||||
const PACK_BYTES = 32 * 1024 * 1024;
|
||||
const PACK_FILES = 1000;
|
||||
/** Keeps a pack's path list well under the server's 1 MB JSON body (upload-check). */
|
||||
const PACK_NAME_BYTES = 400 * 1024;
|
||||
|
||||
/** Small files in packs (each ≥ 2 files) and the rest, one by one. */
|
||||
export function packUploads<T extends { file: Blob; rel: string }>(files: T[]) {
|
||||
const packs: T[][] = [];
|
||||
const singles: T[] = [];
|
||||
let cur: T[] = [];
|
||||
let bytes = 0;
|
||||
let names = 0;
|
||||
for (const f of files) {
|
||||
if (f.file.size > SMALL_FILE) {
|
||||
singles.push(f);
|
||||
continue;
|
||||
}
|
||||
const n = f.rel.length * 3 + 64;
|
||||
if (
|
||||
cur.length &&
|
||||
(cur.length >= PACK_FILES || bytes + f.file.size > PACK_BYTES || names + n > PACK_NAME_BYTES)
|
||||
) {
|
||||
packs.push(cur);
|
||||
cur = [];
|
||||
bytes = 0;
|
||||
names = 0;
|
||||
}
|
||||
cur.push(f);
|
||||
bytes += f.file.size;
|
||||
names += n;
|
||||
}
|
||||
if (cur.length) packs.push(cur);
|
||||
for (const p of packs.filter((p) => p.length < 2)) singles.push(...p);
|
||||
return { packs: packs.filter((p) => p.length >= 2), singles };
|
||||
}
|
||||
const MIME = "application/x-agenthub-fx";
|
||||
const VIEW_KEY = "agenthub.files.view";
|
||||
const HIDDEN_KEY = "agenthub.files.hidden";
|
||||
@@ -327,7 +365,11 @@ export function FileBrowser({
|
||||
? (lanes.find((l) => l.id === target)?.cwd ?? null)
|
||||
: null;
|
||||
const cloneParent =
|
||||
cloneRoot && view === "files" ? (path ? `${cloneRoot.replace(/\/+$/, "")}/${path}` : cloneRoot) : null;
|
||||
cloneRoot && view === "files"
|
||||
? path
|
||||
? `${cloneRoot.replace(/\/+$/, "")}/${path}`
|
||||
: cloneRoot
|
||||
: null;
|
||||
|
||||
/** Go to `p` in workspace `tgt`, remembering where we were for Back. */
|
||||
const go = (p: string, tgt: FsPlace = target) => {
|
||||
@@ -556,32 +598,95 @@ export function FileBrowser({
|
||||
}
|
||||
setBusy(true);
|
||||
let sent = 0;
|
||||
let done = 0;
|
||||
let saved = 0;
|
||||
let forAll: FsConflict | null = null;
|
||||
try {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const { file, rel } = files[i]!;
|
||||
const dest = join(dir, rel);
|
||||
setProgress({ name: rel, done: i, total: files.length, sent, bytes });
|
||||
const send = (c: FsConflict) =>
|
||||
api.files.upload(target, dest, file, c, (n) =>
|
||||
setProgress({ name: rel, done: i, total: files.length, sent: sent + n, bytes })
|
||||
);
|
||||
try {
|
||||
const r = await send(forAll ?? "fail").catch(async (e) => {
|
||||
if (errCode(e) !== "EEXISTS") throw e;
|
||||
const a = await askConflict([rel], files.length > 1);
|
||||
if (!a.choice) throw Object.assign(new Error("cancelled"), { code: "ECANCEL" });
|
||||
if (a.all) forAll = a.choice;
|
||||
return send(a.choice);
|
||||
});
|
||||
if (!r.skipped) saved++;
|
||||
} catch (e) {
|
||||
if (errCode(e) === "ECANCEL") break;
|
||||
toast.error(t("fx.uploadFailed", { name: rel, message: errText(e) }));
|
||||
}
|
||||
sent += file.size;
|
||||
// false once the server says a batch cannot be written here (an old helper).
|
||||
let batchable = true;
|
||||
const show = (name: string, extra = 0) =>
|
||||
setProgress({ name, done, total: files.length, sent: sent + extra, bytes });
|
||||
/** One file; false = the user cancelled. */
|
||||
const one = async ({ file, rel }: { file: File; rel: string }) => {
|
||||
const dest = join(dir, rel);
|
||||
show(rel);
|
||||
const send = (c: FsConflict) => api.files.upload(target, dest, file, c, (n) => show(rel, n));
|
||||
try {
|
||||
const r = await send(forAll ?? "fail").catch(async (e) => {
|
||||
if (errCode(e) !== "EEXISTS") throw e;
|
||||
const a = await askConflict([rel], files.length > 1);
|
||||
if (!a.choice) throw Object.assign(new Error("cancelled"), { code: "ECANCEL" });
|
||||
if (a.all) forAll = a.choice;
|
||||
return send(a.choice);
|
||||
});
|
||||
if (!r.skipped) saved++;
|
||||
} catch (e) {
|
||||
if (errCode(e) === "ECANCEL") return false;
|
||||
toast.error(t("fx.uploadFailed", { name: rel, message: errText(e) }));
|
||||
}
|
||||
sent += file.size;
|
||||
done++;
|
||||
return true;
|
||||
};
|
||||
/** Many small files in one request: "ok", "cancel", or "fallback" (send them one by one). */
|
||||
const pack = async (b: { file: File; rel: string }[]) => {
|
||||
const label = `${b[0]!.rel} (+${b.length - 1})`;
|
||||
const size = b.reduce((n, f) => n + f.file.size, 0);
|
||||
show(label);
|
||||
// Settled BEFORE the bytes go: a 409 after a 32 MB body would waste it.
|
||||
const choose = async (taken: string[]) => {
|
||||
if (forAll) return forAll;
|
||||
if (!taken.length) return "fail" as const;
|
||||
const a = await askConflict(taken, files.length > 1);
|
||||
if (a.all && a.choice) forAll = a.choice;
|
||||
return a.choice;
|
||||
};
|
||||
try {
|
||||
const c = await api.files.uploadCheck(
|
||||
target,
|
||||
dir,
|
||||
b.map((f) => f.rel)
|
||||
);
|
||||
if (!c.batch) {
|
||||
batchable = false;
|
||||
return "fallback";
|
||||
}
|
||||
let policy = await choose(c.taken);
|
||||
if (!policy) return "cancel";
|
||||
const send = (p: FsConflict) =>
|
||||
api.files.uploadBatch(target, dir, b, p, (n) => show(label, n));
|
||||
const r = await send(policy).catch(async (e) => {
|
||||
// Taken meanwhile: ask about those, then once more.
|
||||
if (errCode(e) !== "EEXISTS") throw e;
|
||||
policy = await choose((e as { names?: string[] }).names ?? []);
|
||||
if (!policy) throw Object.assign(new Error("cancelled"), { code: "ECANCEL" });
|
||||
return send(policy);
|
||||
});
|
||||
saved += r.saved;
|
||||
} catch (e) {
|
||||
const code = errCode(e);
|
||||
if (code === "ECANCEL") return "cancel";
|
||||
// Refused before a byte was written (a bad name, an old helper…):
|
||||
// each file then gets its own answer.
|
||||
if (code === "EUPGRADE") batchable = false;
|
||||
if (code && /^E(UPGRADE|BADNAME|BADPATH|SECRET|BADBODY|TOOBIG|EXISTS)$/.test(code)) {
|
||||
return "fallback";
|
||||
}
|
||||
toast.error(t("fx.uploadFailed", { name: label, message: errText(e) }));
|
||||
}
|
||||
sent += size;
|
||||
done += b.length;
|
||||
return "ok";
|
||||
};
|
||||
try {
|
||||
const { packs, singles } = packUploads(files);
|
||||
let go = true;
|
||||
for (const b of packs) {
|
||||
const r = batchable ? await pack(b) : "fallback";
|
||||
if (r === "cancel") go = false;
|
||||
if (r === "fallback") for (const f of b) if (go && !(await one(f))) go = false;
|
||||
if (!go) break;
|
||||
}
|
||||
for (const f of singles) if (!go || !(await one(f))) break;
|
||||
} finally {
|
||||
setProgress(null);
|
||||
setBusy(false);
|
||||
@@ -1488,7 +1593,9 @@ export function FileBrowser({
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={close(() => startCreate("file"))}>{t("fx.newFile")}</MenuItem>
|
||||
{cloneParent && (
|
||||
<MenuItem onSelect={close(() => setCloning(true))}>{t("fx.cloneRepo")}</MenuItem>
|
||||
<MenuItem onSelect={close(() => setCloning(true))}>
|
||||
{t("fx.cloneRepo")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuSeparator />
|
||||
<MenuItem onSelect={close(() => fileInput.current?.click())}>
|
||||
|
||||
@@ -1422,6 +1422,27 @@ export function LaneChangesPanel({
|
||||
}
|
||||
};
|
||||
|
||||
// Discard all: every non-conflicted file in one `discard`, asked once in a
|
||||
// dialog that names how many untracked files it will delete.
|
||||
const discardAll = async () => {
|
||||
const rows = files.filter((f) => !f.conflicted);
|
||||
if (!rows.length) return;
|
||||
const ok = await confirmDialog({
|
||||
title: t("changes.discardAllTitle"),
|
||||
message: t("changes.discardAllConfirm", {
|
||||
count: rows.length,
|
||||
untracked: rows.filter((f) => f.untracked).length,
|
||||
}),
|
||||
confirmLabel: t("changes.discardAll"),
|
||||
});
|
||||
if (ok)
|
||||
await act(
|
||||
"discard",
|
||||
rows.map((f) => f.path),
|
||||
true
|
||||
);
|
||||
};
|
||||
|
||||
// resolve / merge-commit / merge-abort: same shape as `act`, other body.
|
||||
const mergeAct = async (
|
||||
action: "resolve" | "merge-commit" | "merge-abort",
|
||||
@@ -2756,6 +2777,17 @@ export function LaneChangesPanel({
|
||||
>
|
||||
{t(amend ? "changes.amendAndPush" : "changes.commitAndPush")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-changes-discard-all"
|
||||
disabled={busy}
|
||||
onClick={() => void discardAll()}
|
||||
title={t("changes.discardAllTitle")}
|
||||
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-status-danger hover:bg-status-danger/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" aria-hidden />
|
||||
{t("changes.discardAll")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -45,6 +45,8 @@ vi.mock("../../../lib/api", () => ({
|
||||
restore: vi.fn(),
|
||||
purge: vi.fn(),
|
||||
upload: vi.fn(),
|
||||
uploadBatch: vi.fn(),
|
||||
uploadCheck: vi.fn(),
|
||||
zipUrl: (t: number, ps: string[]) => `/api/lanes/${t}/fs/zip?p=${ps.join("&p=")}`,
|
||||
},
|
||||
},
|
||||
@@ -196,6 +198,41 @@ describe("FileExplorer", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("sends many small files as one batch, asking once about every taken name", async () => {
|
||||
vi.mocked(api.files.uploadCheck).mockResolvedValue({ taken: ["a.txt", "b.txt"], batch: true });
|
||||
vi.mocked(api.files.uploadBatch).mockResolvedValue({ saved: 3, skipped: 0, paths: [] });
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const files = ["a.txt", "b.txt", "c.txt"].map((n) => new File(["hi"], n));
|
||||
await user.upload(screen.getByTestId("fx-upload-files"), files);
|
||||
const ask = await screen.findByTestId("fx-conflict");
|
||||
await user.click(within(ask).getByRole("button", { name: "Replace" }));
|
||||
await waitFor(() =>
|
||||
expect(api.files.uploadBatch).toHaveBeenCalledWith(
|
||||
1,
|
||||
"",
|
||||
files.map((file) => ({ file, rel: file.name })),
|
||||
"replace",
|
||||
expect.any(Function)
|
||||
)
|
||||
);
|
||||
expect(api.files.uploadCheck).toHaveBeenCalledWith(1, "", ["a.txt", "b.txt", "c.txt"]);
|
||||
expect(api.files.upload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to one request per file when the server cannot take a batch", async () => {
|
||||
vi.mocked(api.files.uploadCheck).mockResolvedValue({ taken: [], batch: false });
|
||||
vi.mocked(api.files.upload).mockResolvedValue({ path: "x" });
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const files = ["x.txt", "y.txt"].map((n) => new File(["hi"], n));
|
||||
await user.upload(screen.getByTestId("fx-upload-files"), files);
|
||||
await waitFor(() => expect(api.files.upload).toHaveBeenCalledTimes(2));
|
||||
expect(api.files.uploadBatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a batch over 500 MB before sending anything", async () => {
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
|
||||
@@ -770,6 +770,24 @@ describe("LaneChangesPanel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("discards every file at once only after the dialog is confirmed", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
|
||||
await user.click(screen.getByTestId("lane-changes-discard-all"));
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByTestId("lane-changes-discard-all"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", {
|
||||
paths: ["server/lib/git-ops.js", "notes.txt", "docs/API.md"],
|
||||
confirm: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("right-click on a changed file offers Commit File, a two-click discard, copy path and patch", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/**
|
||||
* @file ChatAddMenu.tsx
|
||||
* @description The Chat view's `+` menu, Claude Desktop's: Upload or add
|
||||
* files (the prompt box's own picker), what is already there to attach —
|
||||
* files (the prompt box's own picker), Choose a file on this computer (a
|
||||
* browser on the dashboard's own machine only: the OS's own picker,
|
||||
* `POST /api/lanes/pick-path`, attaches the file by its path — any size,
|
||||
* nothing uploaded), what is already there to attach —
|
||||
* Project files (a search of the box's folder, folders too:
|
||||
* `GET /api/run/files?dirs=1`, sent as `@path`), the workspace's Context
|
||||
* files (`GET /api/lanes/:id/sources`) and Recent uploads (this browser's,
|
||||
@@ -21,6 +24,7 @@ import {
|
||||
ChevronRight,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
HardDrive,
|
||||
History,
|
||||
Paperclip,
|
||||
Plug,
|
||||
@@ -29,7 +33,14 @@ import {
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { projectAttachment, recentUploads, type Attachment } from "../../lib/promptAttach";
|
||||
import {
|
||||
isLocalBrowser,
|
||||
localAttachment,
|
||||
projectAttachment,
|
||||
recentUploads,
|
||||
type Attachment,
|
||||
} from "../../lib/promptAttach";
|
||||
import { toast } from "../../lib/toast";
|
||||
|
||||
interface Entry {
|
||||
name: string;
|
||||
@@ -244,6 +255,17 @@ export function ChatAddMenu({
|
||||
close();
|
||||
onAttach([item]);
|
||||
};
|
||||
const pickLocal = async () => {
|
||||
close();
|
||||
// The dialog opens on the desktop, sometimes behind this window.
|
||||
toast.info(t("chat.pickLocalOpen"));
|
||||
try {
|
||||
const r = await api.lanes.pickPath("file", cwd || undefined);
|
||||
if (r.path) onAttach([localAttachment(r.path)]);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
const manage = (tab: "skills" | "plugins") => {
|
||||
close();
|
||||
navigate(`/cc-config?tab=${tab}`);
|
||||
@@ -291,6 +313,16 @@ export function ChatAddMenu({
|
||||
onUpload();
|
||||
}}
|
||||
/>
|
||||
{isLocalBrowser() && (
|
||||
<Row
|
||||
icon={<HardDrive className="h-4 w-4" />}
|
||||
label={t("chat.pickLocal")}
|
||||
title={t("chat.pickLocalHint")}
|
||||
testId="chat-add-pick-local"
|
||||
onHover={() => setSub(null)}
|
||||
onClick={() => void pickLocal()}
|
||||
/>
|
||||
)}
|
||||
<Row
|
||||
icon={<FolderOpen className="h-4 w-4" />}
|
||||
label={t("chat.projectFiles")}
|
||||
|
||||
@@ -18,15 +18,40 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, type DragEvent } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Copy, FileText, Loader2, X } from "lucide-react";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Download,
|
||||
FileText,
|
||||
Loader2,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Share2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api, gitTarget } from "../../lib/api";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { dragSkill } from "../../lib/promptAttach";
|
||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||
import { MarkdownPreview } from "../MarkdownPreview";
|
||||
import { CodeBlock } from "../conversation/CodeBlock";
|
||||
import { useTheme } from "../../hooks/useTheme";
|
||||
import { toast } from "../../lib/toast";
|
||||
import type { TranscriptMessage } from "../../lib/types";
|
||||
import {
|
||||
RENDERED,
|
||||
artifactDocument,
|
||||
artifactKind,
|
||||
artifactLang,
|
||||
artifactVersions,
|
||||
frameSandbox,
|
||||
parseCsv,
|
||||
type ArtifactKind,
|
||||
} from "../../lib/artifactView";
|
||||
|
||||
/** Where a Chat pane's files can be read from. */
|
||||
export interface PreviewScope {
|
||||
@@ -146,33 +171,89 @@ function useContent(path: string, scope: PreviewScope): Content {
|
||||
return content;
|
||||
}
|
||||
|
||||
function Body({ path, scope }: { path: string; scope: PreviewScope }) {
|
||||
/** A CSV/TSV as a table (the first rows; a file is not a spreadsheet). */
|
||||
function CsvTable({ text, tab }: { text: string; tab: boolean }) {
|
||||
const rows = useMemo(() => parseCsv(text, tab ? "\t" : ",").slice(0, 1000), [text, tab]);
|
||||
const [head, ...body] = rows;
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-auto p-3">
|
||||
<table className="w-full border-collapse text-xs">
|
||||
{head && (
|
||||
<thead className="sticky top-0 bg-surface-2">
|
||||
<tr>
|
||||
{head.map((h, i) => (
|
||||
<th
|
||||
key={i}
|
||||
className="border border-border px-2 py-1 text-left font-medium text-fg-primary"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
)}
|
||||
<tbody>
|
||||
{body.map((r, i) => (
|
||||
<tr key={i} className="odd:bg-surface-1 even:bg-surface-2/40">
|
||||
{r.map((c, j) => (
|
||||
<td key={j} className="border border-border px-2 py-1 text-fg-secondary">
|
||||
{c}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** An HTML, SVG, Mermaid or React artifact, running in its own sandbox:
|
||||
* scripts (none for an SVG) but never the dashboard's origin. */
|
||||
function ArtifactFrame({ kind, text, title }: { kind: ArtifactKind; text: string; title: string }) {
|
||||
const { theme } = useTheme();
|
||||
const doc = useMemo(() => artifactDocument(kind, text, theme === "dark"), [kind, text, theme]);
|
||||
return (
|
||||
<iframe
|
||||
data-testid="artifact-frame"
|
||||
title={title}
|
||||
srcDoc={doc ?? ""}
|
||||
sandbox={frameSandbox(kind)}
|
||||
referrerPolicy="no-referrer"
|
||||
className={`min-h-0 w-full flex-1 border-0 ${kind === "html" || kind === "react" ? "bg-white" : ""}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type View = "preview" | "code";
|
||||
|
||||
function Body({ path, content, view }: { path: string; content: Content; view: View }) {
|
||||
const { t } = useTranslation("run");
|
||||
const loaded = useContent(path, scope);
|
||||
const kind = artifactKind(path);
|
||||
// An image the route refused (gone, server mode) shows like no preview.
|
||||
const [broken, setBroken] = useState<string | null>(null);
|
||||
const content: Content =
|
||||
loaded.kind === "image" && loaded.src === broken ? { kind: "none" } : loaded;
|
||||
if (content.kind === "loading") {
|
||||
const shown: Content =
|
||||
content.kind === "image" && content.src === broken ? { kind: "none" } : content;
|
||||
if (shown.kind === "loading") {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-fg-muted" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (content.kind === "image") {
|
||||
if (shown.kind === "image") {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center overflow-auto p-4">
|
||||
<img
|
||||
src={content.src}
|
||||
src={shown.src}
|
||||
alt={baseName(path)}
|
||||
onError={() => setBroken(content.src)}
|
||||
onError={() => setBroken(shown.src)}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (content.kind === "none") {
|
||||
if (shown.kind === "none") {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-sm text-fg-muted">
|
||||
<FileText className="h-8 w-8" />
|
||||
@@ -181,69 +262,244 @@ function Body({ path, scope }: { path: string; scope: PreviewScope }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{isMarkdown(path) ? (
|
||||
<MarkdownPreview text={content.text} className="p-4" />
|
||||
) : (
|
||||
<pre className="p-4 font-mono text-xs leading-relaxed text-fg-secondary">
|
||||
{content.text}
|
||||
</pre>
|
||||
)}
|
||||
{content.truncated && (
|
||||
<p className="px-4 pb-4 text-xs text-fg-muted">{t("chat.outline.truncated")}</p>
|
||||
)}
|
||||
</div>
|
||||
const note = shown.truncated && (
|
||||
<p className="px-4 pb-4 text-xs text-fg-muted">{t("chat.outline.truncated")}</p>
|
||||
);
|
||||
if (view === "code" || !RENDERED.has(kind)) {
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-auto p-3">
|
||||
<CodeBlock code={shown.text} lang={artifactLang(path)} maxHeight="none" showLineNumbers />
|
||||
{note}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (kind === "markdown") {
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<MarkdownPreview text={shown.text} className="p-4" />
|
||||
{note}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (kind === "csv") return <CsvTable text={shown.text} tab={/\.tsv$/i.test(path)} />;
|
||||
return <ArtifactFrame kind={kind} text={shown.text} title={baseName(path)} />;
|
||||
}
|
||||
|
||||
/** One file: header (name · EXT, Copy path, Close) over its content. */
|
||||
const iconBtn =
|
||||
"flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary disabled:opacity-40";
|
||||
|
||||
/** Saves text (or an image's URL) as a file through the browser. */
|
||||
function download(name: string, content: Content) {
|
||||
const a = document.createElement("a");
|
||||
if (content.kind === "image") a.href = content.src;
|
||||
else if (content.kind === "text")
|
||||
a.href = URL.createObjectURL(new Blob([content.text], { type: "text/plain;charset=utf-8" }));
|
||||
else return;
|
||||
a.download = name;
|
||||
a.click();
|
||||
if (content.kind === "text") setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
}
|
||||
|
||||
/** One file, Claude Desktop's artifact view: name · EXT, Preview | Code, the
|
||||
* versions the conversation made of it, Copy, Download, Share to the
|
||||
* Artifacts Hub, full screen, Close. */
|
||||
export function FilePreview({
|
||||
path,
|
||||
scope,
|
||||
onClose,
|
||||
className = "",
|
||||
messages,
|
||||
}: {
|
||||
path: string;
|
||||
scope: PreviewScope;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
/** The conversation, for the versions its Write/Edit calls left. */
|
||||
messages?: TranscriptMessage[];
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
return (
|
||||
const kind = artifactKind(path);
|
||||
const loaded = useContent(path, scope);
|
||||
const [view, setView] = useState<View>(RENDERED.has(kind) ? "preview" : "code");
|
||||
const [full, setFull] = useState(false);
|
||||
const [sharing, setSharing] = useState(false);
|
||||
const versions = useMemo(
|
||||
() => (messages ? artifactVersions(messages, path) : []),
|
||||
[messages, path]
|
||||
);
|
||||
// null: the newest — the file as it is now.
|
||||
const [picked, setPicked] = useState<number | null>(null);
|
||||
// Another file opens on its own default tab, at its newest version.
|
||||
useEffect(() => {
|
||||
setPicked(null);
|
||||
setView(RENDERED.has(artifactKind(path)) ? "preview" : "code");
|
||||
}, [path]);
|
||||
const last = Math.max(versions.length - 1, 0);
|
||||
const at = picked ?? last;
|
||||
const older = picked !== null && picked < last ? versions[picked] : null;
|
||||
const content: Content =
|
||||
older && older.content !== null
|
||||
? { kind: "text", text: older.content, truncated: false }
|
||||
: loaded;
|
||||
const known = (i: number) => i === last || versions[i]?.content != null;
|
||||
const step = (dir: -1 | 1) => {
|
||||
for (let i = at + dir; i >= 0 && i <= last; i += dir) {
|
||||
if (known(i)) return setPicked(i === last ? null : i);
|
||||
}
|
||||
};
|
||||
const share = async () => {
|
||||
if (sharing) return;
|
||||
setSharing(true);
|
||||
try {
|
||||
const file = slashed(path);
|
||||
const base = slashed(scope.laneCwd).replace(/\/+$/, "");
|
||||
if (scope.laneId !== null && base && file.startsWith(`${base}/`) && picked === null) {
|
||||
await api.artifacts.pinFile({
|
||||
laneId: scope.laneId,
|
||||
path: file.slice(base.length + 1),
|
||||
title: displayName(path),
|
||||
...(scope.sessionId ? { sessionId: scope.sessionId } : {}),
|
||||
});
|
||||
} else if (content.kind === "text") {
|
||||
await api.artifacts.uploadFile(new File([content.text], displayName(path)), {
|
||||
title: displayName(path),
|
||||
});
|
||||
} else throw new Error(t("chat.artifact.cannotShare"));
|
||||
toast.success(t("chat.artifact.shared"));
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setSharing(false);
|
||||
}
|
||||
};
|
||||
const panel = (
|
||||
<section
|
||||
data-testid="chat-file-preview"
|
||||
className={`flex min-h-0 min-w-0 flex-col overflow-hidden bg-surface-1 ${className}`}
|
||||
className={
|
||||
full
|
||||
? "fixed inset-4 z-[200] flex min-h-0 min-w-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl"
|
||||
: `flex min-h-0 min-w-0 flex-col overflow-hidden bg-surface-1 ${className}`
|
||||
}
|
||||
>
|
||||
<header className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
||||
<span className="min-w-0 truncate text-sm text-fg-primary" title={path}>
|
||||
<header className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyText(path)}
|
||||
title={t("chat.outline.copyPath", { path })}
|
||||
className="min-w-0 truncate text-left text-sm text-fg-primary hover:underline"
|
||||
>
|
||||
{displayName(path).replace(/\.[A-Za-z0-9]+$/, "")}
|
||||
</span>
|
||||
<span className="text-xs text-fg-muted">{extOf(path)}</span>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
</button>
|
||||
<span className="flex-shrink-0 text-xs text-fg-muted">{extOf(path)}</span>
|
||||
{versions.length > 1 && (
|
||||
<span
|
||||
data-testid="artifact-versions"
|
||||
className="flex flex-shrink-0 items-center text-xs text-fg-muted"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => step(-1)}
|
||||
disabled={![...Array(at).keys()].some(known)}
|
||||
aria-label={t("chat.artifact.older")}
|
||||
title={t("chat.artifact.older")}
|
||||
className={iconBtn}
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<span className="tabular-nums">
|
||||
{t("chat.artifact.version", { n: at + 1, total: versions.length })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => step(1)}
|
||||
disabled={at >= last}
|
||||
aria-label={t("chat.artifact.newer")}
|
||||
title={t("chat.artifact.newer")}
|
||||
className={iconBtn}
|
||||
>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex flex-shrink-0 items-center gap-1">
|
||||
{RENDERED.has(kind) && kind !== "image" && (
|
||||
<div role="tablist" className="mr-1 flex rounded-md border border-border p-0.5 text-xs">
|
||||
{(["preview", "code"] as const).map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === v}
|
||||
data-testid={`artifact-tab-${v}`}
|
||||
onClick={() => setView(v)}
|
||||
className={`rounded px-2 py-0.5 ${view === v ? "bg-surface-3 text-fg-primary" : "text-fg-muted hover:text-fg-primary"}`}
|
||||
>
|
||||
{t(`chat.artifact.${v}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyText(path)}
|
||||
title={t("chat.outline.copyPath", { path })}
|
||||
aria-label={t("chat.outline.copyPath", { path })}
|
||||
className="flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||
data-testid="artifact-copy"
|
||||
onClick={() => void copyText(content.kind === "text" ? content.text : path)}
|
||||
title={t("chat.artifact.copy")}
|
||||
aria-label={t("chat.artifact.copy")}
|
||||
className={iconBtn}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => download(displayName(path), content)}
|
||||
disabled={content.kind !== "text" && content.kind !== "image"}
|
||||
title={t("chat.artifact.download")}
|
||||
aria-label={t("chat.artifact.download")}
|
||||
className={iconBtn}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="artifact-share"
|
||||
onClick={() => void share()}
|
||||
disabled={sharing || content.kind === "loading" || content.kind === "none"}
|
||||
title={t("chat.artifact.share")}
|
||||
aria-label={t("chat.artifact.share")}
|
||||
className={iconBtn}
|
||||
>
|
||||
{sharing ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Share2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="artifact-expand"
|
||||
onClick={() => setFull((f) => !f)}
|
||||
title={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
|
||||
aria-label={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
|
||||
className={iconBtn}
|
||||
>
|
||||
{full ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
title={t("chat.outline.close")}
|
||||
aria-label={t("chat.outline.close")}
|
||||
className="flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||
className={iconBtn}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<Body path={path} scope={scope} />
|
||||
<Body path={path} content={content} view={view} />
|
||||
</section>
|
||||
);
|
||||
// Full screen: over the whole page, where a pane's edges cannot clip it.
|
||||
return full ? createPortal(panel, document.body) : panel;
|
||||
}
|
||||
|
||||
/** Claude's Uploads dialog: the files on the left, the picked one on the right. */
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
* new lane's own live session for its slot, or an empty pane — never the
|
||||
* previous lane's terminal. `attachRequest` (a run picked in the sidebar or a
|
||||
* tab) attaches to that tmux session only; nothing is started or resumed.
|
||||
* A Claude or Codex pane also has a Chat view (`PaneChat`): the session's
|
||||
* A pane of a `CHAT_TOOLS` tool (Claude, Codex, omp, Cursor, Antigravity) also
|
||||
* has a Chat view (`PaneChat`): the session's
|
||||
* conversation laid OVER the terminal, which stays mounted and attached under
|
||||
* it. Chat or terminal is each SESSION's own (`useSessionChat`, the toggle
|
||||
* left of the pane's split buttons); the Chat UI setting (Settings →
|
||||
@@ -29,9 +30,7 @@ import type { Lane } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { TerminalView } from "./TerminalView";
|
||||
import { PaneChat } from "./PaneChat";
|
||||
import { useSessionChat } from "../../lib/chatUi";
|
||||
|
||||
const CHAT_TOOLS = new Set(["claude", "codex"]);
|
||||
import { CHAT_TOOLS, useSessionChat } from "../../lib/chatUi";
|
||||
|
||||
export interface LaneConsolePaneProps {
|
||||
lanes: Lane[];
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
* in the box beside `+`. A menu the CLI asks with — AskUserQuestion, a
|
||||
* permission or trust prompt, Codex's approval — is read off the terminal
|
||||
* underneath (`readRunMenu`) and shown in the prompt box's place as buttons
|
||||
* that press its keys there (`PaneMenuCard`); its "Type something" row opens
|
||||
* that press its keys there (`PaneMenuCard`) — a permission prompt with its tool, command and a deny-with-note
|
||||
* (`PermissionMenuCard`); its "Type something" row opens
|
||||
* a field in the card, whose text is typed there as the answer
|
||||
* (`answerRunMenu`).
|
||||
* It also has Claude Desktop's activity panel (`ChatOutline`: progress,
|
||||
@@ -56,7 +57,17 @@
|
||||
|
||||
import { memo, useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowUp, FileText, Square, SquareCheck } from "lucide-react";
|
||||
import {
|
||||
ArrowUp,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
MessageCircleQuestion,
|
||||
ShieldQuestion,
|
||||
Square,
|
||||
SquareCheck,
|
||||
} from "lucide-react";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { ConversationView } from "../conversation/ConversationView";
|
||||
import {
|
||||
@@ -77,7 +88,11 @@ import { Divider, showTerminal } from "./PaneGrid";
|
||||
import {
|
||||
answerRunMenu,
|
||||
chooseRunOption,
|
||||
notePaneFocus,
|
||||
onPaneOutput,
|
||||
stepRunMenu,
|
||||
interruptRun,
|
||||
readRunBusy,
|
||||
readRunMenu,
|
||||
submitToRun,
|
||||
type PaneMenu,
|
||||
@@ -89,7 +104,9 @@ import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { setTabBadge } from "../../lib/tabBadge";
|
||||
import { fmt } from "../../lib/format";
|
||||
import { agentAwaitingReason } from "../../lib/types";
|
||||
import { agentAwaitingReason, type TranscriptMessage, type WSMessage } from "../../lib/types";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { throttle } from "../../lib/throttle";
|
||||
import { api, type EffortLevel, type PermissionMode, type RunHandle } from "../../lib/api";
|
||||
|
||||
const MODES: [PermissionMode, string][] = [
|
||||
@@ -100,6 +117,11 @@ const MODES: [PermissionMode, string][] = [
|
||||
["bypassPermissions", "Bypass"],
|
||||
];
|
||||
const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v === m);
|
||||
// Pane output re-reads the transcript at most this often: a TUI spinner
|
||||
// redraws several times a second, and each read walks the transcript file.
|
||||
const OUTPUT_FETCH_MS = 1000;
|
||||
/** A tool with no known busy line counts as at work this long after its pane last drew. */
|
||||
const OUTPUT_BUSY_MS = 2000;
|
||||
const SIDE_KEY = "agenthub.chat.panelWidth";
|
||||
const clampSide = (f: number) => Math.round(Math.min(0.7, Math.max(0.2, f)) * 100) / 100;
|
||||
const outlineKey = (sessionId: string) => `agenthub.chat.panel.${sessionId}`;
|
||||
@@ -117,6 +139,7 @@ const storedSide = () => {
|
||||
* (LiteLLM's list, 2026-10). Null for another vendor's model. */
|
||||
// ponytail: a fixed rule, not a table; read LiteLLM's max_input_tokens if a new 200K model ships.
|
||||
export function contextWindow(model: string | null): number | null {
|
||||
// A Codex/omp chat reports its own window (ContextMeter's `window`).
|
||||
if (!model?.startsWith("claude-")) return null;
|
||||
return /^claude-(?:[23]|instant|(?:opus|sonnet|haiku)-4(?:-[0-5])?(?:-\d{8})?$)/.test(model)
|
||||
? 200_000
|
||||
@@ -125,7 +148,13 @@ export function contextWindow(model: string | null): number | null {
|
||||
|
||||
/** How full the context is: a ring and a percentage, amber then red as
|
||||
* Claude Code's auto-compact comes near. */
|
||||
function ContextMeter({ tokens, model }: { tokens: number; model: string | null }) {
|
||||
function ContextMeter({
|
||||
tokens,
|
||||
model,
|
||||
}: {
|
||||
tokens: number;
|
||||
model: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const size = contextWindow(model);
|
||||
if (!size || !tokens) return null;
|
||||
@@ -212,33 +241,222 @@ function PermissionChip({ runId, initial }: { runId: string; initial: Permission
|
||||
);
|
||||
}
|
||||
|
||||
/** A permission prompt (Claude's tool approval, Codex's command approval,
|
||||
* the folder-trust question): it offers a Yes and a No. */
|
||||
export function isPermissionMenu(menu: PaneMenu): boolean {
|
||||
if (menu.multi || menu.tabs) return false;
|
||||
const labels = menu.options.map((o) => o.label);
|
||||
return labels.some((l) => /^Yes\b/i.test(l)) && labels.some((l) => /^No\b/i.test(l));
|
||||
}
|
||||
|
||||
/** A menu row's label without its key hint (`Yes, proceed (y)`). */
|
||||
const bareLabel = (label: string) => label.replace(/\s+\((?:[a-z]|esc|shift\+tab)\)$/i, "");
|
||||
|
||||
/** A permission prompt read off the terminal: the tool, what it will run, and the CLI's own
|
||||
* answers as buttons — each still presses its key in the pane. "No, and
|
||||
* tell … differently" also takes a note, sent as the next message once the
|
||||
* CLI is back at its prompt. */
|
||||
function PermissionMenuCard({
|
||||
menu,
|
||||
onChoose,
|
||||
onDenyNote,
|
||||
onCancel,
|
||||
}: {
|
||||
menu: PaneMenu;
|
||||
onChoose: (index: number) => void;
|
||||
onDenyNote: (index: number, note: string) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const [noting, setNoting] = useState(false);
|
||||
const [note, setNote] = useState("");
|
||||
const q = menu.question.split("\n");
|
||||
const title = menu.detail?.[0] ?? q[0] ?? "";
|
||||
const body = (menu.detail ? menu.detail.slice(1) : q.slice(1)).join("\n");
|
||||
const ask = menu.detail ? menu.question : "";
|
||||
const yes = menu.options.findIndex((o) => /^Yes\b/i.test(o.label));
|
||||
const tell = menu.options.findIndex((o) => /^No, and tell\b/i.test(o.label));
|
||||
const sendNote = () => {
|
||||
if (note.trim() && tell >= 0) onDenyNote(tell, note.trim());
|
||||
};
|
||||
return (
|
||||
<div data-testid="pane-chat-permission" className="flex flex-col gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<ShieldQuestion className="mt-0.5 h-4 w-4 flex-shrink-0 text-status-warning" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="whitespace-pre-wrap break-words text-sm font-medium text-fg-primary">
|
||||
{title}
|
||||
</p>
|
||||
{ask && <p className="text-xs text-fg-muted">{ask}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{body && (
|
||||
<pre className="max-h-40 overflow-auto whitespace-pre-wrap break-all rounded-lg border border-border bg-surface-2 px-3 py-2 font-mono text-xs text-fg-secondary">
|
||||
{body}
|
||||
</pre>
|
||||
)}
|
||||
{noting ? (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-accent/50 bg-surface-2 px-3 py-1">
|
||||
<input
|
||||
autoFocus
|
||||
data-testid="pane-chat-permission-note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
sendNote();
|
||||
} else if (e.key === "Escape") setNoting(false);
|
||||
}}
|
||||
placeholder={t("chat.request.notePlaceholder")}
|
||||
aria-label={t("chat.request.notePlaceholder")}
|
||||
className="min-w-0 flex-1 bg-transparent py-0.5 text-sm text-fg-primary outline-none placeholder:text-fg-muted"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={sendNote}
|
||||
disabled={!note.trim()}
|
||||
className="btn-secondary text-xs disabled:opacity-40"
|
||||
>
|
||||
{t("chat.request.send")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{menu.options.map((o, i) => (
|
||||
<button
|
||||
key={`${i}-${o.label}`}
|
||||
type="button"
|
||||
data-testid="pane-chat-permission-option"
|
||||
autoFocus={i === yes}
|
||||
onClick={() => onChoose(i)}
|
||||
title={o.hint || undefined}
|
||||
className={`${i === yes ? "btn-primary" : "btn-secondary"} max-w-full break-words text-left text-xs`}
|
||||
>
|
||||
{bareLabel(o.label)}
|
||||
</button>
|
||||
))}
|
||||
{tell >= 0 && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pane-chat-permission-tell"
|
||||
onClick={() => setNoting(true)}
|
||||
className="text-xs text-fg-muted underline-offset-2 hover:text-fg-primary hover:underline"
|
||||
>
|
||||
{t("chat.request.denyWithNote")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="ml-auto rounded-md border border-border px-2 py-0.5 text-xs text-fg-muted hover:bg-surface-3"
|
||||
>
|
||||
{t("chat.menu.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The menu the CLI asks with, as buttons: a pick presses its key in the
|
||||
* pane; its "Type something" row opens a field right there instead —
|
||||
* pressing that row's digit again would type the digit into the CLI's own. */
|
||||
* pressing that row's digit again would type the digit into the CLI's own.
|
||||
* A permission prompt takes its own layout (`PermissionMenuCard`), any
|
||||
* other menu the question layout. */
|
||||
export function PaneMenuCard({
|
||||
menu,
|
||||
onChoose,
|
||||
onAnswer,
|
||||
onCancel,
|
||||
onStep,
|
||||
onDenyNote,
|
||||
}: {
|
||||
menu: PaneMenu;
|
||||
onChoose: (index: number) => void;
|
||||
onAnswer: (text: string) => void;
|
||||
onCancel: () => void;
|
||||
/** Previous (-1) / next (1) question of a multi-question AskUserQuestion. */
|
||||
onStep?: (dir: -1 | 1) => void;
|
||||
/** A permission prompt's "No, and tell …", with the note to send after it. */
|
||||
onDenyNote?: (index: number, note: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const [typing, setTyping] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const submit = () => draft.trim() && onAnswer(draft);
|
||||
if (isPermissionMenu(menu))
|
||||
return (
|
||||
<div
|
||||
data-testid="pane-chat-menu"
|
||||
className="rounded-2xl border border-border bg-surface-1 p-3 shadow-sm"
|
||||
>
|
||||
<PermissionMenuCard
|
||||
menu={menu}
|
||||
onChoose={onChoose}
|
||||
onDenyNote={onDenyNote ?? ((i) => onChoose(i))}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
data-testid="pane-chat-menu"
|
||||
className="rounded-2xl border border-border bg-surface-1 p-3 shadow-sm"
|
||||
>
|
||||
{menu.tabs && (
|
||||
<div
|
||||
data-testid="pane-chat-menu-tabs"
|
||||
className="mb-2 flex flex-wrap items-center gap-1 text-xs"
|
||||
>
|
||||
{onStep && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStep(-1)}
|
||||
aria-label={t("chat.menu.prev")}
|
||||
title={t("chat.menu.prev")}
|
||||
className="rounded-md border border-border p-0.5 text-fg-muted hover:bg-surface-3"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{menu.tabs.map((tab, i) => (
|
||||
<span
|
||||
key={`${i}-${tab.label}`}
|
||||
className={`flex items-center gap-1 rounded-md border px-1.5 py-0.5 ${
|
||||
i === menu.tab
|
||||
? "border-accent/50 bg-surface-2 text-fg-primary"
|
||||
: "border-border text-fg-muted"
|
||||
}`}
|
||||
>
|
||||
{tab.done ? (
|
||||
<Check className="h-3 w-3 text-accent" />
|
||||
) : (
|
||||
<Square className="h-3 w-3" />
|
||||
)}
|
||||
{tab.label}
|
||||
</span>
|
||||
))}
|
||||
{onStep && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStep(1)}
|
||||
aria-label={t("chat.menu.next")}
|
||||
title={t("chat.menu.next")}
|
||||
className="rounded-md border border-border p-0.5 text-fg-muted hover:bg-surface-3"
|
||||
>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{menu.question && (
|
||||
<p className="mb-2 whitespace-pre-wrap break-words text-sm font-medium text-fg-primary">
|
||||
{menu.question}
|
||||
</p>
|
||||
<div className="mb-2 flex items-start gap-2">
|
||||
<MessageCircleQuestion className="mt-0.5 h-4 w-4 flex-shrink-0 text-accent" />
|
||||
<p className="whitespace-pre-wrap break-words text-sm font-medium text-fg-primary">
|
||||
{menu.question}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
{menu.options.map((o, i) =>
|
||||
@@ -338,9 +556,10 @@ export const PaneChat = memo(function PaneChat({
|
||||
const runId = run.id;
|
||||
const claude = run.tool === "claude";
|
||||
// The unsent prompt outlives the terminal view and a reload (`lib/chatDraft`).
|
||||
const [prompt, setPrompt] = useState(() => readChatDraft(sessionId).prompt);
|
||||
const [images, setImages] = useState<PromptImage[]>(() => readChatDraft(sessionId).images);
|
||||
useEffect(() => writeChatDraft(sessionId, prompt, images), [sessionId, prompt, images]);
|
||||
const draftKey = sessionId;
|
||||
const [prompt, setPrompt] = useState(() => readChatDraft(draftKey).prompt);
|
||||
const [images, setImages] = useState<PromptImage[]>(() => readChatDraft(draftKey).images);
|
||||
useEffect(() => writeChatDraft(draftKey, prompt, images), [draftKey, prompt, images]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [follow, setFollow] = useState(0);
|
||||
const [model, setModel] = useState(run.model ?? "");
|
||||
@@ -403,7 +622,11 @@ export const PaneChat = memo(function PaneChat({
|
||||
// status filter ignored: check the status here.
|
||||
.then((r) => {
|
||||
const main = r.agents.filter((a) => a.type === "main");
|
||||
setWorking(main.some((a) => a.status === "working"));
|
||||
// A fresh start, resume or `/clear` leaves main "working" at an
|
||||
// empty prompt (SessionStart's flag) until the first prompt.
|
||||
setWorking(
|
||||
main.some((a) => a.status === "working" && agentAwaitingReason(a) !== "session_start")
|
||||
);
|
||||
setWaiting(main.some((a) => agentAwaitingReason(a) === "notification"));
|
||||
})
|
||||
.catch(() => {}),
|
||||
@@ -412,6 +635,46 @@ export const PaneChat = memo(function PaneChat({
|
||||
useEffect(() => {
|
||||
void readWorking();
|
||||
}, [readWorking]);
|
||||
// The terminal underneath drew something: the CLI is at work, so its
|
||||
// transcript may have grown — read it now, for any tool, hooks or not. A
|
||||
// spinner redraws all the time, hence the throttle.
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
const poke = throttle(
|
||||
() =>
|
||||
eventBus.publish({
|
||||
type: "transcript_changed",
|
||||
data: { session_id: sessionId },
|
||||
} as unknown as WSMessage),
|
||||
OUTPUT_FETCH_MS
|
||||
);
|
||||
const off = onPaneOutput(runId, poke);
|
||||
return () => {
|
||||
off();
|
||||
poke.cancel();
|
||||
};
|
||||
}, [runId, sessionId]);
|
||||
// The terminal's own word on a turn: its busy line (BUSY_LINE) while it
|
||||
// shows, else — a tool whose line we do not know — output in the last
|
||||
// OUTPUT_BUSY_MS. Hooks miss a tool that sends none (omp, Cursor, Codex
|
||||
// without its opt-in hooks), and arrive late on a dashboard they skip.
|
||||
const [screenBusy, setScreenBusy] = useState(false);
|
||||
useEffect(() => {
|
||||
let last = 0;
|
||||
const off = onPaneOutput(runId, () => (last = Date.now()));
|
||||
const read = () => {
|
||||
const known = readRunBusy(runId, run.tool);
|
||||
setScreenBusy(known ?? (last > 0 && Date.now() - last < OUTPUT_BUSY_MS));
|
||||
};
|
||||
read();
|
||||
const timer = window.setInterval(() => {
|
||||
if (isShown(onScreen)) read();
|
||||
}, 700);
|
||||
return () => {
|
||||
off();
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [runId, run.tool, onScreen]);
|
||||
useLiveRefresh(readWorking, {
|
||||
throttleMs: 500,
|
||||
// While Stop shows, also poll: a missed agent_updated (the hook reached
|
||||
@@ -440,6 +703,7 @@ export const PaneChat = memo(function PaneChat({
|
||||
}, 700);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [runId, onScreen]);
|
||||
const busy = working || screenBusy;
|
||||
const asking = waiting || menu !== null;
|
||||
// Gone until the pane redraws: no second press on a stale menu.
|
||||
const choose = (index: number) => {
|
||||
@@ -452,15 +716,22 @@ export const PaneChat = memo(function PaneChat({
|
||||
setFollow((n) => n + 1);
|
||||
} else toast.error(t("chat.notSent"));
|
||||
};
|
||||
// "No, and tell … differently": the CLI drops back to its prompt, where
|
||||
// the note is what the user would type next.
|
||||
const denyWithNote = (index: number, note: string) => {
|
||||
if (!menu || !chooseRunOption(runId, menu, index)) return void toast.error(t("chat.notSent"));
|
||||
setMenu(null);
|
||||
window.setTimeout(() => void command(note), 800);
|
||||
};
|
||||
|
||||
// A hidden tab says when this session waits on the user or its turn ended.
|
||||
const was = useRef({ working: false, asking: false });
|
||||
useEffect(() => {
|
||||
if (asking) setTabBadge(runId, "waiting");
|
||||
else if (working) setTabBadge(runId, null);
|
||||
else if (busy) setTabBadge(runId, null);
|
||||
else if (was.current.working || was.current.asking) setTabBadge(runId, "done");
|
||||
was.current = { working, asking };
|
||||
}, [runId, working, asking]);
|
||||
was.current = { working: busy, asking };
|
||||
}, [runId, busy, asking]);
|
||||
useEffect(() => () => setTabBadge(runId, null), [runId]);
|
||||
const [context, setContext] = useState<{ tokens: number; model: string | null }>({
|
||||
tokens: 0,
|
||||
@@ -480,6 +751,16 @@ export const PaneChat = memo(function PaneChat({
|
||||
() => showTerminal(runId, sessionId, run.tool),
|
||||
[runId, sessionId, run.tool]
|
||||
);
|
||||
// The conversation on screen: an artifact's versions are rebuilt from it.
|
||||
const [convMessages, setConvMessages] = useState<TranscriptMessage[]>([]);
|
||||
// An artifact card opens in this chat's own panel, like Claude Desktop's —
|
||||
// not the workspace's Changes panel a plain file link goes to.
|
||||
const openArtifact = useCallback(
|
||||
(named: string) => {
|
||||
setPreview(resolveIn(cwd, named));
|
||||
},
|
||||
[cwd]
|
||||
);
|
||||
const openFile = useCallback(
|
||||
(named: string) => {
|
||||
// A relative path in a reply (`server/lib/x.js`) is relative to this pane's folder.
|
||||
@@ -529,6 +810,8 @@ export const PaneChat = memo(function PaneChat({
|
||||
<div
|
||||
ref={rootRef}
|
||||
data-prompt-drop
|
||||
// The last chat focused is where the Changes panel's comments go.
|
||||
onFocusCapture={() => notePaneFocus(runId)}
|
||||
className="relative flex min-h-0 flex-1 [container-type:inline-size]"
|
||||
style={{ "--chat-side": `${side * 100}%` } as CSSProperties}
|
||||
>
|
||||
@@ -545,6 +828,8 @@ export const PaneChat = memo(function PaneChat({
|
||||
onContext={onContext}
|
||||
onNotStarted={setNotStarted}
|
||||
onOpenFile={openFile}
|
||||
onOpenArtifact={openArtifact}
|
||||
onMessages={setConvMessages}
|
||||
/>
|
||||
{!outlineOpen && !preview && (
|
||||
<button
|
||||
@@ -561,7 +846,7 @@ export const PaneChat = memo(function PaneChat({
|
||||
)}
|
||||
</div>
|
||||
<div className="mx-4">
|
||||
<div className="mx-auto w-full max-w-[max(48rem,70%)]">
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
{menu && (
|
||||
<PaneMenuCard
|
||||
// A new question starts with its field closed.
|
||||
@@ -569,6 +854,10 @@ export const PaneChat = memo(function PaneChat({
|
||||
menu={menu}
|
||||
onChoose={choose}
|
||||
onAnswer={answer}
|
||||
onDenyNote={denyWithNote}
|
||||
onStep={(dir) => {
|
||||
if (!stepRunMenu(runId, dir)) toast.error(t("chat.notSent"));
|
||||
}}
|
||||
onCancel={() => {
|
||||
if (interruptRun(runId)) setMenu(null);
|
||||
else toast.error(t("chat.notSent"));
|
||||
@@ -578,7 +867,7 @@ export const PaneChat = memo(function PaneChat({
|
||||
{/* In the card's place while the CLI asks: hidden, never unmounted,
|
||||
so a draft or an upload in flight survives the question. */}
|
||||
<div
|
||||
className={`rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50 ${menu ? "hidden" : ""}`}
|
||||
className={`rounded-[1.25rem] border border-border bg-surface-1 p-2.5 shadow-[0_2px_12px_-4px_rgb(0_0_0/0.12)] transition-[border-color,box-shadow] focus-within:border-border-light focus-within:shadow-[0_4px_20px_-6px_rgb(0_0_0/0.2)] ${menu ? "hidden" : ""}`}
|
||||
>
|
||||
<PromptEditor
|
||||
bare
|
||||
@@ -605,6 +894,26 @@ export const PaneChat = memo(function PaneChat({
|
||||
onAttach={(items) => setImages((prev) => addAttachments(prev, items))}
|
||||
/>
|
||||
{claude && <PermissionChip runId={runId} initial={run.permissionMode} />}
|
||||
{run.tool === "codex" &&
|
||||
// Codex's `/model` (then its effort) and `/permissions` are
|
||||
// numbered pickers: typed here, answered on the menu card.
|
||||
(
|
||||
[
|
||||
["/permissions", t("fields.permissionMode")],
|
||||
["/model", t("fields.model")],
|
||||
] as const
|
||||
).map(([line, label]) => (
|
||||
<button
|
||||
key={line}
|
||||
type="button"
|
||||
data-testid={`pane-chat-codex-${line.slice(1)}`}
|
||||
onClick={() => void command(line)}
|
||||
title={t("chat.codexPicker")}
|
||||
className="rounded-md px-2 py-1 text-xs text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<ContextMeter tokens={context.tokens} model={context.model} />
|
||||
{claude && (
|
||||
@@ -620,7 +929,7 @@ export const PaneChat = memo(function PaneChat({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{working && !text ? (
|
||||
{busy && !text ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pane-chat-stop"
|
||||
@@ -633,8 +942,13 @@ export const PaneChat = memo(function PaneChat({
|
||||
}}
|
||||
aria-label={t("chat.stop")}
|
||||
title={t("chat.stop")}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-surface-3 text-fg-primary transition-colors hover:bg-surface-4"
|
||||
className="relative flex h-8 w-8 items-center justify-center rounded-full bg-surface-3 text-fg-primary transition-colors hover:bg-surface-4"
|
||||
>
|
||||
{/* A turning ring: the turn is still going. */}
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-0 animate-spin rounded-full border-2 border-transparent border-t-accent"
|
||||
/>
|
||||
<Square className="h-3 w-3 fill-current" />
|
||||
</button>
|
||||
) : (
|
||||
@@ -660,6 +974,7 @@ export const PaneChat = memo(function PaneChat({
|
||||
<FilePreview
|
||||
path={preview}
|
||||
scope={scope}
|
||||
messages={convMessages}
|
||||
onClose={() => setPreview(null)}
|
||||
// Beside the chat when the pane has room, over it when not.
|
||||
className="absolute inset-x-0 top-0 bottom-[calc(100%-var(--chat-area,100%))] z-30 rounded-xl border border-border [@container(min-width:40rem)]:static [@container(min-width:40rem)]:m-3 [@container(min-width:40rem)]:ml-0 [@container(min-width:40rem)]:w-[calc(var(--chat-side)-0.75rem)] [@container(min-width:40rem)]:flex-shrink-0"
|
||||
|
||||
@@ -70,7 +70,7 @@ import {
|
||||
} from "../../lib/paneLayout";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { RunDot, type RunState } from "./ConsoleTabs";
|
||||
import { setSessionChat, useSessionChat } from "../../lib/chatUi";
|
||||
import { CHAT_TOOLS, setSessionChat, useSessionChat } from "../../lib/chatUi";
|
||||
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
|
||||
import { pasteToRun, takeRunInput } from "../../lib/panePaste";
|
||||
import { promptForPane } from "./RunSetup";
|
||||
@@ -238,7 +238,7 @@ function PaneMenu({
|
||||
const ITEM =
|
||||
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
|
||||
|
||||
/** Chat UI ⇄ terminal for a Claude/Codex pane — this session's own view
|
||||
/** Chat UI ⇄ terminal for a `CHAT_TOOLS` pane — this session's own view
|
||||
* (`lib/chatUi.ts`); a session with no id yet has no chat to show. The
|
||||
* unsent prompt goes along: the terminal's input box into the chat's draft,
|
||||
* the draft (its files as paths) back into the input box, never sent. */
|
||||
@@ -254,7 +254,7 @@ export function showTerminal(runId: string, sessionId: string, tool?: string) {
|
||||
function ChatToggle({ run }: { run?: RunHandle }) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const chat = useSessionChat(run?.sessionId);
|
||||
if (!run?.sessionId || (run.tool !== "claude" && run.tool !== "codex")) return null;
|
||||
if (!run?.sessionId || !CHAT_TOOLS.has(run.tool)) return null;
|
||||
const sessionId = run.sessionId;
|
||||
const label = chat ? t("panes.showTerminal") : t("panes.showChat");
|
||||
return (
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
* other as a name chip) attached by their saved paths (`POST /api/run/drop-file`,
|
||||
* remembered for the + menu's Recent uploads), and a panel row (Files,
|
||||
* Changes, a context file or folder) dropped on it (`lib/promptAttach.ts`)
|
||||
* as an `@path` chip — a `bare` one takes a drop anywhere on its box;
|
||||
* as an `@path` chip — so is a local file a browser on the dashboard's
|
||||
* own machine drops or pastes with its `file://` path, never uploaded —
|
||||
* a `bare` one takes a drop anywhere on its box;
|
||||
* its `/command` and `@path` tokens are coloured by a mirror laid over
|
||||
* the textarea (`lib/promptTokens.ts`);
|
||||
* a borderless (`bare`) one grows with its text up to 40vh;
|
||||
@@ -71,6 +73,10 @@ import {
|
||||
droppedAttachments,
|
||||
droppedSkill,
|
||||
droppedText,
|
||||
isLocalBrowser,
|
||||
localAttachment,
|
||||
localCandidates,
|
||||
sameFilesHere,
|
||||
SKILL_MIME,
|
||||
rememberUploads,
|
||||
} from "../../lib/promptAttach";
|
||||
@@ -229,7 +235,10 @@ export function promptForPane(
|
||||
}
|
||||
const isImage = (i: PromptImage) => IMAGE_PATH.test(i.path);
|
||||
return {
|
||||
text: promptWithImages(text, images.filter((i) => !isImage(i))),
|
||||
text: promptWithImages(
|
||||
text,
|
||||
images.filter((i) => !isImage(i))
|
||||
),
|
||||
files: images.filter(isImage).map((i) => i.path),
|
||||
};
|
||||
}
|
||||
@@ -299,7 +308,7 @@ export function PromptEditor({
|
||||
Promise.all([
|
||||
api.ccConfig.commands(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
||||
api.ccConfig.skills(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
||||
api.ccConfig.plugins().catch(() => ({ plugins: [] })),
|
||||
api.ccConfig.plugins(fileCwd).catch(() => ({ plugins: [] })),
|
||||
]).then(([c, sk, pl]) => {
|
||||
// A plugin's commands and skills are addressed as `/<plugin>:<name>`; one
|
||||
// installed at both user and project scope is listed once.
|
||||
@@ -399,7 +408,11 @@ export function PromptEditor({
|
||||
// A pasted or dropped file is saved on the server's machine (the same
|
||||
// route the terminal's drop uses) and shown as a thumbnail or a name chip;
|
||||
// its path is added to the prompt only when it is sent (promptWithImages).
|
||||
const [upload, setUpload] = useState<"uploading" | "failed" | null>(null);
|
||||
const [upload, setUpload] = useState<"uploading" | "failed" | "tooBig" | "tooBigLocal" | null>(
|
||||
null
|
||||
);
|
||||
// The server's own limit, from its ETOOBIG refusal (never a copy of it here).
|
||||
const [limitMb, setLimitMb] = useState(0);
|
||||
const [viewing, setViewing] = useState<PromptImage | null>(null);
|
||||
const [imageMenu, setImageMenu] = useState<(MenuPoint & { img: PromptImage }) | null>(null);
|
||||
const uploading = upload === "uploading";
|
||||
@@ -410,20 +423,64 @@ export function PromptEditor({
|
||||
const picked = Array.from(list ?? []);
|
||||
if (picked.length === 0) return false;
|
||||
setUpload("uploading");
|
||||
Promise.all(picked.map((f) => api.run.dropFile(f, lane)))
|
||||
.then((saved) => {
|
||||
setUpload(null);
|
||||
const added = picked.map((f, i) => ({
|
||||
path: saved[i]!.path,
|
||||
// Each file on its own: one refused file does not cost the others.
|
||||
void Promise.allSettled(picked.map((f) => api.run.dropFile(f, lane))).then((results) => {
|
||||
const added: PromptImage[] = [];
|
||||
const errors: unknown[] = [];
|
||||
results.forEach((r, i) => {
|
||||
if (r.status === "rejected") return void errors.push(r.reason);
|
||||
const f = picked[i]!;
|
||||
added.push({
|
||||
path: r.value.path,
|
||||
url: f.type.startsWith("image/") ? URL.createObjectURL(f) : undefined,
|
||||
name: f.name,
|
||||
}));
|
||||
});
|
||||
});
|
||||
if (added.length) {
|
||||
rememberUploads(added.map((a) => ({ path: a.path, name: a.name, image: !!a.url })));
|
||||
keepError.current = errors.length > 0; // these came with the error
|
||||
onImagesChange((prev) => [...prev, ...added]);
|
||||
})
|
||||
.catch(() => setUpload("failed"));
|
||||
}
|
||||
// Over the server's limit (ETOOBIG): refused before the body is sent.
|
||||
const tooBig = errors
|
||||
.map((e) => e as { code?: string; maxBytes?: number } | null)
|
||||
.find((e) => e?.code === "ETOOBIG");
|
||||
if (tooBig) setLimitMb(Math.round((tooBig.maxBytes ?? 0) / 1024 / 1024));
|
||||
setUpload(
|
||||
!errors.length ? null : !tooBig ? "failed" : isLocalBrowser() ? "tooBigLocal" : "tooBig"
|
||||
);
|
||||
});
|
||||
return true;
|
||||
};
|
||||
/** Dropped or pasted files: by their own paths when the browser is on the
|
||||
* dashboard's machine and the server finds the same files there (a file
|
||||
* manager hands over `file://` paths beside the bytes), else uploaded. */
|
||||
const attachDropped = (
|
||||
dt: DataTransfer | null | undefined,
|
||||
list: FileList | null | undefined
|
||||
) => {
|
||||
const files = Array.from(list ?? []);
|
||||
if (files.length === 0) return false;
|
||||
const paths = localCandidates(dt, files);
|
||||
if (!paths) return attachImages(files);
|
||||
setUpload("uploading");
|
||||
void sameFilesHere(paths, files).then((same) => {
|
||||
if (!same) return void attachImages(files);
|
||||
setUpload(null);
|
||||
onImagesChange((prev) => addAttachments(prev, paths.map(localAttachment)));
|
||||
});
|
||||
return true;
|
||||
};
|
||||
// Anything attached since — a path, a panel row, a + menu pick — ends a past
|
||||
// upload's error; not the files that uploaded beside the one refused.
|
||||
const keepError = useRef(false);
|
||||
const imageCount = useRef(images.length);
|
||||
useEffect(() => {
|
||||
const grew = images.length > imageCount.current;
|
||||
imageCount.current = images.length;
|
||||
if (grew && !keepError.current) setUpload((u) => (u === "uploading" ? u : null));
|
||||
keepError.current = false;
|
||||
}, [images.length]);
|
||||
const removeImage = (img: PromptImage) => {
|
||||
if (img.url) URL.revokeObjectURL(img.url);
|
||||
onImagesChange((prev) => prev.filter((x) => x !== img));
|
||||
@@ -452,7 +509,7 @@ export function PromptEditor({
|
||||
} else if (skill) {
|
||||
e.preventDefault();
|
||||
onChange(`/${skill} ${value.replace(/^\s+/, "")}`);
|
||||
} else if (attachImages(e.dataTransfer?.files)) e.preventDefault();
|
||||
} else if (attachDropped(e.dataTransfer, e.dataTransfer?.files)) e.preventDefault();
|
||||
else {
|
||||
// Only text (a file manager's or an editor's `file://` list, a
|
||||
// selection, a link): what the terminal beneath would paste.
|
||||
@@ -617,7 +674,7 @@ export function PromptEditor({
|
||||
onBlur={() => setTrigger(null)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={(e) => {
|
||||
if (attachImages(e.clipboardData?.files)) e.preventDefault();
|
||||
if (attachDropped(e.clipboardData, e.clipboardData?.files)) e.preventDefault();
|
||||
else {
|
||||
// A very long paste goes in as a text file, like Claude Desktop:
|
||||
// the prompt then carries its path, never the text (#68: a CLI's
|
||||
@@ -661,10 +718,10 @@ export function PromptEditor({
|
||||
</div>
|
||||
{upload && (
|
||||
<p
|
||||
role={upload === "failed" ? "alert" : "status"}
|
||||
className={`mt-1 text-[11px] ${upload === "failed" ? "text-status-danger" : "text-fg-muted"}`}
|
||||
role={upload === "uploading" ? "status" : "alert"}
|
||||
className={`mt-1 text-[11px] ${upload === "uploading" ? "text-fg-muted" : "text-status-danger"}`}
|
||||
>
|
||||
{t(`drop.${upload}`)}
|
||||
{t(`drop.${upload}`, { mb: limitMb })}
|
||||
</p>
|
||||
)}
|
||||
{kind && (
|
||||
|
||||
@@ -17,7 +17,10 @@
|
||||
* Files (images or any other) dropped or pasted on it — Ctrl+V included — are saved on the server
|
||||
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
|
||||
* local terminal does on a drop — as `@` mentions in a `claude` pane, which
|
||||
* reads them (an image as an image) when the prompt is sent. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
|
||||
* reads them when the prompt is sent — an image as its bare path, which claude
|
||||
* shows as `[Image #n]` like a native drop; a browser on the dashboard's own
|
||||
* machine pastes a file manager's files (dropped, or copied: `file://` text)
|
||||
* by their own paths instead, uploading nothing. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
|
||||
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
|
||||
* pasted into whichever of the workspace's panes was focused last.
|
||||
* Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission
|
||||
@@ -62,13 +65,24 @@ import { useTheme } from "../../hooks/useTheme";
|
||||
import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||
import { api } from "../../lib/api";
|
||||
import { writeClipboard } from "../../lib/clipboard";
|
||||
import { MENU_CURSOR_ROW, notePaneFocus, registerPane } from "../../lib/panePaste";
|
||||
import { claudeMention } from "../../lib/promptAttach";
|
||||
import {
|
||||
MENU_CURSOR_ROW,
|
||||
notePaneFocus,
|
||||
notePaneOutput,
|
||||
registerPane,
|
||||
} from "../../lib/panePaste";
|
||||
import {
|
||||
claudePastes,
|
||||
droppedFilePaths,
|
||||
isLocalBrowser,
|
||||
localCandidates,
|
||||
sameFilesHere,
|
||||
} from "../../lib/promptAttach";
|
||||
import { attachImeInput } from "../../lib/imeInput";
|
||||
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
||||
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
|
||||
|
||||
type DropState = "hint" | "paste" | "uploading" | "failed";
|
||||
type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigLocal";
|
||||
|
||||
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
|
||||
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
|
||||
@@ -146,6 +160,15 @@ const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Fi
|
||||
const hasText = (e: DragEvent) =>
|
||||
!hasFiles(e) && Array.from(e.dataTransfer.types).includes("text/plain");
|
||||
|
||||
/** The `file://` paths a paste carries as text — a file manager's copy — or
|
||||
* null: its uri-list, else its plain text when every line is one. */
|
||||
function copiedFilePaths(dt: DataTransfer | null): string[] | null {
|
||||
const text = dt?.getData("text/plain") ?? "";
|
||||
if (droppedFilePaths(dt)) return droppedFilePaths(dt);
|
||||
if (!/^file:\/\//.test(text.trim())) return null;
|
||||
return droppedFilePaths({ getData: () => text } as unknown as DataTransfer);
|
||||
}
|
||||
|
||||
interface TerminalViewProps {
|
||||
runId: string;
|
||||
wsBaseUrl: string;
|
||||
@@ -161,8 +184,9 @@ interface TerminalViewProps {
|
||||
* so it is moved out of the viewport with a transform, which keeps its
|
||||
* layout size (no PTY resize; the chat still reads and answers its menus). */
|
||||
covered?: boolean;
|
||||
/** The pane's AI tool: a `claude` pane is handed a pasted or dropped file
|
||||
* as an `@` mention (`claudeMention`), any other one its plain path. */
|
||||
/** The pane's AI tool: a `claude` pane is handed a pasted or dropped image
|
||||
* as its bare path and any other file as an `@` mention (`claudePastes`),
|
||||
* any other tool its plain path. */
|
||||
tool?: string;
|
||||
}
|
||||
|
||||
@@ -198,6 +222,8 @@ export const TerminalView = memo(function TerminalView({
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
const [drop, setDrop] = useState<DropState | null>(null);
|
||||
// The server's own limit, from its ETOOBIG refusal (never a copy of it here).
|
||||
const [limitMb, setLimitMb] = useState(0);
|
||||
// The socket dropped after it had been up: keystrokes are not reaching tmux.
|
||||
const [reconnecting, setReconnecting] = useState(false);
|
||||
// Ctrl+M: Tab and Shift+Tab move focus out instead of reaching the pane.
|
||||
@@ -212,10 +238,10 @@ export const TerminalView = memo(function TerminalView({
|
||||
// only goes away when the pointer leaves the terminal itself.
|
||||
const dragDepth = useRef(0);
|
||||
const flashTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const flash = (state: DropState) => {
|
||||
const flash = (state: DropState, ms = 2500) => {
|
||||
setDrop(state);
|
||||
if (flashTimer.current) clearTimeout(flashTimer.current);
|
||||
flashTimer.current = setTimeout(() => setDrop(null), 2500);
|
||||
flashTimer.current = setTimeout(() => setDrop(null), ms);
|
||||
};
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -232,15 +258,18 @@ export const TerminalView = memo(function TerminalView({
|
||||
e.preventDefault();
|
||||
dragDepth.current = 0;
|
||||
setDrop(null);
|
||||
const local = isLocalBrowser() ? droppedFilePaths(e.dataTransfer) : null;
|
||||
const text = e.dataTransfer.getData("text/plain");
|
||||
if (text) termRef.current?.paste(text + " ");
|
||||
if (local) pastePaths(local);
|
||||
else if (text) termRef.current?.paste(text + " ");
|
||||
termRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
if (!hasFiles(e)) return;
|
||||
e.preventDefault();
|
||||
dragDepth.current = 0;
|
||||
void attachFiles(Array.from(e.dataTransfer.files));
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
void attachFiles(files, localCandidates(e.dataTransfer, files));
|
||||
};
|
||||
|
||||
// A pasted image (a screenshot) goes the way of a drop: xterm only reads
|
||||
@@ -252,27 +281,60 @@ export const TerminalView = memo(function TerminalView({
|
||||
const ctrlV = ctrlVRef.current;
|
||||
ctrlVRef.current = false;
|
||||
const files = Array.from(e.clipboardData?.files ?? []);
|
||||
// A file manager's copied files reach a browser as `file://` text only.
|
||||
const local = files.length === 0 && isLocalBrowser() ? copiedFilePaths(e.clipboardData) : null;
|
||||
if (local) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
pastePaths(local);
|
||||
return;
|
||||
}
|
||||
if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (files.length > 0) void attachFiles(files);
|
||||
if (files.length > 0) void attachFiles(files, localCandidates(e.clipboardData, files));
|
||||
else termRef.current?.input("\x16", true);
|
||||
};
|
||||
|
||||
const attachFiles = async (files: File[]) => {
|
||||
/** Pastes files' paths as a local terminal hands them over — paste(), not
|
||||
* a raw send: it honours bracketed-paste mode, so the CLI sees a paste
|
||||
* rather than typed keystrokes. A `claude` pane gets its images as bare
|
||||
* paths, which it shows as `[Image #n]`, and other files as `@` mentions
|
||||
* (`claudePastes`). */
|
||||
const pastePaths = (paths: string[]) => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
for (const text of tool === "claude" ? claudePastes(paths) : [paths.join(" ")])
|
||||
term.paste(text + " ");
|
||||
term.focus();
|
||||
};
|
||||
|
||||
/** Pastes the files' paths: their own (`local`, the `file://` paths a
|
||||
* browser on the dashboard's machine is handed beside the bytes) once the
|
||||
* server finds the same files there — nothing uploaded —, else the paths
|
||||
* the uploads were saved at. A refused file does not cost the others. */
|
||||
const attachFiles = async (files: File[], local: string[] | null = null) => {
|
||||
if (files.length === 0) return;
|
||||
setDrop("uploading");
|
||||
try {
|
||||
const saved = await Promise.all(files.map((f) => api.run.dropFile(f, runId)));
|
||||
// paste(), not a raw send: it honours bracketed-paste mode, so claude
|
||||
// sees pasted paths rather than typed keystrokes.
|
||||
const asText = tool === "claude" ? claudeMention : (p: string) => p;
|
||||
termRef.current?.paste(saved.map((r) => asText(r.path)).join(" ") + " ");
|
||||
termRef.current?.focus();
|
||||
setDrop(null);
|
||||
} catch {
|
||||
flash("failed");
|
||||
let paths = local && (await sameFilesHere(local, files)) ? local : null;
|
||||
const errors: unknown[] = [];
|
||||
if (!paths) {
|
||||
const results = await Promise.allSettled(files.map((f) => api.run.dropFile(f, runId)));
|
||||
paths = results.flatMap((r) => {
|
||||
if (r.status === "fulfilled") return [r.value.path];
|
||||
errors.push(r.reason);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
if (paths.length) pastePaths(paths);
|
||||
// Over the server's limit (ETOOBIG): refused before the body is sent.
|
||||
const tooBig = errors
|
||||
.map((e) => e as { code?: string; maxBytes?: number } | null)
|
||||
.find((e) => e?.code === "ETOOBIG");
|
||||
if (tooBig) setLimitMb(Math.round((tooBig.maxBytes ?? 0) / 1024 / 1024));
|
||||
if (!errors.length) setDrop(null);
|
||||
else if (!tooBig) flash("failed");
|
||||
else flash(isLocalBrowser() ? "tooBigLocal" : "tooBig", 6000); // a sentence to read
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -560,6 +622,7 @@ export const TerminalView = memo(function TerminalView({
|
||||
}
|
||||
}
|
||||
term.write(data);
|
||||
notePaneOutput(runId);
|
||||
}
|
||||
};
|
||||
sock.onclose = (e?: CloseEvent) => {
|
||||
@@ -722,6 +785,17 @@ export const TerminalView = memo(function TerminalView({
|
||||
rows.push(row);
|
||||
}
|
||||
return { rows, cols: term.cols };
|
||||
},
|
||||
// Plain text of the rows on screen, dim cells kept (a status line's
|
||||
// `esc to interrupt` is dim): the chat's busy read.
|
||||
() => {
|
||||
const term = termRef.current;
|
||||
if (!term) return [];
|
||||
const buf = term.buffer.active;
|
||||
const rows: string[] = [];
|
||||
for (let y = 0; y < term.rows; y++)
|
||||
rows.push(buf.getLine(buf.baseY + y)?.translateToString(true) ?? "");
|
||||
return rows;
|
||||
}
|
||||
),
|
||||
[runId]
|
||||
@@ -878,10 +952,12 @@ export const TerminalView = memo(function TerminalView({
|
||||
<div
|
||||
data-testid="terminal-drop"
|
||||
className={`pointer-events-none absolute inset-2 flex items-center justify-center rounded-lg border-2 border-dashed bg-black/50 text-sm font-medium ${
|
||||
drop === "failed" ? "border-red-400 text-red-300" : "border-sky-400 text-sky-200"
|
||||
drop === "failed" || drop === "tooBig" || drop === "tooBigLocal"
|
||||
? "border-red-400 text-red-300"
|
||||
: "border-sky-400 text-sky-200"
|
||||
}`}
|
||||
>
|
||||
{t(`drop.${drop}`)}
|
||||
{t(`drop.${drop}`, { mb: limitMb })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
* @file ChatAddMenu.test.tsx
|
||||
* @description The `+` menu's existing-resource pickers: Project files (a
|
||||
* search with folders, attached as `@path`), the workspace's Context files
|
||||
* (files only, none missing) and this browser's Recent uploads.
|
||||
* (files only, none missing), this browser's Recent uploads and, for a
|
||||
* browser on the dashboard's machine, a file chosen in the OS's own picker.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { ChatAddMenu } from "../ChatAddMenu";
|
||||
import { api } from "../../../lib/api";
|
||||
@@ -21,6 +22,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
api: {
|
||||
run: { files: vi.fn().mockResolvedValue({ items: ["src/", "src/a.ts"] }) },
|
||||
lanes: {
|
||||
pickPath: vi.fn().mockResolvedValue({ path: "/home/u/Videos/demo clip.mp4" }),
|
||||
sources: vi.fn().mockResolvedValue({
|
||||
sources: [
|
||||
{ id: 1, kind: "file", path: "/data/ctx/1/brief.md", name: "brief.md", missing: false },
|
||||
@@ -61,6 +63,28 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("ChatAddMenu — a file on this computer", () => {
|
||||
it("attaches the file the OS picker chose by its path, uploading nothing", async () => {
|
||||
const onAttach = renderMenu();
|
||||
fireEvent.click(screen.getByTestId("chat-add-pick-local"));
|
||||
expect(api.lanes.pickPath).toHaveBeenCalledWith("file", "/repo");
|
||||
await vi.waitFor(() =>
|
||||
expect(onAttach).toHaveBeenCalledWith([
|
||||
{ path: '@"/home/u/Videos/demo clip.mp4"', name: "demo clip.mp4" },
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("is not offered to a browser on another machine", () => {
|
||||
// Only a loopback address: a name that merely starts with "localhost" is not one.
|
||||
vi.stubGlobal("location", { ...window.location, hostname: "localhost.example.com" });
|
||||
renderMenu();
|
||||
expect(screen.queryByTestId("chat-add-pick-local")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatAddMenu — existing resources", () => {
|
||||
it("searches the project, folders included, and attaches a pick as @path", async () => {
|
||||
const onAttach = renderMenu();
|
||||
|
||||
@@ -31,6 +31,10 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
}),
|
||||
gitRawUrl: () => "",
|
||||
},
|
||||
artifacts: {
|
||||
pinFile: vi.fn().mockResolvedValue({ item: { id: 1 } }),
|
||||
uploadFile: vi.fn().mockResolvedValue({ item: { id: 2 } }),
|
||||
},
|
||||
sessions: {
|
||||
readImageUrl: (id: string, p: string) => `/img/${id}?path=${p}`,
|
||||
readFile: vi.fn((_id: string, p: string) =>
|
||||
@@ -75,7 +79,11 @@ const scope = { laneId: 4, laneCwd: "/w", cwd: "/w", sessionId: "s1" };
|
||||
describe("FilePreview", () => {
|
||||
it("reads a workspace file through the lane", async () => {
|
||||
render(<FilePreview path="/w/src/a.ts" scope={scope} onClose={() => {}} />);
|
||||
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
(_t, el) => el?.tagName === "CODE" && !!el.textContent?.includes("export const a = 1;")
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(4, "src/a.ts");
|
||||
});
|
||||
|
||||
@@ -89,7 +97,11 @@ describe("FilePreview", () => {
|
||||
it("reads Windows paths the same way", async () => {
|
||||
const win = { laneId: 4, laneCwd: "D:\\w", cwd: "D:\\w", sessionId: "s1" };
|
||||
render(<FilePreview path={"D:\\w\\src\\a.ts"} scope={win} onClose={() => {}} />);
|
||||
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
(_t, el) => el?.tagName === "CODE" && !!el.textContent?.includes("export const a = 1;")
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(4, "src/a.ts");
|
||||
cleanup();
|
||||
const tool = "C:\\Users\\k\\.claude\\projects\\D--w\\s1\\tool-results\\b.txt";
|
||||
@@ -144,7 +156,11 @@ describe("FileListModal", () => {
|
||||
);
|
||||
expect(screen.getByText("No preview for this file here")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "a.ts" }));
|
||||
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
(_t, el) => el?.tagName === "CODE" && !!el.textContent?.includes("export const a = 1;")
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -172,3 +188,77 @@ describe("SkillListModal", () => {
|
||||
expect(screen.getByText("No preview for this file here")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the artifact view", () => {
|
||||
const html = (lines: string[]) =>
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValueOnce({
|
||||
available: true,
|
||||
path: "page.html",
|
||||
rev: "work",
|
||||
binary: false,
|
||||
truncated: false,
|
||||
lines,
|
||||
} as never);
|
||||
|
||||
it("runs a page in a sandbox with scripts but no same origin, and shows its code", async () => {
|
||||
html(["<button>go</button>"]);
|
||||
render(<FilePreview path="/w/page.html" scope={scope} onClose={() => {}} />);
|
||||
const frame = (await screen.findByTestId("artifact-frame")) as HTMLIFrameElement;
|
||||
expect(frame.getAttribute("sandbox")).toContain("allow-scripts");
|
||||
expect(frame.getAttribute("sandbox")).not.toContain("allow-same-origin");
|
||||
expect(frame.getAttribute("srcdoc")).toContain("<button>go</button>");
|
||||
fireEvent.click(screen.getByTestId("artifact-tab-code"));
|
||||
expect(screen.queryByTestId("artifact-frame")).toBeNull();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
(_t, el) => el?.tagName === "CODE" && !!el.textContent?.includes("<button>go</button>")
|
||||
)
|
||||
).toBeTruthy();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("steps back to an older version the conversation wrote", async () => {
|
||||
html(["<p>two</p>"]);
|
||||
const tool = (id: string, input: Record<string, unknown>, name = "Write") => ({
|
||||
type: "assistant" as const,
|
||||
timestamp: null,
|
||||
content: [{ type: "tool_use" as const, id, name, input }],
|
||||
});
|
||||
const messages = [
|
||||
tool("1", { file_path: "/w/page.html", content: "<p>one</p>" }),
|
||||
tool("2", { file_path: "/w/page.html", old_string: "one", new_string: "two" }, "Edit"),
|
||||
];
|
||||
render(
|
||||
<FilePreview
|
||||
path="/w/page.html"
|
||||
scope={scope}
|
||||
onClose={() => {}}
|
||||
messages={messages as never}
|
||||
/>
|
||||
);
|
||||
const versions = await screen.findByTestId("artifact-versions");
|
||||
expect(versions.textContent).toContain("2");
|
||||
expect(
|
||||
((await screen.findByTestId("artifact-frame")) as HTMLIFrameElement).getAttribute("srcdoc")
|
||||
).toContain("two");
|
||||
fireEvent.click(versions.querySelector("button")!);
|
||||
expect(screen.getByTestId("artifact-frame").getAttribute("srcdoc")).toContain("<p>one</p>");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("saves a workspace file to the Artifacts library by its path", async () => {
|
||||
html(["<p>x</p>"]);
|
||||
render(<FilePreview path="/w/page.html" scope={scope} onClose={() => {}} />);
|
||||
await screen.findByTestId("artifact-frame");
|
||||
fireEvent.click(screen.getByTestId("artifact-share"));
|
||||
await vi.waitFor(() =>
|
||||
expect(api.artifacts.pinFile).toHaveBeenCalledWith({
|
||||
laneId: 4,
|
||||
path: "page.html",
|
||||
title: "page.html",
|
||||
sessionId: "s1",
|
||||
})
|
||||
);
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
* activity panel, collapsed into a file button with the output count, whose
|
||||
* files open a preview in its place; a file in a turn's "Edited N files" card
|
||||
* opens in the Workspace's Changes panel when it is in the pane's folder; and
|
||||
* Stop in Send's place while the main agent works, pressing Esc in the pane;
|
||||
* Stop in Send's place while the main agent works (its hooks, or the pane's
|
||||
* own busy line / fresh output), pressing Esc in the pane;
|
||||
* and the menu the CLI asks with, shown as buttons that press its keys, a
|
||||
* message sent meanwhile typed as its "Type something" answer.
|
||||
* message sent meanwhile typed as its "Type something" answer; a Codex
|
||||
* terminal's Model / Permission mode buttons typing its own pickers.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -22,11 +24,14 @@ import {
|
||||
answerRunMenu,
|
||||
chooseRunOption,
|
||||
interruptRun,
|
||||
onPaneOutput,
|
||||
readRunBusy,
|
||||
readRunMenu,
|
||||
submitToRun,
|
||||
type PaneMenu,
|
||||
} from "../../../lib/panePaste";
|
||||
import { OPEN_IN_CHANGES_EVENT } from "../../../lib/openInChanges";
|
||||
import { eventBus } from "../../../lib/eventBus";
|
||||
|
||||
vi.mock("../../conversation/ConversationView", () => ({
|
||||
// Stands in for a turn's "Edited N files" card: a click names a file.
|
||||
@@ -51,8 +56,12 @@ vi.mock("../../../lib/panePaste", () => ({
|
||||
submitToRun: vi.fn(() => true),
|
||||
interruptRun: vi.fn(() => true),
|
||||
readRunMenu: vi.fn(() => null),
|
||||
readRunBusy: vi.fn(() => false),
|
||||
chooseRunOption: vi.fn(() => true),
|
||||
answerRunMenu: vi.fn(() => true),
|
||||
stepRunMenu: vi.fn(() => true),
|
||||
notePaneFocus: vi.fn(),
|
||||
onPaneOutput: vi.fn(() => () => {}),
|
||||
}));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
@@ -212,6 +221,54 @@ describe("PaneChat — Stop", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("shows Stop while the pane's own busy line shows, with no hook saying so", async () => {
|
||||
// omp, Cursor or Codex without its hooks: only the terminal knows.
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
vi.mocked(readRunBusy).mockReturnValue(true);
|
||||
try {
|
||||
renderChat();
|
||||
expect(await screen.findByTestId("pane-chat-stop")).toBeInTheDocument();
|
||||
expect(readRunBusy).toHaveBeenCalledWith("agenthub-lane-1", "claude");
|
||||
vi.mocked(readRunBusy).mockReturnValue(false);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.mocked(readRunBusy).mockReturnValue(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("counts a tool with no known busy line as working while its pane draws", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
vi.mocked(readRunBusy).mockReturnValue(null);
|
||||
const fns: (() => void)[] = [];
|
||||
vi.mocked(onPaneOutput).mockImplementation((_runId, fn) => {
|
||||
fns.push(fn);
|
||||
return () => {};
|
||||
});
|
||||
try {
|
||||
renderChat();
|
||||
await waitFor(() => expect(fns.length).toBeGreaterThan(0));
|
||||
expect(screen.queryByTestId("pane-chat-stop")).toBeNull();
|
||||
fns.forEach((fn) => fn());
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
});
|
||||
expect(await screen.findByTestId("pane-chat-stop")).toBeInTheDocument();
|
||||
// Quiet for longer than OUTPUT_BUSY_MS: at rest again.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2800);
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.mocked(readRunBusy).mockReturnValue(false);
|
||||
vi.mocked(onPaneOutput).mockImplementation(() => () => {});
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps Send for a session at rest", async () => {
|
||||
// With session_id the server answers EVERY agent of the session (its
|
||||
// status filter does not apply): the main agent is there, waiting.
|
||||
@@ -553,4 +610,103 @@ describe("PaneChat — a menu the CLI asks with", () => {
|
||||
vi.mocked(readRunMenu).mockReturnValue(null);
|
||||
}
|
||||
});
|
||||
|
||||
it("re-reads its transcript when the pane draws, at most once a second", async () => {
|
||||
// Every listener the chat put on the pane (the re-read, the busy clock).
|
||||
const fns: (() => void)[] = [];
|
||||
const drew = () => fns.forEach((fn) => fn());
|
||||
vi.mocked(onPaneOutput).mockImplementation((_runId, fn) => {
|
||||
fns.push(fn);
|
||||
return () => {};
|
||||
});
|
||||
const seen: unknown[] = [];
|
||||
const off = eventBus.subscribe((m) => {
|
||||
if (m.type === "transcript_changed") seen.push(m.data);
|
||||
});
|
||||
try {
|
||||
renderChat();
|
||||
await waitFor(() => expect(fns.length).toBeGreaterThan(0));
|
||||
vi.useFakeTimers();
|
||||
drew();
|
||||
drew();
|
||||
drew();
|
||||
expect(seen).toEqual([{ session_id: "s1" }]);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1100);
|
||||
});
|
||||
expect(seen).toHaveLength(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
off();
|
||||
vi.mocked(onPaneOutput).mockImplementation(() => () => {});
|
||||
}
|
||||
});
|
||||
|
||||
const PERMISSION: PaneMenu = {
|
||||
question: "Do you want to proceed?",
|
||||
detail: ["Bash command", "npm test", "Run the tests"],
|
||||
options: [
|
||||
{ n: 1, label: "Yes", hint: "", checked: null },
|
||||
{ n: 2, label: "Yes, and don't ask again for npm test", hint: "", checked: null },
|
||||
{ n: 3, label: "No, and tell Claude what to do differently (esc)", hint: "", checked: null },
|
||||
],
|
||||
cursor: 0,
|
||||
multi: false,
|
||||
typeAt: -1,
|
||||
};
|
||||
|
||||
it("lays a permission prompt out as a card, each answer its own key", async () => {
|
||||
vi.mocked(readRunMenu).mockReturnValue(PERMISSION);
|
||||
vi.mocked(chooseRunOption).mockClear();
|
||||
try {
|
||||
renderChat();
|
||||
const card = await screen.findByTestId("pane-chat-permission");
|
||||
expect(card).toHaveTextContent("Bash command");
|
||||
expect(card).toHaveTextContent("npm test");
|
||||
expect(card).toHaveTextContent("Do you want to proceed?");
|
||||
const options = screen.getAllByTestId("pane-chat-permission-option");
|
||||
// The key hint is dropped from the label.
|
||||
expect(options.map((o) => o.textContent)).toEqual([
|
||||
"Yes",
|
||||
"Yes, and don't ask again for npm test",
|
||||
"No, and tell Claude what to do differently",
|
||||
]);
|
||||
fireEvent.click(options[1]!);
|
||||
expect(chooseRunOption).toHaveBeenCalledWith("agenthub-lane-1", PERMISSION, 1);
|
||||
} finally {
|
||||
vi.mocked(readRunMenu).mockReturnValue(null);
|
||||
}
|
||||
});
|
||||
|
||||
it("denies with a note: picks the No row, then sends the note as the next message", async () => {
|
||||
vi.mocked(readRunMenu).mockReturnValue(PERMISSION);
|
||||
vi.mocked(chooseRunOption).mockClear();
|
||||
vi.mocked(submitToRun).mockClear();
|
||||
try {
|
||||
renderChat();
|
||||
fireEvent.click(await screen.findByTestId("pane-chat-permission-tell"));
|
||||
const field = screen.getByTestId("pane-chat-permission-note");
|
||||
fireEvent.change(field, { target: { value: "use pnpm" } });
|
||||
fireEvent.keyDown(field, { key: "Enter" });
|
||||
expect(chooseRunOption).toHaveBeenCalledWith("agenthub-lane-1", PERMISSION, 2);
|
||||
await waitFor(() => expect(submitToRun).toHaveBeenCalled(), { timeout: 2000 });
|
||||
expect(vi.mocked(submitToRun).mock.calls[0]![1]).toContain("use pnpm");
|
||||
} finally {
|
||||
vi.mocked(readRunMenu).mockReturnValue(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("PaneChat — a Codex terminal", () => {
|
||||
it("types /model and /permissions, whose pickers come back as menu cards", () => {
|
||||
renderChat({ tool: "codex" });
|
||||
fireEvent.click(screen.getByTestId("pane-chat-codex-model"));
|
||||
fireEvent.click(screen.getByTestId("pane-chat-codex-permissions"));
|
||||
expect(vi.mocked(submitToRun).mock.calls.map((c) => c[1])).toEqual(["/model", "/permissions"]);
|
||||
});
|
||||
|
||||
it("offers neither to a Claude pane", () => {
|
||||
renderChat();
|
||||
expect(screen.queryByTestId("pane-chat-codex-model")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,7 +56,11 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
return { flag: id in lists, models: lists[id] ?? [], defaultModel: null };
|
||||
}),
|
||||
},
|
||||
run: { files: r({ items: [] }), dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }) },
|
||||
run: {
|
||||
files: r({ items: [] }),
|
||||
dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }),
|
||||
localPaths: r({ sizes: [1] }),
|
||||
},
|
||||
ccConfig: {
|
||||
commands: r({
|
||||
items: [
|
||||
@@ -342,6 +346,120 @@ describe("PromptEditor — newlines and images", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("attaches a local file manager's files by their own paths, uploading nothing", async () => {
|
||||
function Harness() {
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
return (
|
||||
<>
|
||||
<PromptEditor
|
||||
value="read"
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
placeholder="Ask anything…"
|
||||
slash={false}
|
||||
fileCwd="/home/u"
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
/>
|
||||
<output data-testid="sent">{promptWithImages("read", images)}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Harness />);
|
||||
const big = new File(["x"], "demo.mp4", { type: "video/mp4" });
|
||||
const data: Record<string, string> = { "text/uri-list": "file:///home/u/demo.mp4" };
|
||||
fireEvent.drop(box(), {
|
||||
dataTransfer: { files: [big], types: ["Files"], getData: (k: string) => data[k] ?? "" },
|
||||
});
|
||||
expect(await screen.findByText("demo.mp4")).toBeTruthy();
|
||||
expect(api.run.localPaths).toHaveBeenCalledWith(["/home/u/demo.mp4"]);
|
||||
expect(api.run.dropFile).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("sent").textContent).toBe("read @/home/u/demo.mp4");
|
||||
});
|
||||
|
||||
it("uploads a dropped file whose path the server does not have (an SSH tunnel's laptop)", async () => {
|
||||
vi.mocked(api.run.localPaths).mockResolvedValueOnce({ sizes: [null] });
|
||||
const onImagesChange = vi.fn();
|
||||
render(
|
||||
<PromptEditor
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
placeholder="Ask anything…"
|
||||
slash={false}
|
||||
fileCwd="/home/u"
|
||||
images={[]}
|
||||
onImagesChange={onImagesChange}
|
||||
/>
|
||||
);
|
||||
const doc = new File(["x"], "spec.pdf", { type: "application/pdf" });
|
||||
const data: Record<string, string> = { "text/uri-list": "file:///Users/me/spec.pdf" };
|
||||
fireEvent.drop(box(), {
|
||||
dataTransfer: { files: [doc], types: ["Files"], getData: (k: string) => data[k] ?? "" },
|
||||
});
|
||||
await vi.waitFor(() => expect(api.run.dropFile).toHaveBeenCalledWith(doc, undefined));
|
||||
});
|
||||
|
||||
it("keeps the files that uploaded when another one is refused", async () => {
|
||||
vi.mocked(api.run.dropFile)
|
||||
.mockResolvedValueOnce({ path: "/tmp/agenthub-drops-1/a-ok.txt" })
|
||||
.mockRejectedValueOnce(Object.assign(new Error("too big"), { code: "ETOOBIG" }));
|
||||
function Harness() {
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
return (
|
||||
<>
|
||||
<PromptEditor
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
placeholder="Ask anything…"
|
||||
slash={false}
|
||||
fileCwd="/home/u"
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
/>
|
||||
<output data-testid="sent">{promptWithImages("", images)}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Harness />);
|
||||
fireEvent.drop(box(), {
|
||||
dataTransfer: { files: [new File(["x"], "ok.txt"), new File(["y"], "huge.iso")] },
|
||||
});
|
||||
expect(await screen.findByText("ok.txt")).toBeTruthy();
|
||||
expect(screen.getByTestId("sent").textContent).toBe("/tmp/agenthub-drops-1/a-ok.txt");
|
||||
expect(screen.getByRole("alert").textContent).toMatch(/too large/);
|
||||
// Anything attached afterwards — here a Files panel row — ends the error.
|
||||
const data: Record<string, string> = {
|
||||
[ATTACH_MIME]: JSON.stringify({ path: "@src/a.ts", name: "a.ts" }),
|
||||
};
|
||||
fireEvent.drop(box(), { dataTransfer: { getData: (k: string) => data[k] ?? "" } });
|
||||
expect(await screen.findByText("a.ts")).toBeTruthy();
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
it("says a file over the server's limit is too big, and how to attach it", async () => {
|
||||
vi.mocked(api.run.dropFile).mockRejectedValueOnce(
|
||||
Object.assign(new Error("too big"), { code: "ETOOBIG", status: 413, maxBytes: 20971520 })
|
||||
);
|
||||
render(
|
||||
<PromptEditor
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
placeholder="Ask anything…"
|
||||
slash={false}
|
||||
fileCwd="/home/u"
|
||||
images={[]}
|
||||
onImagesChange={() => {}}
|
||||
/>
|
||||
);
|
||||
fireEvent.drop(box(), { dataTransfer: { files: [new File(["x"], "huge.iso")] } });
|
||||
expect((await screen.findByRole("alert")).textContent).toMatch(
|
||||
/too large to upload \(max 20 MB\).*Choose a file on this computer/
|
||||
);
|
||||
});
|
||||
|
||||
it("attaches a Files panel row dropped on it as an @path chip, once", () => {
|
||||
function Harness() {
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
@@ -593,7 +711,10 @@ describe("promptForPane — images apart, every other file in the text", () => {
|
||||
// A Windows path inside a paste of text stayed a plain path in claude; a
|
||||
// paste that is only the path becomes [Image #n].
|
||||
const images: PromptImage[] = [
|
||||
{ path: "C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png", name: "image.png" },
|
||||
{
|
||||
path: "C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png",
|
||||
name: "image.png",
|
||||
},
|
||||
{ path: "/data/drops/0123456789abcdef-log.txt", name: "log.txt" },
|
||||
{ path: '@"/home/u/My Docs/a.PNG"', name: "a.PNG" },
|
||||
{ path: "/data/drops/0123456789abcdef.JPG", name: "b.jpg" },
|
||||
@@ -611,14 +732,17 @@ describe("promptForPane — images apart, every other file in the text", () => {
|
||||
// A bare pasted path stayed plain text on Windows, and a path in the
|
||||
// command-line prompt is never an image; claude reads an @ mention at submit.
|
||||
const images: PromptImage[] = [
|
||||
{ path: "C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png", name: "image.png" },
|
||||
{
|
||||
path: "C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png",
|
||||
name: "image.png",
|
||||
},
|
||||
{ path: "/data/drops/0123456789abcdef-log.txt", name: "log.txt" },
|
||||
{ path: '@"/home/u/My Docs/a.PNG"', name: "a.PNG" },
|
||||
{ path: "C:\\Users\\Jo Do\\drops\\0123456789abcdef.png", name: "b.png" },
|
||||
];
|
||||
expect(promptForPane(" look ", images, true)).toEqual({
|
||||
text:
|
||||
'look @C:/Users/u/.claude/agenthub/drops/f326fc666d3bdd92-image.png ' +
|
||||
"look @C:/Users/u/.claude/agenthub/drops/f326fc666d3bdd92-image.png " +
|
||||
'@/data/drops/0123456789abcdef-log.txt @"/home/u/My Docs/a.PNG" ' +
|
||||
'@"C:/Users/Jo Do/drops/0123456789abcdef.png" ',
|
||||
files: [],
|
||||
|
||||
@@ -26,8 +26,14 @@ const inputMock = vi.fn();
|
||||
// The newest terminal's hidden input, where the browser's IME types.
|
||||
let textarea: HTMLTextAreaElement;
|
||||
const dropFileMock = vi.fn();
|
||||
const localPathsMock = vi.fn();
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { run: { dropFile: (f: File, lane?: string) => dropFileMock(f, lane) } },
|
||||
api: {
|
||||
run: {
|
||||
dropFile: (f: File, lane?: string) => dropFileMock(f, lane),
|
||||
localPaths: (paths: string[]) => localPathsMock(paths),
|
||||
},
|
||||
},
|
||||
}));
|
||||
const loadAddonMock = vi.fn();
|
||||
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
|
||||
@@ -512,6 +518,23 @@ describe("TerminalView", () => {
|
||||
await waitFor(() => expect(screen.queryByTestId("terminal-drop")).toBeNull());
|
||||
});
|
||||
|
||||
it("pastes a local file manager's own paths, uploading nothing", async () => {
|
||||
dropFileMock.mockReset();
|
||||
localPathsMock.mockReset().mockResolvedValue({ sizes: [1] });
|
||||
pasteMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
|
||||
const big = new File(["x"], "demo.mp4", { type: "video/mp4" });
|
||||
const data: Record<string, string> = {
|
||||
"text/uri-list": "file:///home/u/My%20Videos/demo.mp4",
|
||||
};
|
||||
fireEvent.drop(screen.getByTestId("terminal-view"), {
|
||||
dataTransfer: { types: ["Files"], files: [big], getData: (k: string) => data[k] ?? "" },
|
||||
});
|
||||
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith('@"/home/u/My Videos/demo.mp4" '));
|
||||
expect(localPathsMock).toHaveBeenCalledWith(["/home/u/My Videos/demo.mp4"]);
|
||||
expect(dropFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("pastes every path of several dropped files, images or not", async () => {
|
||||
dropFileMock
|
||||
.mockReset()
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"deleteBody": "It is removed from your library and the Hub. Anything already installed from it stays installed.",
|
||||
"deleteConfirm": "Delete",
|
||||
"installTitle": "Install “{{title}}”?",
|
||||
"installSkillBody": "These files are written to your own ~/.claude/skills/{{name}}/. A skill can carry scripts your agent will run — only install what you trust:",
|
||||
"installSkillBody": "These files are written to your own ~/.claude/skills/{{name}}/ and ~/.agents/skills/{{name}}/, so every AI tool and session finds it. A skill can carry scripts your agent will run — only install what you trust:",
|
||||
"installPluginBody": "Runs, as your account: claude plugin marketplace add {{market}} and claude plugin install {{name}} --scope user. Only install what you trust.",
|
||||
"installConfirm": "Install",
|
||||
"replaceTitle": "Replace your skill “{{name}}”?",
|
||||
@@ -70,7 +70,8 @@
|
||||
"form.save": "Add",
|
||||
"form.cancel": "Cancel",
|
||||
"added": "Added",
|
||||
"files": "{{count}} files",
|
||||
"files_one": "{{count}} file",
|
||||
"files_other": "{{count}} files",
|
||||
"close": "Close",
|
||||
"fromSession": "From a session",
|
||||
"form.saveEdit": "Save",
|
||||
@@ -78,7 +79,7 @@
|
||||
"skill.tab.write": "Write new",
|
||||
"skill.tab.existing": "From my skills",
|
||||
"skill.name": "Skill name",
|
||||
"skill.slugHint": "Installs as ~/.claude/skills/{{slug}}/ and runs as /{{slug}}",
|
||||
"skill.slugHint": "Installs as ~/.claude/skills/{{slug}}/ (and ~/.agents/skills/) and runs as /{{slug}}",
|
||||
"skill.slugBad": "Use letters, digits, '.', '_' or '-'",
|
||||
"skill.description": "Description — when Claude should use this skill",
|
||||
"skill.descriptionHint": "e.g. Use when writing release notes from the git log",
|
||||
@@ -110,5 +111,68 @@
|
||||
"file.fromComputer": "From your computer (≤ 25 MB)",
|
||||
"file.upload": "Upload",
|
||||
"file.tooBig": "The file is larger than 25 MB.",
|
||||
"file.fromWorkspace": "Or pin a document a workspace's sessions wrote"
|
||||
"file.fromWorkspace": "Or pin a document a workspace's sessions wrote",
|
||||
"editTitle": "Edit “{{title}}”",
|
||||
"edit.title": "Title",
|
||||
"edit.description": "Description",
|
||||
"previewFailed": "Could not load this file.",
|
||||
"truncated": "Showing the first 1 MB — download the file to see all of it.",
|
||||
"tab.all": "All",
|
||||
"installedBadge": "Installed",
|
||||
"installsTitle": "Installs",
|
||||
"installsYou": "You",
|
||||
"installsUnknown": "A removed user",
|
||||
"installsReplaced": "replaced an older copy",
|
||||
"installsPluginWhere": "claude plugin install --scope user",
|
||||
"installsUnrecorded": "{{count}} earlier installs were counted before details were kept",
|
||||
"installsNoneOfYours": "You have not installed it — only your own installs are listed.",
|
||||
"reinstall": "Reinstall",
|
||||
"reinstallHint": "Write this version over your installed copy",
|
||||
"reinstallTitle": "Reinstall “{{title}}”?",
|
||||
"reinstallBody": "Your installed ~/.claude/skills/{{name}}/ and ~/.agents/skills/{{name}}/ are replaced with this version. The current copy is moved to ~/.claude/agenthub-replaced-skills/, not deleted.",
|
||||
"uninstall": "Uninstall",
|
||||
"uninstallHint": "Remove it from your account",
|
||||
"uninstallTitle": "Uninstall “{{title}}”?",
|
||||
"uninstallSkillBody": "~/.claude/skills/{{name}}/ and ~/.agents/skills/{{name}}/ are moved to ~/.claude/agenthub-replaced-skills/, not deleted. Sessions already open keep it until they reload.",
|
||||
"uninstallPluginBody": "Runs claude plugin uninstall {{name}} for your account; the plugin's data folder is kept. Open sessions keep it until /reload-plugins.",
|
||||
"uninstalled": "Uninstalled {{name}}",
|
||||
"deleteBodyShared": "It is shared, so it also disappears from the Hub for everyone. To keep it but stop sharing, use Unshare instead. Anything already installed from it stays installed.",
|
||||
"deleteBodyKeep": "It leaves your library but stays in the Hub, because it is shared. To take it off the Hub too, use Remove from Hub afterwards (or Unshare first).",
|
||||
"deleteKept": "Removed from your library — still in the Hub",
|
||||
"hubOnly": "Hub only",
|
||||
"hubOnlyHint": "You removed it from your library; it stays shared in the Hub",
|
||||
"hubRemove": "Remove from Hub",
|
||||
"hubRemoveTitle": "Remove “{{title}}” from the Hub?",
|
||||
"hubRemoveBody": "It is no longer in your library, so it is deleted for good. Anything already installed from it stays installed.",
|
||||
"edit.otherFiles": "Its other {{count}} file(s) are kept as they are.",
|
||||
"edit.reinstallNote": "Your installed copy keeps the old version until you Reinstall it.",
|
||||
"preview.instructions": "Instructions",
|
||||
"preview.frontmatter": "Frontmatter (SKILL.md header)",
|
||||
"preview.whenUsed": "When Claude uses it",
|
||||
"preview.noDescription": "No description",
|
||||
"preview.installsAs": "Installs as ~/.claude/skills/{{slug}}/ and ~/.agents/skills/{{slug}}/",
|
||||
"blurb.skill": "Reusable instructions Claude follows",
|
||||
"blurb.plugin": "Bundles of skills, commands and hooks",
|
||||
"blurb.page": "Pages your sessions published on claude.ai",
|
||||
"blurb.file": "Documents and outputs worth keeping",
|
||||
"clearSearch": "Clear search",
|
||||
"categories": "Categories",
|
||||
"seeAll": "See all",
|
||||
"results_one": "{{count}} result",
|
||||
"results_other": "{{count}} results",
|
||||
"empty.title": "Your library is empty",
|
||||
"empty.hubTitle": "The Hub is quiet",
|
||||
"empty.searchTitle": "No matches",
|
||||
"empty.search": "Nothing matches this search or category — try other words, or pick All.",
|
||||
"details": "Details",
|
||||
"more": "More actions",
|
||||
"about": "About",
|
||||
"stat.kind": "Type",
|
||||
"stat.installs": "Installs",
|
||||
"stat.files": "Files",
|
||||
"stat.size": "Size",
|
||||
"stat.updated": "Updated",
|
||||
"detail.runAs": "Run it as",
|
||||
"detail.market": "Marketplace",
|
||||
"detail.added": "Added {{date}}"
|
||||
}
|
||||
|
||||
@@ -531,6 +531,9 @@
|
||||
"changes.unversionedGroup": "Unversioned Files",
|
||||
"changes.commitTicked": "Commit ({{count}})",
|
||||
"changes.commitAndPush": "Commit and Push",
|
||||
"changes.discardAll": "Discard all",
|
||||
"changes.discardAllTitle": "Discard all changes?",
|
||||
"changes.discardAllConfirm": "Every change in {{count}} file(s) goes back to the last commit, and {{untracked}} new file(s) git does not track yet are deleted. This cannot be undone.",
|
||||
"changes.amendAndPush": "Amend and Push",
|
||||
"changes.pushFailed": "Committed, but the push failed: {{error}}",
|
||||
"changes.tickFile": "Include {{path}} in the commit",
|
||||
|
||||
@@ -71,6 +71,8 @@
|
||||
"paste": "Drop to paste into the terminal",
|
||||
"uploading": "Uploading…",
|
||||
"failed": "Could not attach the file",
|
||||
"tooBig": "File too large to upload (max {{mb}} MB) — put it in the workspace folder and attach it from Files",
|
||||
"tooBigLocal": "File too large to upload (max {{mb}} MB) — attach it by path instead: + → Choose a file on this computer",
|
||||
"remove": "Remove attachment",
|
||||
"view": "View image"
|
||||
},
|
||||
@@ -87,6 +89,7 @@
|
||||
"findClose": "Close find"
|
||||
},
|
||||
"chat": {
|
||||
"codexPicker": "Opens Codex's own picker — answer it in the chat",
|
||||
"placeholder": "Write a message…",
|
||||
"send": "Send",
|
||||
"notSent": "The terminal is not connected — the message was not sent.",
|
||||
@@ -97,6 +100,9 @@
|
||||
"notStopped": "The terminal is not connected — nothing was stopped.",
|
||||
"add": "Add files, skills…",
|
||||
"upload": "Upload or add files",
|
||||
"pickLocal": "Choose a file on this computer",
|
||||
"pickLocalHint": "Attach a file on this machine by its path — nothing is uploaded",
|
||||
"pickLocalOpen": "The file picker is open on this computer's screen…",
|
||||
"projectFiles": "Project files",
|
||||
"contextFiles": "Context files",
|
||||
"recentUploads": "Recent uploads",
|
||||
@@ -134,7 +140,28 @@
|
||||
},
|
||||
"menu": {
|
||||
"placeholder": "Type your answer…",
|
||||
"cancel": "Cancel (Esc)"
|
||||
"cancel": "Cancel (Esc)",
|
||||
"prev": "Previous question",
|
||||
"next": "Next question"
|
||||
},
|
||||
"request": {
|
||||
"notePlaceholder": "Tell the model what to do instead…",
|
||||
"send": "Send",
|
||||
"denyWithNote": "Deny with a note"
|
||||
},
|
||||
"artifact": {
|
||||
"preview": "Preview",
|
||||
"code": "Code",
|
||||
"copy": "Copy",
|
||||
"download": "Download",
|
||||
"share": "Save to Artifacts",
|
||||
"shared": "Saved to your Artifacts library — share it from the Artifacts page.",
|
||||
"cannotShare": "This file cannot be saved to Artifacts.",
|
||||
"expand": "Full screen",
|
||||
"collapse": "Exit full screen",
|
||||
"older": "Older version",
|
||||
"newer": "Newer version",
|
||||
"version": "Version {{n}} of {{total}}"
|
||||
}
|
||||
},
|
||||
"interrupted": {
|
||||
|
||||
@@ -216,7 +216,30 @@
|
||||
"agentsStill_other": "{{count}} agents still running",
|
||||
"agentsRunning_one": "{{count}} agent running in the background",
|
||||
"agentsRunning_other": "{{count}} agents running in the background",
|
||||
"agentContext": "{{tokens}} tokens"
|
||||
"agentContext": "{{tokens}} tokens",
|
||||
"agent": {
|
||||
"open": "View conversation",
|
||||
"prompt": "Task",
|
||||
"background": "background",
|
||||
"toolUses_one": "{{count}} tool call",
|
||||
"toolUses_other": "{{count}} tool calls",
|
||||
"status": {
|
||||
"running": "running",
|
||||
"done": "done",
|
||||
"error": "failed",
|
||||
"stopped": "stopped"
|
||||
},
|
||||
"hide": "Hide conversation"
|
||||
},
|
||||
"artifact": {
|
||||
"html": "Web page",
|
||||
"svg": "Image · SVG",
|
||||
"markdown": "Document",
|
||||
"mermaid": "Diagram",
|
||||
"react": "React component",
|
||||
"csv": "Table",
|
||||
"open": "Click to open"
|
||||
}
|
||||
},
|
||||
"overview": {
|
||||
"agent": "Agent",
|
||||
|
||||
@@ -1302,9 +1302,9 @@
|
||||
},
|
||||
"chatUi": {
|
||||
"title": "Chat UI",
|
||||
"description": "How a new Claude or Codex session opens in its workspace. Saved for your account only.",
|
||||
"description": "How a new Claude, Codex, omp, Cursor or Antigravity session opens in its workspace. Saved for your account only.",
|
||||
"label": "Open new sessions as a chat",
|
||||
"labelDesc": "A Claude or Codex session first shows its conversation like Claude Desktop, with a message box, instead of its terminal. Each session then keeps its own view: the chat/terminal button beside a pane's split buttons switches it. Permission prompts and menus still need the terminal.",
|
||||
"labelDesc": "A new session first shows its conversation like Claude Desktop, with a message box, instead of its terminal (the terminal stays underneath). Each session then keeps its own view: the chat/terminal button beside a pane's split buttons switches it. Permission prompts and questions are answered from cards in the chat.",
|
||||
"saveFailed": "Could not save: {{message}}"
|
||||
},
|
||||
"sessionDefaults": {
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"deleteBody": "Mục này bị gỡ khỏi thư viện của bạn và Hub. Những bản người khác đã cài vẫn giữ nguyên.",
|
||||
"deleteConfirm": "Xoá",
|
||||
"installTitle": "Cài “{{title}}”?",
|
||||
"installSkillBody": "Các file này được ghi vào ~/.claude/skills/{{name}}/ của chính bạn. Skill có thể kèm script mà agent sẽ chạy — chỉ cài thứ bạn tin:",
|
||||
"installSkillBody": "Các file này được ghi vào ~/.claude/skills/{{name}}/ và ~/.agents/skills/{{name}}/ của chính bạn, để mọi AI tool và mọi phiên đều dùng được. Skill có thể kèm script mà agent sẽ chạy — chỉ cài thứ bạn tin:",
|
||||
"installPluginBody": "Chạy dưới tài khoản của bạn: claude plugin marketplace add {{market}} và claude plugin install {{name}} --scope user. Chỉ cài thứ bạn tin.",
|
||||
"installConfirm": "Cài",
|
||||
"replaceTitle": "Thay skill “{{name}}” của bạn?",
|
||||
@@ -70,7 +70,8 @@
|
||||
"form.save": "Thêm",
|
||||
"form.cancel": "Huỷ",
|
||||
"added": "Đã thêm",
|
||||
"files": "{{count}} file",
|
||||
"files_one": "{{count}} file",
|
||||
"files_other": "{{count}} file",
|
||||
"close": "Đóng",
|
||||
"fromSession": "Từ một phiên",
|
||||
"form.saveEdit": "Lưu",
|
||||
@@ -78,7 +79,7 @@
|
||||
"skill.tab.write": "Viết mới",
|
||||
"skill.tab.existing": "Từ skill có sẵn",
|
||||
"skill.name": "Tên skill",
|
||||
"skill.slugHint": "Cài vào ~/.claude/skills/{{slug}}/ và gọi bằng /{{slug}}",
|
||||
"skill.slugHint": "Cài vào ~/.claude/skills/{{slug}}/ (và ~/.agents/skills/) và gọi bằng /{{slug}}",
|
||||
"skill.slugBad": "Chỉ dùng chữ, số, '.', '_' hoặc '-'",
|
||||
"skill.description": "Mô tả — khi nào Claude nên dùng skill này",
|
||||
"skill.descriptionHint": "VD: Dùng khi viết release notes từ git log",
|
||||
@@ -110,5 +111,68 @@
|
||||
"file.fromComputer": "Từ máy tính (≤ 25 MB)",
|
||||
"file.upload": "Tải lên",
|
||||
"file.tooBig": "File lớn hơn 25 MB.",
|
||||
"file.fromWorkspace": "Hoặc ghim tài liệu do các phiên của workspace đã ghi"
|
||||
"file.fromWorkspace": "Hoặc ghim tài liệu do các phiên của workspace đã ghi",
|
||||
"editTitle": "Sửa “{{title}}”",
|
||||
"edit.title": "Tiêu đề",
|
||||
"edit.description": "Mô tả",
|
||||
"previewFailed": "Không tải được file này.",
|
||||
"truncated": "Chỉ hiển thị 1 MB đầu — tải file về để xem toàn bộ.",
|
||||
"tab.all": "Tất cả",
|
||||
"installedBadge": "Đã cài",
|
||||
"installsTitle": "Lượt cài",
|
||||
"installsYou": "Bạn",
|
||||
"installsUnknown": "Người dùng đã xoá",
|
||||
"installsReplaced": "thay bản cũ",
|
||||
"installsPluginWhere": "claude plugin install --scope user",
|
||||
"installsUnrecorded": "{{count}} lượt cài trước đây chỉ được đếm, không lưu chi tiết",
|
||||
"installsNoneOfYours": "Bạn chưa cài — chỉ hiện các lượt cài của bạn.",
|
||||
"reinstall": "Cài lại",
|
||||
"reinstallHint": "Ghi bản này đè lên bản bạn đã cài",
|
||||
"reinstallTitle": "Cài lại “{{title}}”?",
|
||||
"reinstallBody": "Bản đã cài ở ~/.claude/skills/{{name}}/ và ~/.agents/skills/{{name}}/ sẽ được thay bằng bản này. Bản hiện tại được chuyển vào ~/.claude/agenthub-replaced-skills/, không bị xoá.",
|
||||
"uninstall": "Gỡ cài đặt",
|
||||
"uninstallHint": "Gỡ khỏi tài khoản của bạn",
|
||||
"uninstallTitle": "Gỡ cài đặt “{{title}}”?",
|
||||
"uninstallSkillBody": "~/.claude/skills/{{name}}/ và ~/.agents/skills/{{name}}/ sẽ được chuyển vào ~/.claude/agenthub-replaced-skills/, không bị xoá. Các phiên đang mở vẫn giữ nó cho tới khi tải lại.",
|
||||
"uninstallPluginBody": "Chạy claude plugin uninstall {{name}} cho tài khoản của bạn; thư mục dữ liệu của plugin được giữ lại. Các phiên đang mở vẫn giữ nó cho tới khi /reload-plugins.",
|
||||
"uninstalled": "Đã gỡ {{name}}",
|
||||
"deleteBodyShared": "Mục này đang được chia sẻ nên cũng sẽ biến mất khỏi Hub với mọi người. Muốn giữ lại nhưng thôi chia sẻ thì dùng Bỏ chia sẻ. Những gì đã cài từ nó vẫn được giữ.",
|
||||
"deleteBodyKeep": "Mục này rời khỏi thư viện của bạn nhưng vẫn ở trên Hub vì đang được chia sẻ. Muốn gỡ khỏi Hub luôn thì sau đó bấm Gỡ khỏi Hub (hoặc Bỏ chia sẻ trước).",
|
||||
"deleteKept": "Đã xoá khỏi thư viện — vẫn còn trên Hub",
|
||||
"hubOnly": "Chỉ trên Hub",
|
||||
"hubOnlyHint": "Bạn đã xoá khỏi thư viện; mục này vẫn được chia sẻ trên Hub",
|
||||
"hubRemove": "Gỡ khỏi Hub",
|
||||
"hubRemoveTitle": "Gỡ “{{title}}” khỏi Hub?",
|
||||
"hubRemoveBody": "Mục này không còn trong thư viện của bạn nên sẽ bị xoá hẳn. Những gì đã cài từ nó vẫn được giữ.",
|
||||
"edit.otherFiles": "{{count}} file khác của skill được giữ nguyên.",
|
||||
"edit.reinstallNote": "Bản bạn đã cài vẫn là bản cũ cho đến khi bấm Cài lại.",
|
||||
"preview.instructions": "Hướng dẫn",
|
||||
"preview.frontmatter": "Frontmatter (phần đầu SKILL.md)",
|
||||
"preview.whenUsed": "Khi nào Claude dùng",
|
||||
"preview.noDescription": "Chưa có mô tả",
|
||||
"preview.installsAs": "Cài vào ~/.claude/skills/{{slug}}/ và ~/.agents/skills/{{slug}}/",
|
||||
"blurb.skill": "Bộ hướng dẫn tái sử dụng cho Claude",
|
||||
"blurb.plugin": "Gói skill, lệnh và hook",
|
||||
"blurb.page": "Trang mà phiên đã publish lên claude.ai",
|
||||
"blurb.file": "Tài liệu và kết quả đáng giữ lại",
|
||||
"clearSearch": "Xoá tìm kiếm",
|
||||
"categories": "Danh mục",
|
||||
"seeAll": "Xem tất cả",
|
||||
"results_one": "{{count}} kết quả",
|
||||
"results_other": "{{count}} kết quả",
|
||||
"empty.title": "Thư viện đang trống",
|
||||
"empty.hubTitle": "Hub chưa có gì",
|
||||
"empty.searchTitle": "Không tìm thấy",
|
||||
"empty.search": "Không có mục nào khớp tìm kiếm hoặc danh mục này — thử từ khác, hoặc chọn Tất cả.",
|
||||
"details": "Chi tiết",
|
||||
"more": "Thao tác khác",
|
||||
"about": "Giới thiệu",
|
||||
"stat.kind": "Loại",
|
||||
"stat.installs": "Lượt cài",
|
||||
"stat.files": "File",
|
||||
"stat.size": "Dung lượng",
|
||||
"stat.updated": "Cập nhật",
|
||||
"detail.runAs": "Gọi bằng",
|
||||
"detail.market": "Marketplace",
|
||||
"detail.added": "Thêm lúc {{date}}"
|
||||
}
|
||||
|
||||
@@ -531,6 +531,9 @@
|
||||
"changes.unversionedGroup": "File chưa theo dõi",
|
||||
"changes.commitTicked": "Commit ({{count}})",
|
||||
"changes.commitAndPush": "Commit và Push",
|
||||
"changes.discardAll": "Bỏ tất cả",
|
||||
"changes.discardAllTitle": "Bỏ tất cả thay đổi?",
|
||||
"changes.discardAllConfirm": "Mọi thay đổi trong {{count}} file sẽ trở về commit cuối, và {{untracked}} file mới chưa được git theo dõi sẽ bị xoá. Không thể hoàn tác.",
|
||||
"changes.amendAndPush": "Amend và Push",
|
||||
"changes.pushFailed": "Đã commit, nhưng push thất bại: {{error}}",
|
||||
"changes.tickFile": "Đưa {{path}} vào commit",
|
||||
|
||||
@@ -71,6 +71,8 @@
|
||||
"paste": "Thả để dán vào terminal",
|
||||
"uploading": "Đang tải lên…",
|
||||
"failed": "Không đính kèm được file",
|
||||
"tooBig": "File quá lớn để tải lên (tối đa {{mb}} MB) — hãy đặt file vào thư mục workspace rồi đính kèm từ Files",
|
||||
"tooBigLocal": "File quá lớn để tải lên (tối đa {{mb}} MB) — hãy đính kèm bằng đường dẫn: + → Chọn file trên máy này",
|
||||
"remove": "Bỏ đính kèm",
|
||||
"view": "Xem ảnh"
|
||||
},
|
||||
@@ -87,6 +89,7 @@
|
||||
"findClose": "Đóng tìm kiếm"
|
||||
},
|
||||
"chat": {
|
||||
"codexPicker": "Mở bộ chọn của chính Codex — trả lời ngay trong chat",
|
||||
"placeholder": "Viết tin nhắn…",
|
||||
"send": "Gửi",
|
||||
"notSent": "Terminal chưa kết nối — tin nhắn chưa được gửi.",
|
||||
@@ -97,6 +100,9 @@
|
||||
"notStopped": "Terminal chưa kết nối — chưa ngưng được.",
|
||||
"add": "Thêm file, skill…",
|
||||
"upload": "Tải lên hoặc thêm file",
|
||||
"pickLocal": "Chọn file trên máy này",
|
||||
"pickLocalHint": "Đính kèm file trên máy này bằng đường dẫn — không tải lên gì cả",
|
||||
"pickLocalOpen": "Hộp chọn file đang mở trên màn hình máy này…",
|
||||
"projectFiles": "File trong dự án",
|
||||
"contextFiles": "File ngữ cảnh",
|
||||
"recentUploads": "Đã tải lên gần đây",
|
||||
@@ -134,7 +140,28 @@
|
||||
},
|
||||
"menu": {
|
||||
"placeholder": "Nhập câu trả lời của bạn…",
|
||||
"cancel": "Huỷ (Esc)"
|
||||
"cancel": "Huỷ (Esc)",
|
||||
"prev": "Câu trước",
|
||||
"next": "Câu tiếp"
|
||||
},
|
||||
"request": {
|
||||
"notePlaceholder": "Nói cho model biết nên làm gì thay vào đó…",
|
||||
"send": "Gửi",
|
||||
"denyWithNote": "Từ chối kèm lời nhắn"
|
||||
},
|
||||
"artifact": {
|
||||
"preview": "Xem trước",
|
||||
"code": "Mã",
|
||||
"copy": "Sao chép",
|
||||
"download": "Tải xuống",
|
||||
"share": "Lưu vào Artifacts",
|
||||
"shared": "Đã lưu vào thư viện Artifacts của bạn — chia sẻ từ trang Artifacts.",
|
||||
"cannotShare": "Không lưu được file này vào Artifacts.",
|
||||
"expand": "Toàn màn hình",
|
||||
"collapse": "Thoát toàn màn hình",
|
||||
"older": "Phiên bản cũ hơn",
|
||||
"newer": "Phiên bản mới hơn",
|
||||
"version": "Phiên bản {{n}}/{{total}}"
|
||||
}
|
||||
},
|
||||
"interrupted": {
|
||||
|
||||
@@ -216,7 +216,30 @@
|
||||
"agentsStill_other": "còn {{count}} agent đang chạy",
|
||||
"agentsRunning_one": "{{count}} agent đang chạy nền",
|
||||
"agentsRunning_other": "{{count}} agent đang chạy nền",
|
||||
"agentContext": "{{tokens}} token"
|
||||
"agentContext": "{{tokens}} token",
|
||||
"agent": {
|
||||
"open": "Xem hội thoại",
|
||||
"prompt": "Nhiệm vụ",
|
||||
"background": "chạy nền",
|
||||
"toolUses_one": "{{count}} lần gọi tool",
|
||||
"toolUses_other": "{{count}} lần gọi tool",
|
||||
"status": {
|
||||
"running": "đang chạy",
|
||||
"done": "xong",
|
||||
"error": "lỗi",
|
||||
"stopped": "đã dừng"
|
||||
},
|
||||
"hide": "Ẩn hội thoại"
|
||||
},
|
||||
"artifact": {
|
||||
"html": "Trang web",
|
||||
"svg": "Ảnh · SVG",
|
||||
"markdown": "Tài liệu",
|
||||
"mermaid": "Sơ đồ",
|
||||
"react": "Component React",
|
||||
"csv": "Bảng",
|
||||
"open": "Bấm để mở"
|
||||
}
|
||||
},
|
||||
"overview": {
|
||||
"agent": "Agent",
|
||||
|
||||
@@ -1302,9 +1302,9 @@
|
||||
},
|
||||
"chatUi": {
|
||||
"title": "Giao diện chat",
|
||||
"description": "Cách một session Claude hoặc Codex mới mở trong workspace. Chỉ lưu cho tài khoản của bạn.",
|
||||
"description": "Cách một session Claude, Codex, omp, Cursor hoặc Antigravity mới mở trong workspace. Chỉ lưu cho tài khoản của bạn.",
|
||||
"label": "Mở session mới dạng chat",
|
||||
"labelDesc": "Session Claude hoặc Codex sẽ mở hội thoại như Claude Desktop, kèm ô nhập tin, thay cho terminal. Sau đó mỗi session giữ dạng xem riêng: nút chat/terminal cạnh nút split của pane để chuyển. Hỏi quyền và menu vẫn cần terminal.",
|
||||
"labelDesc": "Session mới sẽ mở hội thoại như Claude Desktop, kèm ô nhập tin, thay cho terminal (terminal vẫn chạy bên dưới). Sau đó mỗi session giữ dạng xem riêng: nút chat/terminal cạnh nút split của pane để chuyển. Hỏi quyền và câu hỏi được trả lời bằng thẻ ngay trong chat.",
|
||||
"saveFailed": "Không lưu được: {{message}}"
|
||||
},
|
||||
"sessionDefaults": {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @file artifactView.test.ts
|
||||
* @description What counts as an artifact and how it is shown: kinds by
|
||||
* name, a React file only when written whole, a file's versions rebuilt from
|
||||
* the conversation's Write/Edit/MultiEdit inputs (a failed call skipped, an
|
||||
* edit to unseen text unknown), CSV rows, and the sandboxed documents.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
artifactDocument,
|
||||
artifactKind,
|
||||
artifactVersions,
|
||||
frameSandbox,
|
||||
isArtifact,
|
||||
parseCsv,
|
||||
} from "../artifactView";
|
||||
import type { TranscriptMessage } from "../types";
|
||||
|
||||
const use = (id: string, name: string, input: Record<string, unknown>): TranscriptMessage => ({
|
||||
type: "assistant",
|
||||
timestamp: `2026-10-21T00:00:0${id}.000Z`,
|
||||
content: [{ type: "tool_use", id, name, input } as TranscriptMessage["content"][number]],
|
||||
});
|
||||
const result = (id: string, isError = false): TranscriptMessage => ({
|
||||
type: "user",
|
||||
sender: "tool",
|
||||
timestamp: null,
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
id,
|
||||
output: "",
|
||||
is_error: isError,
|
||||
} as TranscriptMessage["content"][number],
|
||||
],
|
||||
});
|
||||
|
||||
describe("artifactView", () => {
|
||||
it("knows an artifact by its name", () => {
|
||||
expect(artifactKind("/a/page.HTML")).toBe("html");
|
||||
expect(artifactKind("d.mmd")).toBe("mermaid");
|
||||
expect(artifactKind("App.tsx")).toBe("react");
|
||||
expect(artifactKind("x.go")).toBe("code");
|
||||
expect(isArtifact("report.md", false)).toBe(true);
|
||||
expect(isArtifact("App.tsx", false)).toBe(false);
|
||||
expect(isArtifact("App.tsx", true)).toBe(true);
|
||||
expect(isArtifact("server.js", true)).toBe(false);
|
||||
});
|
||||
|
||||
it("rebuilds a file's versions from Write, Edit and MultiEdit", () => {
|
||||
const msgs = [
|
||||
use("1", "Write", { file_path: "/w/a.md", content: "# A\nold line\n" }),
|
||||
result("1"),
|
||||
use("2", "Edit", { file_path: "/w/a.md", old_string: "old", new_string: "new" }),
|
||||
result("2"),
|
||||
use("3", "Edit", { file_path: "/w/a.md", old_string: "missing", new_string: "x" }),
|
||||
result("3", true),
|
||||
use("4", "MultiEdit", {
|
||||
file_path: "/w/a.md",
|
||||
edits: [{ old_string: "# A", new_string: "# B" }],
|
||||
}),
|
||||
result("4"),
|
||||
use("5", "Write", { file_path: "/w/other.md", content: "no" }),
|
||||
];
|
||||
const v = artifactVersions(msgs, "/w/a.md");
|
||||
expect(v.map((x) => x.content)).toEqual([
|
||||
"# A\nold line\n",
|
||||
"# A\nnew line\n",
|
||||
"# B\nnew line\n",
|
||||
]);
|
||||
});
|
||||
|
||||
it("says an edit to text it never saw is of unknown content", () => {
|
||||
const v = artifactVersions(
|
||||
[use("1", "Edit", { file_path: "a.md", old_string: "a", new_string: "b" })],
|
||||
"a.md"
|
||||
);
|
||||
expect(v).toHaveLength(1);
|
||||
expect(v[0]!.content).toBeNull();
|
||||
});
|
||||
|
||||
it("reads CSV with quotes and newlines inside them", () => {
|
||||
expect(parseCsv('a,b\n"x, y","say ""hi""\nthere"\n')).toEqual([
|
||||
["a", "b"],
|
||||
["x, y", 'say "hi"\nthere'],
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds sandboxed documents that keep the source inert inside a script", () => {
|
||||
const doc = artifactDocument("react", "export default () => <p>a</script>b</p>", false)!;
|
||||
expect(doc).toContain("babel");
|
||||
expect(doc).not.toContain("a</script>b");
|
||||
expect(artifactDocument("mermaid", "graph TD; A-->B<img>", true)).toContain(
|
||||
"A-->B<img>"
|
||||
);
|
||||
expect(artifactDocument("code", "x", false)).toBeNull();
|
||||
expect(frameSandbox("svg")).toBe("");
|
||||
expect(frameSandbox("html")).not.toContain("allow-same-origin");
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
chooseRunOption,
|
||||
parsePaneMenu,
|
||||
pasteToLane,
|
||||
readRunBusy,
|
||||
readRunMenu,
|
||||
registerPane,
|
||||
takeRunInput,
|
||||
@@ -160,6 +161,33 @@ describe("panePaste", () => {
|
||||
expect(m.typeAt).toBe(2);
|
||||
});
|
||||
|
||||
// Several questions: the unnumbered row is Next until the last one, and the
|
||||
// tab bar says which questions have an answer.
|
||||
it("reads a multi-question multiSelect with its Next row and question tabs", () => {
|
||||
const m = parsePaneMenu(MULTI_Q)!;
|
||||
expect(m.options.map((o) => [o.n, o.label, o.checked])).toEqual([
|
||||
[1, "JWT HS256 + default secret", true],
|
||||
[2, "Product code issue 2 lần", true],
|
||||
[3, "Type something", false],
|
||||
[null, "Next", null],
|
||||
[4, "Chat about this", null],
|
||||
]);
|
||||
expect(m.options[2]!.hint).toBe("");
|
||||
expect(m.cursor).toBe(0);
|
||||
expect(m.tabs).toEqual([
|
||||
{ label: "Critical", done: true },
|
||||
{ label: "High 1/3", done: false },
|
||||
{ label: "High 2/3", done: false },
|
||||
{ label: "High 3/3", done: false },
|
||||
]);
|
||||
expect(m.tab).toBe(0);
|
||||
expect(MENU_CURSOR_ROW.test("❯ Next")).toBe(true);
|
||||
});
|
||||
|
||||
it("reads no tabs for a single question", () => {
|
||||
expect(parsePaneMenu(ASK)!.tabs).toBeUndefined();
|
||||
});
|
||||
|
||||
// The "Type something" row once something is typed in it: its label is the
|
||||
// text. It is still the row right above "Chat about this".
|
||||
it("finds Type something by its place once text is typed in it, and replaces that text", () => {
|
||||
@@ -188,8 +216,11 @@ describe("panePaste", () => {
|
||||
const c = parsePaneMenu(PERMISSION)!;
|
||||
expect(c.question).toBe("Do you want to proceed?");
|
||||
expect(c.options.map((o) => o.label)).toEqual(["Yes", "Yes, and always allow", "No"]);
|
||||
// The tool and its command, drawn over the dashed rule.
|
||||
expect(c.detail).toEqual(["Bash command", "touch probe-file.txt"]);
|
||||
const x = parsePaneMenu(CODEX_APPROVAL)!;
|
||||
expect(x.question).toBe("Would you like to run the following command?\n$ touch probe.txt");
|
||||
expect(x.detail).toBeUndefined();
|
||||
expect(x.options).toHaveLength(3);
|
||||
expect(x.cursor).toBe(0);
|
||||
});
|
||||
@@ -317,6 +348,23 @@ const MULTI = [
|
||||
"",
|
||||
"Enter to select · Tab/Arrow keys to navigate · Esc to cancel",
|
||||
];
|
||||
const MULTI_Q = [
|
||||
RULE,
|
||||
"← ☒ Critical □ High 1/3 □ High 2/3 □ High 3/3 ✔ Submit →",
|
||||
"",
|
||||
"Critical (đã verify trong code). Chọn issue để log BUG:",
|
||||
"",
|
||||
"❯ 1. [✔] JWT HS256 + default secret",
|
||||
" pkg/middleware/auth.go:156,184 — HMAC only",
|
||||
" 2. [✔] Product code issue 2 lần",
|
||||
" gift product_code_repository.go:57 — không FOR UPDATE",
|
||||
" 3. [ ] Type something",
|
||||
" Next",
|
||||
RULE,
|
||||
" 4. Chat about this",
|
||||
"",
|
||||
"Enter to select · Tab/Arrow keys to navigate · Esc to cancel",
|
||||
];
|
||||
const PERMISSION = [
|
||||
" Bash command",
|
||||
" touch probe-file.txt",
|
||||
@@ -378,3 +426,51 @@ describe("MENU_CURSOR_ROW", () => {
|
||||
expect(has(CODEX)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readRunBusy", () => {
|
||||
it("reads each tool's own busy line, and nothing for a tool it does not know", () => {
|
||||
let rows = ["✻ Thinking… (4s · ↓ 120 tokens · esc to interrupt)"];
|
||||
const off = registerPane("agenthub-lane-8", () => {}, () => true, undefined, undefined, () => rows);
|
||||
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(true);
|
||||
// Claude's permission footer is not a turn at work.
|
||||
rows = ["Esc to cancel · Tab to amend"];
|
||||
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(false);
|
||||
expect(readRunBusy("agenthub-lane-8", "antigravity")).toBe(false);
|
||||
rows = ["Thinking… (3s · esc to cancel)"];
|
||||
expect(readRunBusy("agenthub-lane-8", "antigravity")).toBe(true);
|
||||
expect(readRunBusy("agenthub-lane-8", "omp")).toBeNull();
|
||||
off();
|
||||
expect(readRunBusy("agenthub-lane-8", "claude")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Codex's pickers", () => {
|
||||
it("reads /permissions with each row's description column as its hint", () => {
|
||||
const menu = parsePaneMenu([
|
||||
" >_ OpenAI Codex (v0.163.0)",
|
||||
"",
|
||||
" Update Model Permissions",
|
||||
"",
|
||||
"› 1. Ask for approval (current) Read and edit workspace files and run commands, with approval req",
|
||||
" access or edits outside the workspace",
|
||||
" 2. Approve for me Only ask for actions detected as potentially unsafe",
|
||||
" 3. Full Access Use with caution: Codex can edit files",
|
||||
" without approval",
|
||||
" 4. Read Only Read workspace files",
|
||||
"",
|
||||
" enter select · esc back",
|
||||
]);
|
||||
expect(menu?.question).toBe("Update Model Permissions");
|
||||
expect(menu?.cursor).toBe(0);
|
||||
expect(menu?.options.map((o) => [o.n, o.label, o.hint])).toEqual([
|
||||
[
|
||||
1,
|
||||
"Ask for approval (current)",
|
||||
"Read and edit workspace files and run commands, with approval req access or edits outside the workspace",
|
||||
],
|
||||
[2, "Approve for me", "Only ask for actions detected as potentially unsafe"],
|
||||
[3, "Full Access", "Use with caution: Codex can edit files without approval"],
|
||||
[4, "Read Only", "Read workspace files"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* @file promptAttach.test.ts — `claudePastes`: how files reach a `claude` pane.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { claudePastes } from "../promptAttach";
|
||||
|
||||
describe("claudePastes", () => {
|
||||
it("pastes each image as its bare path and the other files as one @ mention paste", () => {
|
||||
expect(
|
||||
claudePastes(["/d/drops/a.png", "/d/drops/b-report.pdf", "/d/x.JPG", "/d/c.log"])
|
||||
).toEqual(["/d/drops/a.png", "/d/x.JPG", "@/d/drops/b-report.pdf @/d/c.log"]);
|
||||
});
|
||||
|
||||
it("keeps a Windows or spaced image path a mention", () => {
|
||||
expect(claudePastes(["C:\\u\\drops\\a.png", "/home/u/my shot.png"])).toEqual([
|
||||
'@C:/u/drops/a.png @"/home/u/my shot.png"',
|
||||
]);
|
||||
});
|
||||
});
|
||||
+112
-36
@@ -796,6 +796,8 @@ async function send<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
runId: body?.error?.run_id as string | undefined,
|
||||
// EEXISTS from the File Explorer: the names already taken.
|
||||
names: body?.error?.names as string[] | undefined,
|
||||
// ETOOBIG from a prompt drop: the server's own limit.
|
||||
maxBytes: body?.error?.max_bytes as number | undefined,
|
||||
});
|
||||
}
|
||||
// A 200 that is not JSON — the SPA's index.html from a proxy or a dev server
|
||||
@@ -861,6 +863,44 @@ function fsUrl(place: FsPlace, sub: string, q?: URLSearchParams): string {
|
||||
return gitUrl(place, sub, q).replace(/^(\/lanes\/\d+)\/git/, "$1/fs");
|
||||
}
|
||||
|
||||
/**
|
||||
* POST `payload` (a file or a batch) as a raw stream to `url`, reporting upload
|
||||
* progress. Rejects like {@link request}: `code`, and `names` on EEXISTS.
|
||||
*/
|
||||
const sendRaw = <T>(url: string, payload: Blob, onProgress?: (sent: number) => void) =>
|
||||
new Promise<T>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", `${BASE}${url}`);
|
||||
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
||||
const token = dashboardToken();
|
||||
if (token) xhr.setRequestHeader("x-dashboard-token", token);
|
||||
xhr.upload.onprogress = (e) => onProgress?.(e.loaded);
|
||||
xhr.onerror = () => reject(Object.assign(new Error("network error"), { code: "ENET" }));
|
||||
xhr.onload = () => {
|
||||
let body: { error?: { code?: string; message?: string; names?: string[] } } = {};
|
||||
try {
|
||||
body = JSON.parse(xhr.responseText);
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(body as T);
|
||||
return;
|
||||
}
|
||||
if (xhr.status === 401 && body.error?.code === "EAUTH") {
|
||||
window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT));
|
||||
}
|
||||
reject(
|
||||
Object.assign(new Error(body.error?.message || `HTTP ${xhr.status}`), {
|
||||
code: body.error?.code,
|
||||
status: xhr.status,
|
||||
names: body.error?.names,
|
||||
})
|
||||
);
|
||||
};
|
||||
xhr.send(payload);
|
||||
});
|
||||
|
||||
/** A home the File Explorer can open (`GET /api/homes`): `me`, or another user's for an admin. */
|
||||
export interface FsHome {
|
||||
id: string;
|
||||
@@ -2170,7 +2210,8 @@ export const api = {
|
||||
* each one contributes (skills/agents/commands/hooks counts).
|
||||
* @returns {@link CcPluginsResponse} — the manifest path/status plus plugins.
|
||||
*/
|
||||
plugins: () => request<CcPluginsResponse>("/cc-config/plugins"),
|
||||
plugins: (cwd?: string) =>
|
||||
request<CcPluginsResponse>(`/cc-config/plugins${ccQuery(undefined, cwd)}`),
|
||||
/**
|
||||
* GET /api/cc-config/mcp - configured MCP servers, user and project-scoped.
|
||||
* @returns {@link CcMcpResponse} — servers split into `user`/`projectScoped`.
|
||||
@@ -2764,40 +2805,45 @@ export const api = {
|
||||
conflict: FsConflict = "fail",
|
||||
onProgress?: (sent: number) => void
|
||||
) =>
|
||||
new Promise<{ path: string; skipped?: boolean }>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", `${BASE}${fsUrl(t, "/upload", new URLSearchParams({ path, conflict }))}`);
|
||||
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
||||
const token = dashboardToken();
|
||||
if (token) xhr.setRequestHeader("x-dashboard-token", token);
|
||||
xhr.upload.onprogress = (e) => onProgress?.(e.loaded);
|
||||
xhr.onerror = () => reject(Object.assign(new Error("network error"), { code: "ENET" }));
|
||||
xhr.onload = () => {
|
||||
let body: { error?: { code?: string; message?: string; names?: string[] } } & {
|
||||
path?: string;
|
||||
skipped?: boolean;
|
||||
} = {};
|
||||
try {
|
||||
body = JSON.parse(xhr.responseText);
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(body as { path: string; skipped?: boolean });
|
||||
return;
|
||||
}
|
||||
if (xhr.status === 401 && body.error?.code === "EAUTH") {
|
||||
window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT));
|
||||
}
|
||||
reject(
|
||||
Object.assign(new Error(body.error?.message || `HTTP ${xhr.status}`), {
|
||||
code: body.error?.code,
|
||||
status: xhr.status,
|
||||
names: body.error?.names,
|
||||
})
|
||||
);
|
||||
};
|
||||
xhr.send(file);
|
||||
sendRaw<{ path: string; skipped?: boolean }>(
|
||||
fsUrl(t, "/upload", new URLSearchParams({ path, conflict })),
|
||||
file,
|
||||
onProgress
|
||||
),
|
||||
/**
|
||||
* Many small files in ONE request, under the folder `dir` (each `rel`
|
||||
* relative to it): a 4-byte length, the JSON file list, then the bytes.
|
||||
* The Blob is only composed, never read into memory here.
|
||||
*/
|
||||
uploadBatch: (
|
||||
t: FsPlace,
|
||||
dir: string,
|
||||
files: { file: Blob; rel: string }[],
|
||||
conflict: FsConflict = "fail",
|
||||
onProgress?: (sent: number) => void
|
||||
) => {
|
||||
const head = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
files: files.map((f) => ({
|
||||
path: f.rel,
|
||||
size: f.file.size,
|
||||
mtime: f.file instanceof File ? f.file.lastModified : undefined,
|
||||
})),
|
||||
})
|
||||
);
|
||||
const len = new Uint8Array(4);
|
||||
new DataView(len.buffer).setUint32(0, head.length);
|
||||
return sendRaw<{ saved: number; skipped: number; paths: string[] }>(
|
||||
fsUrl(t, "/upload-batch", new URLSearchParams({ dir, conflict })),
|
||||
new Blob([len, head, ...files.map((f) => f.file)], { type: "application/octet-stream" }),
|
||||
onProgress
|
||||
);
|
||||
},
|
||||
/** Before a batch: which of `paths` (under `dir`) are taken, and whether a batch can go there. */
|
||||
uploadCheck: (t: FsPlace, dir: string, paths: string[]) =>
|
||||
request<{ taken: string[]; batch: boolean }>(fsUrl(t, "/upload-check"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ dir, paths }),
|
||||
}),
|
||||
},
|
||||
/** The signed-in user's own git identity (`/api/me/git`, `server/routes/git-identity.js`). */
|
||||
@@ -2945,18 +2991,29 @@ export const api = {
|
||||
source?: string;
|
||||
/** plugin only: the marketplace source. */
|
||||
market?: string;
|
||||
/** skill only: new contents, written as a new snapshot. */
|
||||
skill?: { name: string; description: string; content: string };
|
||||
}
|
||||
) =>
|
||||
request<{ item: Artifact }>(`/artifacts/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
remove: (id: number) => request<{ ok: true }>(`/artifacts/${id}`, { method: "DELETE" }),
|
||||
/** `kept`: a shared one left your library but stays in the Hub. */
|
||||
remove: (id: number) =>
|
||||
request<{ ok: true; kept: boolean }>(`/artifacts/${id}`, { method: "DELETE" }),
|
||||
install: (id: number, replace = false) =>
|
||||
request<{ installed: string; replaced?: boolean }>(`/artifacts/${id}/install`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ replace }),
|
||||
}),
|
||||
/** 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`, {
|
||||
method: "POST",
|
||||
}),
|
||||
/** GET /api/artifacts/:id/installs — who installed it, when, and where. */
|
||||
installs: (id: number) => request<ArtifactInstalls>(`/artifacts/${id}/installs`),
|
||||
/** One of the snapshot's own files, served sandboxed. */
|
||||
rawUrl: (id: number, path?: string, download = false) => {
|
||||
const qs = new URLSearchParams();
|
||||
@@ -3134,6 +3191,14 @@ export const api = {
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
});
|
||||
},
|
||||
/** POST /api/run/local-paths — each absolute path's size when it is a
|
||||
* regular file on the dashboard's machine, else null; 403 EREMOTE unless
|
||||
* the browser is on that machine (and never in server mode). */
|
||||
localPaths: (paths: string[]) =>
|
||||
request<{ sizes: (number | null)[] }>("/run/local-paths", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ paths }),
|
||||
}),
|
||||
/** POST /api/run - start (or adopt, if already live) a lane's terminal run. */
|
||||
start: (args: RunStartArgs) =>
|
||||
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
|
||||
@@ -4203,10 +4268,21 @@ export interface Artifact {
|
||||
shared: boolean;
|
||||
sharedAt: string | null;
|
||||
installs: number;
|
||||
/** False once its owner removed it from their library while shared: Hub only. */
|
||||
inLibrary: boolean;
|
||||
/** List rows only: a skill/plugin already in every account the viewer's sessions run as. */
|
||||
installed?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** GET /api/artifacts/:id/installs. `paths` only for your own installs (or an admin). */
|
||||
export interface ArtifactInstalls {
|
||||
items: { user: string | null; mine: boolean; at: string; replaced: boolean; paths?: string[] }[];
|
||||
/** Installs counted before the list was kept. */
|
||||
unrecorded: number;
|
||||
}
|
||||
|
||||
/** POST /api/artifacts/plugin/inspect — a marketplace and the plugins it lists. */
|
||||
export interface MarketplaceInfo {
|
||||
/** The source as stored (owner/repo, a git URL or a marketplace.json link). */
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* @file artifactView.ts
|
||||
* @description What makes a file an artifact, Claude Desktop's way, and how
|
||||
* one is shown: its kind from its name (a page, an image, a diagram, a React
|
||||
* component, a document, a table, code), the sandboxed document an HTML,
|
||||
* SVG, Mermaid or React artifact runs in (an iframe `srcdoc` with no
|
||||
* same-origin access, its libraries from a pinned CDN build, as Claude's own
|
||||
* artifacts load theirs), a CSV's rows, and the versions a conversation's
|
||||
* Write/Edit calls left of one file — rebuilt from their own inputs, so no
|
||||
* server keeps copies.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import type { TranscriptMessage } from "./types";
|
||||
|
||||
export type ArtifactKind =
|
||||
"html" | "svg" | "markdown" | "mermaid" | "react" | "csv" | "image" | "code";
|
||||
|
||||
const EXT = (p: string) =>
|
||||
(/\.([A-Za-z0-9]+)$/.exec(p.split(/[\\/]/).pop() || "")?.[1] || "").toLowerCase();
|
||||
|
||||
/** The kind a file shows as. */
|
||||
export function artifactKind(path: string): ArtifactKind {
|
||||
const e = EXT(path);
|
||||
if (e === "html" || e === "htm") return "html";
|
||||
if (e === "svg") return "svg";
|
||||
if (e === "md" || e === "markdown" || e === "mdx") return "markdown";
|
||||
if (e === "mmd" || e === "mermaid") return "mermaid";
|
||||
if (e === "jsx" || e === "tsx") return "react";
|
||||
if (e === "csv" || e === "tsv") return "csv";
|
||||
if (["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "ico"].includes(e)) return "image";
|
||||
return "code";
|
||||
}
|
||||
|
||||
/** Kinds with a rendered Preview (the rest show their code only). */
|
||||
export const RENDERED: ReadonlySet<ArtifactKind> = new Set([
|
||||
"html",
|
||||
"svg",
|
||||
"markdown",
|
||||
"mermaid",
|
||||
"react",
|
||||
"csv",
|
||||
"image",
|
||||
]);
|
||||
|
||||
/** An edit worth a card in the conversation: a page, a document, a diagram, a
|
||||
* table — and a React component the model wrote whole (a `.tsx` it merely
|
||||
* edited is project code, not an artifact). */
|
||||
export function isArtifact(path: string, created: boolean): boolean {
|
||||
const k = artifactKind(path);
|
||||
if (k === "react") return created;
|
||||
return k === "html" || k === "svg" || k === "markdown" || k === "mermaid" || k === "csv";
|
||||
}
|
||||
|
||||
/** A code fence language for the Code tab. */
|
||||
export function artifactLang(path: string): string {
|
||||
const e = EXT(path);
|
||||
const map: Record<string, string> = {
|
||||
htm: "html",
|
||||
mmd: "text",
|
||||
mermaid: "text",
|
||||
md: "markdown",
|
||||
mdx: "markdown",
|
||||
jsx: "jsx",
|
||||
tsx: "tsx",
|
||||
js: "javascript",
|
||||
mjs: "javascript",
|
||||
cjs: "javascript",
|
||||
ts: "typescript",
|
||||
py: "python",
|
||||
sh: "bash",
|
||||
yml: "yaml",
|
||||
};
|
||||
return map[e] || e;
|
||||
}
|
||||
|
||||
// Pinned builds (all on npm for well over two weeks), as Claude's own
|
||||
// artifacts load React, Tailwind and Mermaid from a CDN.
|
||||
const CDN = {
|
||||
react: "https://cdn.jsdelivr.net/npm/react@18.3.1/umd/react.production.min.js",
|
||||
reactDom: "https://cdn.jsdelivr.net/npm/react-dom@18.3.1/umd/react-dom.production.min.js",
|
||||
babel: "https://cdn.jsdelivr.net/npm/@babel/standalone@7.26.4/babel.min.js",
|
||||
tailwind: "https://cdn.tailwindcss.com/3.4.16",
|
||||
mermaid: "https://cdn.jsdelivr.net/npm/mermaid@11.4.1/dist/mermaid.esm.min.mjs",
|
||||
};
|
||||
|
||||
/** Text safe inside a `<script>` element. */
|
||||
const scriptSafe = (s: string) => s.replace(/<\/(script)/gi, "<\\/$1");
|
||||
const htmlEscape = (s: string) =>
|
||||
s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]!);
|
||||
|
||||
/** Shown inside the frame when the artifact itself fails. */
|
||||
const ERROR_BOX = `<pre id="__err" style="display:none;margin:16px;padding:12px;border-radius:8px;background:#fee2e2;color:#991b1b;font:12px/1.5 ui-monospace,monospace;white-space:pre-wrap"></pre>
|
||||
<script>window.addEventListener("error",function(e){var b=document.getElementById("__err");if(b){b.style.display="block";b.textContent=String(e.message||e.error||e);}});</script>`;
|
||||
|
||||
/** The document a rendered artifact runs in (null: it is not a frame kind). */
|
||||
export function artifactDocument(kind: ArtifactKind, source: string, dark: boolean): string | null {
|
||||
switch (kind) {
|
||||
case "html":
|
||||
return /<html[\s>]|<!doctype/i.test(source)
|
||||
? source
|
||||
: `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head><body>${source}</body></html>`;
|
||||
case "svg":
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;height:100%}body{display:flex;align-items:center;justify-content:center;background:${dark ? "#1f1f1f" : "#fff"}}svg{max-width:100%;max-height:100vh;height:auto}</style></head><body>${source}</body></html>`;
|
||||
case "mermaid":
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><style>body{margin:0;padding:16px;background:${dark ? "#1f1f1f" : "#fff"};display:flex;justify-content:center}</style></head><body>${ERROR_BOX}<pre class="mermaid">${htmlEscape(source)}</pre>
|
||||
<script type="module">import mermaid from "${CDN.mermaid}";mermaid.initialize({startOnLoad:false,theme:${dark ? '"dark"' : '"default"'},securityLevel:"strict"});mermaid.run().catch(function(e){var b=document.getElementById("__err");b.style.display="block";b.textContent=String(e&&e.message||e);});</script></body></html>`;
|
||||
case "react":
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<script src="${CDN.react}"></script><script src="${CDN.reactDom}"></script><script src="${CDN.babel}"></script><script src="${CDN.tailwind}"></script></head>
|
||||
<body><div id="root"></div>${ERROR_BOX}
|
||||
<script>
|
||||
(function(){
|
||||
var src = ${JSON.stringify(scriptSafe(source))};
|
||||
var mods = { react: window.React, "react-dom": window.ReactDOM, "react-dom/client": window.ReactDOM };
|
||||
function req(name){ if (mods[name]) return mods[name]; throw new Error("This preview does not provide \\"" + name + "\\" — only react and react-dom are available."); }
|
||||
try {
|
||||
var out = Babel.transform(src, { filename: "Artifact.tsx", presets: [["typescript", { isTSX: true, allExtensions: true }], "react"], plugins: ["transform-modules-commonjs"] }).code;
|
||||
var module = { exports: {} };
|
||||
new Function("require", "module", "exports", "React", out)(req, module, module.exports, window.React);
|
||||
var C = module.exports.default || module.exports.App || Object.values(module.exports).find(function(v){ return typeof v === "function"; });
|
||||
if (!C) throw new Error("No component exported: export a default React component.");
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(React.createElement(C));
|
||||
} catch (e) { var b = document.getElementById("__err"); b.style.display = "block"; b.textContent = String(e && e.message || e); }
|
||||
})();
|
||||
</script></body></html>`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The sandbox a frame kind gets: scripts but never the dashboard's origin
|
||||
* (no `allow-same-origin`), and none at all for an SVG. */
|
||||
export const frameSandbox = (kind: ArtifactKind) =>
|
||||
kind === "svg" ? "" : "allow-scripts allow-forms allow-modals allow-popups allow-downloads";
|
||||
|
||||
/** A CSV/TSV's rows (quoted fields, doubled quotes, newlines inside quotes). */
|
||||
export function parseCsv(text: string, sep = ","): string[][] {
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let field = "";
|
||||
let quoted = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i]!;
|
||||
if (quoted) {
|
||||
if (c === '"' && text[i + 1] === '"') {
|
||||
field += '"';
|
||||
i++;
|
||||
} else if (c === '"') quoted = false;
|
||||
else field += c;
|
||||
} else if (c === '"' && field === "") quoted = true;
|
||||
else if (c === sep) {
|
||||
row.push(field);
|
||||
field = "";
|
||||
} else if (c === "\n" || c === "\r") {
|
||||
if (c === "\r" && text[i + 1] === "\n") i++;
|
||||
row.push(field);
|
||||
rows.push(row);
|
||||
row = [];
|
||||
field = "";
|
||||
} else field += c;
|
||||
}
|
||||
if (field !== "" || row.length) {
|
||||
row.push(field);
|
||||
rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** One version of a file, as a Write/Edit call left it. */
|
||||
export interface ArtifactVersion {
|
||||
/** The call that made it. */
|
||||
toolUseId: string | null;
|
||||
timestamp: string | null;
|
||||
/** null: the call is known, its result text is not (an edit to a file
|
||||
* whose earlier text the conversation never showed). */
|
||||
content: string | null;
|
||||
}
|
||||
|
||||
const samePath = (a: unknown, b: string) =>
|
||||
typeof a === "string" && a.replace(/\\/g, "/") === b.replace(/\\/g, "/");
|
||||
|
||||
/** Applies one Edit (old → new, once or everywhere) to known text. */
|
||||
function applyEdit(text: string, oldS: unknown, newS: unknown, all: unknown): string | null {
|
||||
if (typeof oldS !== "string" || typeof newS !== "string") return null;
|
||||
if (!text.includes(oldS)) return null;
|
||||
return all ? text.split(oldS).join(newS) : text.replace(oldS, () => newS);
|
||||
}
|
||||
|
||||
/**
|
||||
* The versions of `path` in a conversation, oldest first: each successful
|
||||
* Write gives its whole text, and each Edit/MultiEdit after a known text gives
|
||||
* that text with its edits applied. A call whose input the transcript cut, or
|
||||
* an edit to text never shown, is a version of unknown content (the current
|
||||
* file on disk stands in for the newest one).
|
||||
*/
|
||||
export function artifactVersions(messages: TranscriptMessage[], path: string): ArtifactVersion[] {
|
||||
const failed = new Set<string>();
|
||||
for (const m of messages)
|
||||
for (const c of m.content) if (c.type === "tool_result" && c.is_error && c.id) failed.add(c.id);
|
||||
const out: ArtifactVersion[] = [];
|
||||
let text: string | null = null;
|
||||
for (const m of messages) {
|
||||
if (m.type !== "assistant") continue;
|
||||
for (const c of m.content) {
|
||||
if (c.type !== "tool_use" || (c.id && failed.has(c.id))) continue;
|
||||
const input = (c.input || {}) as Record<string, unknown>;
|
||||
if (!samePath(input.file_path, path)) continue;
|
||||
if (c.name === "Write") {
|
||||
text = typeof input.content === "string" ? input.content : null;
|
||||
} else if (c.name === "Edit") {
|
||||
text =
|
||||
text === null
|
||||
? null
|
||||
: applyEdit(text, input.old_string, input.new_string, input.replace_all);
|
||||
} else if (c.name === "MultiEdit") {
|
||||
const edits = Array.isArray(input.edits) ? (input.edits as Record<string, unknown>[]) : [];
|
||||
for (const e of edits) {
|
||||
if (text === null) break;
|
||||
text = applyEdit(text, e.old_string, e.new_string, e.replace_all);
|
||||
}
|
||||
} else continue;
|
||||
out.push({ toolUseId: c.id ?? null, timestamp: m.timestamp, content: text });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* @file chatUi.ts — whether a Claude/Codex session shows as a chat
|
||||
* (`PaneChat`, like Claude Desktop) or as its terminal. Each SESSION has its
|
||||
* @file chatUi.ts — which tools' terminal panes have a Chat view
|
||||
* (`CHAT_TOOLS`), and whether such a session shows as a chat (`PaneChat`,
|
||||
* like Claude Desktop) or as its terminal. Each SESSION has its
|
||||
* own view (`useSessionChat`, toggled by `setSessionChat` from the pane's
|
||||
* chat/terminal button), kept in this browser's localStorage
|
||||
* (`agenthub.chatUi.sessions`, the newest `MAX_VIEWS`). The account's Chat UI
|
||||
@@ -18,6 +19,19 @@ import { api } from "./api";
|
||||
|
||||
export const CHAT_UI_KEY = "agenthub.chatUi";
|
||||
|
||||
/** The tools whose terminal pane can show a chat: each one's pane learns its
|
||||
* session id (Claude's `--session-id`, the others' pollers through
|
||||
* `claimToolSessions`) and the server reads that session's conversation
|
||||
* (`GET /api/sessions/:id/transcript`). omp and Cursor send no hooks, so
|
||||
* their chat has no Stop button and follows at the pollers' pace. */
|
||||
export const CHAT_TOOLS: ReadonlySet<string> = new Set([
|
||||
"claude",
|
||||
"codex",
|
||||
"omp",
|
||||
"cursor",
|
||||
"antigravity",
|
||||
]);
|
||||
|
||||
export const VIEWS_KEY = "agenthub.chatUi.sessions";
|
||||
const MAX_VIEWS = 200;
|
||||
|
||||
|
||||
+115
-11
@@ -34,20 +34,24 @@ interface Pane {
|
||||
key?: (data: string) => void;
|
||||
/** `menuOnly`: null at once unless a row could be a menu's cursor row. */
|
||||
screen?: (menuOnly?: boolean) => PaneScreen | null;
|
||||
/** The rows on screen as plain text, dim cells kept. */
|
||||
lines?: () => string[];
|
||||
}
|
||||
const panes = new Map<string, Pane>();
|
||||
let lastFocused: string | null = null;
|
||||
|
||||
/** Registers a mounted terminal (`visible`: is it on screen now; `key`: send
|
||||
* raw input, as a key press; `screen`: its rows now); returns its unregister. */
|
||||
* raw input, as a key press; `screen`: its rows now; `lines`: the same as
|
||||
* plain text); returns its unregister. */
|
||||
export function registerPane(
|
||||
runId: string,
|
||||
paste: (text: string, submit?: boolean) => void,
|
||||
visible: () => boolean = () => true,
|
||||
key?: (data: string) => void,
|
||||
screen?: (menuOnly?: boolean) => PaneScreen | null
|
||||
screen?: (menuOnly?: boolean) => PaneScreen | null,
|
||||
lines?: () => string[]
|
||||
): () => void {
|
||||
const entry = { paste, visible, key, screen };
|
||||
const entry = { paste, visible, key, screen, lines };
|
||||
panes.set(runId, entry);
|
||||
return () => {
|
||||
if (panes.get(runId) === entry) panes.delete(runId);
|
||||
@@ -59,6 +63,26 @@ export function notePaneFocus(runId: string): void {
|
||||
lastFocused = runId;
|
||||
}
|
||||
|
||||
/** Who wants to hear that a pane drew something (its chat: the CLI is doing
|
||||
* something, so its transcript may have grown). */
|
||||
const outputListeners = new Map<string, Set<() => void>>();
|
||||
|
||||
/** A terminal received output for `runId` (TerminalView, every PTY frame). */
|
||||
export function notePaneOutput(runId: string): void {
|
||||
outputListeners.get(runId)?.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
/** Calls `fn` on every output `runId`'s terminal receives; returns its unregister. */
|
||||
export function onPaneOutput(runId: string, fn: () => void): () => void {
|
||||
let set = outputListeners.get(runId);
|
||||
if (!set) outputListeners.set(runId, (set = new Set()));
|
||||
set.add(fn);
|
||||
return () => {
|
||||
set!.delete(fn);
|
||||
if (!set!.size) outputListeners.delete(runId);
|
||||
};
|
||||
}
|
||||
|
||||
/** The tmux names of workspace `laneId`'s panes: `agenthub-lane-<id>[-slot]`. */
|
||||
function ofLane(runId: string, laneId: number): boolean {
|
||||
const base = `agenthub-lane-${laneId}`;
|
||||
@@ -162,32 +186,47 @@ export function takeRunInput(runId: string): string {
|
||||
}
|
||||
|
||||
/** One choice of a menu the CLI shows: `n` its number (null for Claude's
|
||||
* unnumbered Submit row), `checked` a multiSelect box's state. */
|
||||
* unnumbered Submit / Next row), `checked` a multiSelect box's state. */
|
||||
export type PaneMenuOption = {
|
||||
n: number | null;
|
||||
label: string;
|
||||
hint: string;
|
||||
checked: boolean | null;
|
||||
};
|
||||
/** One question tab of an AskUserQuestion that asks several: `done` once it
|
||||
* has an answer (`☒`). */
|
||||
export type PaneMenuTab = { label: string; done: boolean };
|
||||
/** A menu the CLI asks with (AskUserQuestion, a permission or trust prompt,
|
||||
* Codex's approval): `cursor` the row its `❯`/`›` is on, `typeAt` the
|
||||
* "Type something" row (-1: none). */
|
||||
* "Type something" row (-1: none). `tabs` only when it asks several
|
||||
* questions (`← ☒ A □ B ✔ Submit →`), `tab` the one shown (-1: unknown).
|
||||
* `detail` what a permission prompt asks about (its tool, then the command),
|
||||
* when the screen shows it apart from the question. */
|
||||
export type PaneMenu = {
|
||||
question: string;
|
||||
options: PaneMenuOption[];
|
||||
cursor: number;
|
||||
multi: boolean;
|
||||
typeAt: number;
|
||||
tabs?: PaneMenuTab[];
|
||||
tab?: number;
|
||||
detail?: string[];
|
||||
};
|
||||
|
||||
const OPTION = /^ {0,4}([❯›][ \u00a0]?)? *(\d{1,2})\. +(.*)$/;
|
||||
/** A row only a menu shows: its cursor on a numbered option or Submit, or the
|
||||
* "Enter to confirm" footer of Claude's unnumbered menus (the trust prompt). */
|
||||
export const MENU_CURSOR_ROW = /^ *(?:[❯›][ \u00a0]* *(?:\d{1,2}\. |Submit\s*$)|Enter to confirm)/;
|
||||
const SUBMIT = /^ *([❯›])?[ \u00a0]*Submit$/;
|
||||
export const MENU_CURSOR_ROW =
|
||||
/^ *(?:[❯›][ \u00a0]* *(?:\d{1,2}\. |(?:Submit|Next)\s*$)|Enter to confirm)/;
|
||||
// The unnumbered button under a multiSelect's rows: Next while more questions
|
||||
// follow, Submit on the last one.
|
||||
const SUBMIT = /^ *([❯›])?[ \u00a0]*(Submit|Next)$/;
|
||||
const TAB_BAR = /^ *← +(.+?) +→ *$/;
|
||||
const RULE_ROW = /^ *[─╌]{3,} *$/;
|
||||
const DASHED_ROW = /^ *╌{3,} *$/;
|
||||
const CHECK = /^\[([ ✔])\] *(.*)$/;
|
||||
const PLACEHOLDER = /^Type something\.?$/;
|
||||
const COLUMN = /^(\S.*?\S) {2,}(\S.*)$/;
|
||||
|
||||
/** The numbered menu at the bottom of `rows`, or null. A menu has two
|
||||
* numbered rows or more from 1, the cursor on one of them, and no rule right
|
||||
@@ -207,10 +246,13 @@ export function parsePaneMenu(rows: string[]): PaneMenu | null {
|
||||
const m = OPTION.exec(row);
|
||||
const s = m ? null : SUBMIT.exec(row);
|
||||
if (m || s) {
|
||||
const text = m ? (m[3] ?? "") : "Submit";
|
||||
const text = m ? (m[3] ?? "") : (s?.[2] ?? "Submit");
|
||||
const c = m ? CHECK.exec(text) : null;
|
||||
const label = c ? (c[2] ?? "") : text;
|
||||
const h = hint.join(" ");
|
||||
// Codex's pickers (`/model`, `/permissions`) draw each row's
|
||||
// description in a column after the name: split it off as the hint.
|
||||
const col = COLUMN.exec(c ? (c[2] ?? "") : text);
|
||||
const label = col ? col[1]! : c ? (c[2] ?? "") : text;
|
||||
const h = [col?.[2], ...hint].filter(Boolean).join(" ");
|
||||
options.unshift({
|
||||
n: m ? Number(m[2]) : null,
|
||||
label,
|
||||
@@ -239,15 +281,47 @@ export function parsePaneMenu(rows: string[]): PaneMenu | null {
|
||||
const lines: string[] = [];
|
||||
for (; q >= 0 && r[q] && !RULE_ROW.test(r[q]!) && lines.length < 4; q--)
|
||||
lines.unshift(r[q]!.trim());
|
||||
const question = lines.join("\n");
|
||||
const tabs = tabBar(r.slice(Math.max(0, q - 4), Math.max(0, q + 1)));
|
||||
const low = question.toLowerCase();
|
||||
// What a permission prompt asks about: Claude draws the tool and its
|
||||
// command over a dashed rule above the question, up to the dialog's top rule.
|
||||
const detail: string[] = [];
|
||||
let d = q;
|
||||
while (d >= 0 && !r[d]) d--;
|
||||
if (d >= 0 && DASHED_ROW.test(r[d]!))
|
||||
for (d--; d >= 0 && !RULE_ROW.test(r[d]!) && detail.length < 12; d--)
|
||||
if (r[d]) detail.unshift(r[d]!.trim());
|
||||
return {
|
||||
question: lines.join("\n"),
|
||||
question,
|
||||
options,
|
||||
cursor: options.length - cursor,
|
||||
multi: options.some((o) => o.checked !== null),
|
||||
typeAt: typeRow(options),
|
||||
...(tabs.length > 1
|
||||
? { tabs, tab: tabs.findIndex((t) => low.startsWith(t.label.toLowerCase())) }
|
||||
: {}),
|
||||
...(detail.length ? { detail } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The question tabs of a multi-question AskUserQuestion, read off its bar
|
||||
* (`← ☒ Critical □ High 1/3 ✔ Submit →`) among `rows`; the Submit tab is
|
||||
* no question. [] when there is no bar. */
|
||||
function tabBar(rows: string[]): PaneMenuTab[] {
|
||||
for (let i = rows.length - 1; i >= 0; i--) {
|
||||
const m = TAB_BAR.exec(rows[i] ?? "");
|
||||
if (!m) continue;
|
||||
return (m[1] ?? "")
|
||||
.split(/(?=[☒☐□✔☑■] )/)
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
.map((x) => ({ label: x.slice(1).trim(), done: /^[☒✔☑■]/.test(x) }))
|
||||
.filter((t) => t.label && t.label !== "Submit");
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const BARE_CURSOR = /^( {0,4})❯[ \u00a0]+(\S.*)$/;
|
||||
|
||||
/** Claude's UNNUMBERED menu at the bottom of `r` (the folder-trust prompt:
|
||||
@@ -291,6 +365,26 @@ function typeRow(options: PaneMenuOption[]): number {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** The status line a tool's TUI shows only while a turn runs, read off its
|
||||
* binary's own strings: Claude's and Codex's `esc to interrupt`, agy's
|
||||
* `Thinking… (3s · esc to cancel)` — in parentheses after an ellipsis, so
|
||||
* Claude's permission footer (`Esc to cancel · Tab to amend`) is not it. A
|
||||
* tool missing here (omp, Cursor) has no line we know. */
|
||||
export const BUSY_LINE: Readonly<Record<string, RegExp>> = {
|
||||
claude: /\besc to interrupt\b/i,
|
||||
codex: /\besc to interrupt\b/i,
|
||||
antigravity: /…\s*\([^)]*\besc to cancel\)/i,
|
||||
};
|
||||
|
||||
/** Whether `runId`'s pane shows `tool` at work now (its BUSY_LINE), or null
|
||||
* when the tool's line is unknown or the pane is not mounted. */
|
||||
export function readRunBusy(runId: string, tool: string): boolean | null {
|
||||
const re = BUSY_LINE[tool];
|
||||
const p = panes.get(runId);
|
||||
if (!re || !p?.lines) return null;
|
||||
return p.lines().some((l) => re.test(l));
|
||||
}
|
||||
|
||||
/** The menu `runId`'s visible pane shows now, or null. */
|
||||
export function readRunMenu(runId: string): PaneMenu | null {
|
||||
const p = panes.get(runId);
|
||||
@@ -322,6 +416,16 @@ export function chooseRunOption(runId: string, menu: PaneMenu, index: number): b
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Moves a multi-question AskUserQuestion to its previous (-1) or next (1)
|
||||
* question tab, the arrows Claude's footer names. False when the pane is not
|
||||
* visible or not attached. */
|
||||
export function stepRunMenu(runId: string, dir: -1 | 1): boolean {
|
||||
const p = panes.get(runId);
|
||||
if (!p?.visible() || !p.key) return false;
|
||||
p.key(dir < 0 ? "\x1b[D" : "\x1b[C");
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Answers `menu` with typed text: the arrows to its "Type something" row,
|
||||
* Ctrl+U over what is typed there already (one key clears it, however long
|
||||
* it is), the text as a bracketed
|
||||
|
||||
@@ -5,12 +5,16 @@
|
||||
* `PromptEditor`'s drop) or from a Chat pane's activity panel
|
||||
* (`dragAttachments`, `dragSkill`: a skill becomes the prompt's `/command`) and the files uploaded through a prompt box in the
|
||||
* last day (`recentUploads`, kept in this browser), and `claudeMention`, the
|
||||
* `@` form a file is handed to `claude` in. An attachment is a path sent with
|
||||
* the prompt, so nothing here touches the server.
|
||||
* `@` form a file is handed to `claude` in, and what a browser on the
|
||||
* dashboard's own machine attaches by path instead of uploading
|
||||
* (`localCandidates`, checked by the server in `sameFilesHere` —
|
||||
* `POST /api/run/local-paths` —, `localAttachment`). An attachment is a path
|
||||
* sent with the prompt; that check is the one call here to the server.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import type { DragEvent } from "react";
|
||||
import { api } from "./api";
|
||||
|
||||
/** One attachment, shaped like `PromptImage`: what is sent, and its label. */
|
||||
export interface Attachment {
|
||||
@@ -65,6 +69,76 @@ export function droppedAttachments(dt: DataTransfer | null | undefined): Attachm
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether this browser reaches the dashboard by a loopback address — a
|
||||
* hint only: an SSH tunnel's browser does too, from another machine, so a
|
||||
* dropped path is used only once `sameFilesHere` has checked it. */
|
||||
export function isLocalBrowser(): boolean {
|
||||
return /^(localhost|127(\.\d{1,3}){3}|\[::1\])$/.test(window.location.hostname);
|
||||
}
|
||||
|
||||
/** The `file://` paths a drop or paste carries beside its `files`, when this
|
||||
* may be a browser on the dashboard's machine — read now: a DataTransfer is
|
||||
* empty once its event is over. Null when there is nothing to check. */
|
||||
export function localCandidates(
|
||||
dt: DataTransfer | null | undefined,
|
||||
files: File[]
|
||||
): string[] | null {
|
||||
if (!isLocalBrowser()) return null;
|
||||
const paths = droppedFilePaths(dt);
|
||||
return paths && paths.length === files.length ? paths : null;
|
||||
}
|
||||
|
||||
/** Whether `paths` are the dropped `files` as the dashboard's machine sees
|
||||
* them: every one a regular file there, and the sizes the same. Anything
|
||||
* else (an SSH tunnel's laptop paths, server mode, an error) is false, and
|
||||
* the caller uploads the bytes as before. */
|
||||
export async function sameFilesHere(paths: string[], files: File[]): Promise<boolean> {
|
||||
try {
|
||||
const { sizes } = await api.run.localPaths(paths);
|
||||
const want = files.map((f) => f.size).sort((a, b) => a - b);
|
||||
const have = sizes.slice().sort((a, b) => (a ?? -1) - (b ?? -1));
|
||||
return have.length === want.length && have.every((s, i) => s === want[i]);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The local files a drop or a paste names as `file://` URIs (a file
|
||||
* manager's, an editor's), as absolute paths — or null when it names none,
|
||||
* or anything that is not one. `file:///C:/x` is `C:/x`. */
|
||||
export function droppedFilePaths(dt: DataTransfer | null | undefined): string[] | null {
|
||||
try {
|
||||
const paths = (dt?.getData("text/uri-list") ?? "")
|
||||
.split(/\r?\n/)
|
||||
.filter((l) => l && !l.startsWith("#"))
|
||||
.map((l) => {
|
||||
try {
|
||||
const u = new URL(l.trim());
|
||||
if (u.protocol !== "file:" || (u.host && u.host !== "localhost")) return null;
|
||||
return decodeURIComponent(u.pathname).replace(/^\/([A-Za-z]:\/)/, "$1");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
return paths.length && paths.every(Boolean) ? (paths as string[]) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** A file on this machine, by its absolute path, as an attachment. */
|
||||
export function localAttachment(path: string): Attachment {
|
||||
return {
|
||||
// A space would end the mention: Claude Code reads `@"…"` whole.
|
||||
path: /\s/.test(path) ? `@"${path}"` : `@${path}`,
|
||||
name:
|
||||
path
|
||||
.replace(/[\\/]$/, "")
|
||||
.split(/[\\/]/)
|
||||
.pop() || path,
|
||||
};
|
||||
}
|
||||
|
||||
/** A drop that carries no file and none of ours, only text — a file manager
|
||||
* or an editor (VS Code) that hands over `file://` URIs, a selection, a
|
||||
* link: the local `file://` URIs as `@path` attachments, else the text to
|
||||
@@ -73,28 +147,8 @@ export function droppedText(
|
||||
dt: DataTransfer | null | undefined
|
||||
): { attachments: Attachment[] } | { text: string } | null {
|
||||
try {
|
||||
const paths = (dt?.getData("text/uri-list") ?? "")
|
||||
.split(/\r?\n/)
|
||||
.filter((l) => l && !l.startsWith("#"))
|
||||
.map((l) => {
|
||||
try {
|
||||
const u = new URL(l.trim());
|
||||
return u.protocol === "file:" && (!u.host || u.host === "localhost")
|
||||
? decodeURIComponent(u.pathname)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
if (paths.length && paths.every(Boolean)) {
|
||||
return {
|
||||
attachments: (paths as string[]).map((p) => ({
|
||||
// A space would end the mention: Claude Code reads `@"…"` whole.
|
||||
path: /\s/.test(p) ? `@"${p}"` : `@${p}`,
|
||||
name: p.replace(/\/$/, "").split("/").pop() || p,
|
||||
})),
|
||||
};
|
||||
}
|
||||
const paths = droppedFilePaths(dt);
|
||||
if (paths) return { attachments: paths.map(localAttachment) };
|
||||
const text = dt?.getData("text/plain") ?? "";
|
||||
return text.trim() ? { text } : null;
|
||||
} catch {
|
||||
@@ -115,6 +169,22 @@ export function claudeMention(path: string): string {
|
||||
return /\s/.test(p) ? `@"${p}"` : `@${p}`;
|
||||
}
|
||||
|
||||
const IMAGE_PATH = /\.(png|jpe?g|gif|webp)$/i;
|
||||
|
||||
/** What a terminal pastes for files dropped or pasted on a `claude` pane, one
|
||||
* paste per entry, as a local terminal hands them over: the images as their
|
||||
* bare paths, each a paste of its own — the shape Claude Code turns into
|
||||
* `[Image #n]`, as it does a native drop — and every other file as an `@`
|
||||
* mention, together in one. A Windows path, or
|
||||
* one with a space, stays a mention — the bare path stayed plain text there
|
||||
* (see `claudeMention`). */
|
||||
export function claudePastes(paths: string[]): string[] {
|
||||
const bare = (p: string) => IMAGE_PATH.test(p) && p.startsWith("/") && !/\s/.test(p);
|
||||
const images = paths.filter(bare);
|
||||
const others = paths.filter((p) => !bare(p)).map(claudeMention);
|
||||
return [...images, others.join(" ")].filter(Boolean);
|
||||
}
|
||||
|
||||
export const SKILL_MIME = "application/x-agenthub-skill";
|
||||
// A skill's name as Claude Code types it: `name` or `plugin:name`.
|
||||
const SKILL_RE = /^[\w.-]+(?::[\w.-]+)?$/;
|
||||
|
||||
+18
-1
@@ -1930,7 +1930,10 @@ export interface WSMessage {
|
||||
/** A pane ended; refetch GET /api/run/interrupted (no payload). */
|
||||
| "runs_interrupted"
|
||||
/** An artifact was added, changed, shared or deleted; refetch the list. */
|
||||
| "artifact_changed";
|
||||
| "artifact_changed"
|
||||
/** A session's transcript grew on disk (`{session_id}`); a chat showing
|
||||
* it reads the new lines now. */
|
||||
| "transcript_changed";
|
||||
/** The message body, whose concrete shape is selected by `type` above. */
|
||||
data:
|
||||
| Session
|
||||
@@ -3228,6 +3231,17 @@ export interface TranscriptContent {
|
||||
/** Present on an AskUserQuestion "tool_result": question text → the option
|
||||
* the user picked, or what they typed. */
|
||||
answers?: Record<string, string>;
|
||||
/** Present on an Agent/Task "tool_result": the subagent it ran — its id
|
||||
* (the transcript's `agent_id`), model and, for a foreground run, its time,
|
||||
* tokens and tool calls (null when the result does not say). */
|
||||
agent?: {
|
||||
id: string;
|
||||
model: string | null;
|
||||
status: string | null;
|
||||
duration_ms: number | null;
|
||||
tokens: number | null;
|
||||
tool_uses: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
/** Who actually sent a transcript message. A JSONL `type:"user"` line can be the
|
||||
@@ -3313,6 +3327,9 @@ export interface TranscriptInfo {
|
||||
/** The subagent type for subagent entries; null/absent otherwise (e.g. the
|
||||
* main or compaction entries). */
|
||||
subagent_type?: string | null;
|
||||
/** The Agent call that launched this subagent (its `tool_use` id), when its
|
||||
* meta file names it. */
|
||||
tool_use_id?: string | null;
|
||||
/** Whether a JSONL transcript file was actually found on disk for this entry -
|
||||
* false means the entry exists in the DB but its transcript isn't available
|
||||
* (e.g. the raw file was pruned), so the UI disables that picker option. */
|
||||
|
||||
+1356
-285
File diff suppressed because it is too large
Load Diff
@@ -107,7 +107,7 @@ export function GitCredentialsConfig() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{/* The page header above already names the section. */}
|
||||
<p className="max-w-2xl text-xs text-fg-muted">{t("gitCreds.description")}</p>
|
||||
<button
|
||||
@@ -128,7 +128,7 @@ export function GitCredentialsConfig() {
|
||||
|
||||
{form && (
|
||||
<form
|
||||
className="grid max-w-2xl gap-2 rounded-lg border border-border p-3 sm:grid-cols-3"
|
||||
className="grid gap-2 rounded-lg border border-border bg-surface-1 p-3 sm:grid-cols-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void save();
|
||||
@@ -159,18 +159,18 @@ export function GitCredentialsConfig() {
|
||||
onChange={(e) => setForm({ ...form, token: e.target.value })}
|
||||
className={INPUT}
|
||||
/>
|
||||
<div className="flex gap-2 sm:col-span-3">
|
||||
<div className="flex justify-end gap-2 sm:col-span-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || !form.url.trim() || !form.username.trim() || !form.token}
|
||||
className="rounded border border-accent/40 bg-accent/15 px-3 py-1 text-xs text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
className="rounded-lg border border-accent/40 bg-accent/15 px-3 py-1.5 text-xs font-medium text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{t("gitCreds.save")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm(null)}
|
||||
className="rounded border border-border-light px-3 py-1 text-xs text-fg-secondary hover:bg-surface-3"
|
||||
className="rounded-lg border border-border-light px-3 py-1.5 text-xs text-fg-secondary hover:bg-surface-3"
|
||||
>
|
||||
{t("gitCreds.cancel")}
|
||||
</button>
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
/**
|
||||
* @file Tests for the `/artifacts` page: the library lists what the server
|
||||
* answers, the Hub tab and Share appear only in server mode, a skill is
|
||||
* answers (one merged list; All/My library/Hub filter and Share only in
|
||||
* server mode), an installed skill shows Installed, its install count lists
|
||||
* who installed it and where, a skill is
|
||||
* installed only after a confirmation (and a taken name asks to replace),
|
||||
* and a file preview is a sandboxed iframe with no `allow-*` token. Adding:
|
||||
* an HTML preview is a sandboxed iframe with no `allow-*` token, and a
|
||||
* skill's SKILL.md is rendered by the page itself. Adding:
|
||||
* a plugin is picked from the list its marketplace link answers (a link the
|
||||
* server cannot read opens the by-hand form), a skill is uploaded from one
|
||||
* Markdown file under the name the user types, or written in the dialog.
|
||||
* A card's right-click menu runs the same actions (Preview, Delete). A file
|
||||
* the sandbox cannot show (Office, zip, PDF) offers Download only, and the
|
||||
* Add menu closes on Escape or a click outside it.
|
||||
* 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.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
@@ -29,6 +33,8 @@ vi.mock("../../lib/api", () => ({
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
install: vi.fn(),
|
||||
uninstall: vi.fn(),
|
||||
installs: vi.fn(),
|
||||
setInstallsEnabled: vi.fn(),
|
||||
skills: vi.fn(),
|
||||
addSkill: vi.fn(),
|
||||
@@ -58,6 +64,7 @@ const item = (over: Partial<Artifact>): Artifact => ({
|
||||
market: null,
|
||||
url: null,
|
||||
files: [{ path: "report.html", size: 10 }],
|
||||
inLibrary: true,
|
||||
size: 10,
|
||||
shared: false,
|
||||
sharedAt: null,
|
||||
@@ -208,9 +215,109 @@ describe("Artifacts", () => {
|
||||
installsEnabled: true,
|
||||
canManage: false,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
expect(await screen.findByRole("button", { name: "Hub" })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("button", { name: /^Share$/ }).length).toBe(2);
|
||||
// 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" }));
|
||||
await waitFor(() =>
|
||||
expect(api.artifacts.list).toHaveBeenLastCalledWith(expect.objectContaining({ scope: "hub" }))
|
||||
);
|
||||
});
|
||||
|
||||
it("lists who installed an artifact, when and where, from its install count", async () => {
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [item({ id: 2, kind: "skill", title: "deploy", source: "deploy", installs: 3 })],
|
||||
});
|
||||
vi.mocked(api.artifacts.installs).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
user: "vi",
|
||||
mine: true,
|
||||
at: "2026-10-19T10:00:00Z",
|
||||
replaced: false,
|
||||
paths: ["/h/.claude/skills/deploy"],
|
||||
},
|
||||
{ user: "khoa", mine: false, at: "2026-10-18T10:00:00Z", replaced: true },
|
||||
],
|
||||
unrecorded: 1,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await user.click(await screen.findByRole("button", { name: "3 installs" }));
|
||||
const box = await screen.findByTestId("artifact-installs");
|
||||
expect(api.artifacts.installs).toHaveBeenCalledWith(2);
|
||||
expect(await within(box).findByText("/h/.claude/skills/deploy")).toBeInTheDocument();
|
||||
expect(within(box).getByText(/khoa/)).toBeInTheDocument();
|
||||
expect(within(box).getByText(/1 earlier installs/)).toBeInTheDocument();
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.queryByTestId("artifact-installs")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a shared row in the Hub when its owner deletes it, and removes it from there on ask", async () => {
|
||||
vi.mocked(api.artifacts.settings).mockResolvedValue({
|
||||
serverMode: true,
|
||||
canShare: true,
|
||||
installsEnabled: true,
|
||||
canManage: false,
|
||||
});
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [item({ id: 3, title: "hub-only", shared: true, inLibrary: false })],
|
||||
});
|
||||
vi.mocked(api.artifacts.update).mockResolvedValue({ item: item({ id: 3 }) } as never);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
const card = await screen.findByTestId("artifact-3");
|
||||
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 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("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 })],
|
||||
});
|
||||
open();
|
||||
const card = await screen.findByTestId("artifact-2");
|
||||
expect(within(card).getByTestId("artifact-installed")).toHaveTextContent("Installed");
|
||||
expect(within(card).queryByRole("button", { name: "Install" })).toBeNull();
|
||||
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
|
||||
vi.mocked(api.artifacts.install).mockResolvedValueOnce({
|
||||
installed: "/h/.claude/skills/deploy",
|
||||
});
|
||||
await userEvent.setup().click(within(card).getByRole("button", { name: "Reinstall" }));
|
||||
await waitFor(() => expect(api.artifacts.install).toHaveBeenLastCalledWith(2, true));
|
||||
});
|
||||
|
||||
it("uninstalls an installed plugin only after a confirmation", async () => {
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [
|
||||
item({ id: 4, kind: "plugin", title: "tool", source: "tool@market", installed: true }),
|
||||
],
|
||||
});
|
||||
vi.mocked(api.artifacts.uninstall).mockReset();
|
||||
open();
|
||||
const card = await screen.findByTestId("artifact-4");
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
|
||||
await user.click(within(card).getByRole("button", { name: "Uninstall" }));
|
||||
await waitFor(() => expect(confirmDialog).toHaveBeenCalled());
|
||||
expect(api.artifacts.uninstall).not.toHaveBeenCalled();
|
||||
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
|
||||
vi.mocked(api.artifacts.uninstall).mockResolvedValueOnce({
|
||||
uninstalled: "tool@market",
|
||||
paths: [],
|
||||
});
|
||||
await user.click(within(card).getByRole("button", { name: "Uninstall" }));
|
||||
await waitFor(() => expect(api.artifacts.uninstall).toHaveBeenCalledWith(4));
|
||||
});
|
||||
|
||||
it("installs a skill only after a confirmation, and asks before replacing", async () => {
|
||||
@@ -234,13 +341,79 @@ describe("Artifacts", () => {
|
||||
it("previews a file in a sandboxed iframe that may run nothing", async () => {
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("report.html");
|
||||
await user.click(screen.getAllByRole("button", { name: "Preview" })[0]!);
|
||||
const card = await screen.findByTestId("artifact-1");
|
||||
await user.click(within(card).getByRole("button", { name: "Preview" }));
|
||||
const frame = await screen.findByTestId("artifact-preview");
|
||||
expect(frame.getAttribute("sandbox")).toBe("");
|
||||
expect(frame.getAttribute("src")).toBe("/api/artifacts/1/raw?path=report.html");
|
||||
});
|
||||
|
||||
it("renders a skill's SKILL.md itself instead of an iframe", async () => {
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [
|
||||
item({
|
||||
kind: "skill",
|
||||
title: "standup",
|
||||
files: [
|
||||
{ path: "SKILL.md", size: 10 },
|
||||
{ path: "notes.txt", size: 5 },
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
const fetchMock = vi.fn(async (url: string) =>
|
||||
url.includes("SKILL.md")
|
||||
? new Response("---\nname: standup\n---\n# Daily standup\n\nSummarise.")
|
||||
: new Response("plain notes")
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("standup");
|
||||
await user.click(screen.getByRole("button", { name: "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();
|
||||
await user.click(screen.getByRole("button", { name: "notes.txt" }));
|
||||
expect(await screen.findByText("plain notes")).toBeInTheDocument();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("edits a skill's name, description and instructions from its SKILL.md", async () => {
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [
|
||||
item({
|
||||
kind: "skill",
|
||||
title: "standup",
|
||||
source: "standup",
|
||||
files: [{ path: "SKILL.md", size: 10 }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
vi.mocked(api.artifacts.update).mockResolvedValue({ item: item({}) } as never);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("---\nname: standup\ndescription: Old\n---\n\nOld body"))
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("standup");
|
||||
await user.click(screen.getByRole("button", { name: "Edit" }));
|
||||
const name = await screen.findByLabelText(/Skill name/);
|
||||
expect(name).toHaveValue("standup");
|
||||
expect(screen.getByLabelText(/Instructions/)).toHaveValue("Old body");
|
||||
await user.clear(screen.getByLabelText(/Description/));
|
||||
await user.type(screen.getByLabelText(/Description/), "New");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() =>
|
||||
expect(api.artifacts.update).toHaveBeenCalledWith(1, {
|
||||
title: "standup",
|
||||
skill: { name: "standup", description: "New", content: "Old body" },
|
||||
})
|
||||
);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("opens a card's actions on right-click", async () => {
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
@@ -278,4 +451,22 @@ describe("Artifacts", () => {
|
||||
await user.click(screen.getByText("report.html"));
|
||||
expect(screen.queryByRole("button", { name: "Skill" })).toBeNull();
|
||||
});
|
||||
|
||||
it("shelves the front page by kind, and a card opens its detail sheet", async () => {
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("deploy");
|
||||
const shelves = screen.getAllByRole("heading", { level: 2 }).map((h) => h.textContent);
|
||||
expect(shelves).toEqual(["Skills1", "Files1"]);
|
||||
await user.click(screen.getAllByRole("button", { name: "See all" })[0]!);
|
||||
await waitFor(() =>
|
||||
expect(api.artifacts.list).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ kind: "skill" })
|
||||
)
|
||||
);
|
||||
await user.click(screen.getByText("deploy"));
|
||||
const sheet = await screen.findByTestId("artifact-detail");
|
||||
expect(within(sheet).getByText("/deploy")).toBeInTheDocument();
|
||||
expect(within(sheet).getByRole("button", { name: "Install" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7239,7 +7239,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.8.12 (rebuild client to match)
|
||||
UI build v2.8.14 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+41
-10
@@ -241,6 +241,11 @@ already open, `500 EDIALOG` when the program failed. The dialog is closed when
|
||||
the request goes away (a closed tab, an SSH tunnel's browser leaving). A
|
||||
**Browse** button in the UI (`client/src/components/PathPickButton.tsx`) falls
|
||||
back on ANY of those to the in-app browser `GET /api/lanes/browse?path=<abs>`.
|
||||
A prompt box's `+` menu offers **Choose a file on this computer** (`kind:
|
||||
"file"`, `start` = the box's folder) to a browser on a loopback host only; the
|
||||
chosen file is attached as an `@path` mention by its own path — any size,
|
||||
nothing uploaded. A toast says the dialog is open (it may open behind the
|
||||
browser); a failure is a toast too, with no in-app fallback.
|
||||
A file field asks it with `files=1`: entries then include the folder's files
|
||||
(`file: true`, listed after the folders), and `path` opens the way the native
|
||||
picker's `start` does (`~` expanded, a file's folder, home when missing) —
|
||||
@@ -275,6 +280,8 @@ GET /api/lanes/:id/fs/trash
|
||||
POST /api/lanes/:id/fs/trash/restore { id } → { path, laneId }
|
||||
POST /api/lanes/:id/fs/trash/purge { id | "all" } → { purged }
|
||||
POST /api/lanes/:id/fs/upload?path=&conflict= raw body, streamed → 201 { path } | 200 { skipped: true }
|
||||
POST /api/lanes/:id/fs/upload-check { dir?, paths } → { taken: [paths], batch }
|
||||
POST /api/lanes/:id/fs/upload-batch?dir=&conflict= length + JSON list + bytes → 201 { saved, skipped, paths }
|
||||
GET /api/lanes/:id/fs/zip?p=<path>&p=… application/zip, streamed
|
||||
```
|
||||
|
||||
@@ -295,6 +302,24 @@ Under S3 a move/copy/delete needs the installed `write-as` to know
|
||||
`move`/`copy`: `409 EUPGRADE` until `agenthub server install-system` is
|
||||
re-run.
|
||||
|
||||
Many small files cost per FILE (a request, a path walk, under S3 a sudo
|
||||
`write-as` each), so the Files page sends files up to 4 MB in **batches**
|
||||
(up to 1,000 files / 32 MB per request; bigger files still go one by one
|
||||
through `upload`). A batch body is a 4-byte big-endian length, that many
|
||||
bytes of JSON `{files: [{path, size, mtime?}]}` (paths relative to `?dir=`),
|
||||
then each file's bytes in that order — at most 5,000 files, 16 MB each,
|
||||
256 MB in all (`413 ETOOBIG`), `400 EBADBODY` when malformed or short. Every
|
||||
name passes `upload`'s guards and the whole batch is planned (folders,
|
||||
`conflict`) before the first write, so a taken name under `conflict=fail` is
|
||||
ONE `409 EEXISTS` naming them all; `upload-check` asks the same question
|
||||
before the bytes are sent. A member's batch is written by ONE
|
||||
`write-as <dir> extract` from a tar the server writes itself (folders and
|
||||
regular files only, `tar -k`: never over an existing file) — `409 EUPGRADE`
|
||||
(and `batch: false` from `upload-check`) until `agenthub server
|
||||
install-system` is re-run, and the page then falls back to one request per
|
||||
file. Measured on loopback (host folder): 1,000 files of 2 KB took 4.3 s one
|
||||
by one, 0.5 s as one batch.
|
||||
|
||||
#### File Explorer on a home
|
||||
|
||||
```
|
||||
@@ -362,7 +387,9 @@ Add workspace → **Clone**. Clones are queued machine-wide
|
||||
(`server/lib/clone-queue.js`): at most `AGENTHUB_CLONE_CONCURRENCY` run at
|
||||
once (default half the CPU cores, at least 1), the rest wait in arrival order
|
||||
with the request held open. Each runs under `nice -n 10` / `ionice -c2 -n7`
|
||||
with `pack.threads` = its share of the cores (not persisted), and is killed
|
||||
with `pack.threads` and `checkout.workers` = its share of the cores (not
|
||||
persisted; parallel checkout writes a tree of many small files ~4× faster on
|
||||
4 cores), and is killed
|
||||
after 30 minutes of running — so several ~1 GB clones no longer starve the
|
||||
dashboard and the panes. `partial: true` (the form's **Partial clone** box;
|
||||
`400 EBADFIELD` when not a boolean) makes it blobless (`--filter=blob:none`):
|
||||
@@ -2760,7 +2787,7 @@ POST /api/cc-config/mcp/add Body: { name, scope, transport?, url?,
|
||||
POST /api/cc-config/mcp/remove Body: { name, scope? }
|
||||
```
|
||||
|
||||
The list reads take an optional `cwd` naming the project whose `.claude/` is read (default: the server's own cwd); the run console's `/` autocomplete passes the directory the run will start in, so a workspace's own project commands and skills are the ones offered. In server mode a non-admin's `cwd` (on `/api/cc-config/*` and `/api/codex-config/*`) must lie inside one of their own workspaces, else `403 EFOLDER`. `GET /api/cc-config/file` and `GET /api/codex-config/file` never serve a credential file (`.credentials.json`, `auth.json`, any non-Markdown name matching the AI Tools secret pattern, anything under `<CLAUDE_HOME>/agenthub-*`): `400 READ_DENIED`.
|
||||
The list reads take an optional `cwd` naming the project whose `.claude/` is read (default: the server's own cwd); the run console's `/` autocomplete passes the directory the run will start in, so a workspace's own project commands and skills are the ones offered. `skills`, `commands` and `plugins` read their USER scope from the account the pane will run as: under S3 the `cwd` folder's account (a member's `~/.claude`, where a Hub install puts a skill), a non-admin with no `cwd` their own, an admin otherwise the host's Claude home, and a non-admin with no account of their own NOTHING (never the host's or another member's) — so a skill a developer installed shows up in their `/` list, and an admin's install into the host's home no longer shows up in a member's. In server mode a non-admin's `cwd` (on `/api/cc-config/*` and `/api/codex-config/*`) must lie inside one of their own workspaces, else `403 EFOLDER`. `GET /api/cc-config/file` and `GET /api/codex-config/file` never serve a credential file (`.credentials.json`, `auth.json`, any non-Markdown name matching the AI Tools secret pattern, anything under `<CLAUDE_HOME>/agenthub-*`): `400 READ_DENIED`.
|
||||
|
||||
`scope` is `"user"`, `"project"`, or `"auto-memory"`. `type` is one of `skills`, `agents`, `commands`, `output-styles`, `memory`, `auto-memory`. `name` is required for everything except `memory` (which is `CLAUDE.md` itself). On `PUT`, `name` is validated against `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$` (for `auto-memory` it must instead be a flat `*.md` filename). Settings are returned with secret-like keys (matching `/token|secret|password|api[_-]?key|auth/i`) replaced by `"<redacted>"`.
|
||||
|
||||
@@ -2857,8 +2884,9 @@ GET /api/run/history?limit=&laneId=&tool= Persisted run history; laneId narr
|
||||
GET /api/run/interrupted {items} — sessions whose pane died under them (see below), newest per session, max 50
|
||||
POST /api/run/interrupted/dismiss {sessionIds: string[]} (≤ 100) → {dismissed}: stop offering them; resumes nothing. 400 EBADFIELD otherwise
|
||||
GET /api/run/files?cwd=&q=&dirs=1 Fuzzy file search inside cwd for the @-file autocomplete (dirs=1 adds folders as `dir/`, for the + menu)
|
||||
POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <data dir>/drops/ (0700; S3: the member's ~/.agenthub/handoff/drops/) with a random name, pruned after a day; the terminal pastes the path
|
||||
POST /api/run/drop-file?name=&laneId= Any raw file body (≤20 MB) → {path}: same dir (S3: the handoff dir of the account workspace `laneId`'s panes run as, else the requester's), 0600, named <random>-<name reduced to a [A-Za-z0-9._-] basename>; what the terminal (drop or paste, Ctrl+V included), the prompt box and its paste use for every dropped file or image
|
||||
POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <data dir>/drops/ (0700; S3: the member's ~/.agenthub/handoff/drops/) with a random name, pruned after a day; the terminal pastes the path (a `claude` pane: an image as its bare path, each in a paste of its own, which Claude Code shows as `[Image #n]`; any other file as an `@` mention)
|
||||
POST /api/run/drop-file?name=&laneId= Any raw file body (≤20 MB; ≤2 GB from a loopback browser outside server mode, streamed to disk — Chrome hands a dropped file no `file://` path) → {path}: same dir (S3: the handoff dir of the account workspace `laneId`'s panes run as, else the requester's), 0600, named <random>-<name reduced to a [A-Za-z0-9._-] basename>; what the terminal (drop or paste, Ctrl+V included), the prompt box and its paste use for every dropped file or image — except a browser on a loopback host dropping or pasting a file manager's files, whose `file://` paths are attached as they are, uploading nothing, once `POST /api/run/local-paths` finds the same files here. Both drop routes answer 413 `ETOOBIG` (with `max_bytes`, the limit the UI shows) to a `Content-Length` over their limit before reading the body, and close the connection; of several files only the refused ones are lost
|
||||
POST /api/run/local-paths {paths:[<absolute>…]} (1–100) → {sizes:[bytes|null…]}: each path's size when it is a regular file on this machine, else null. The composer and the terminal use a dropped file's own `file://` path only when every size matches the dropped bytes' — "localhost" in the address bar is no proof (an SSH tunnel's browser is on another machine). 403 EREMOTE in server mode (a pane runs as an account that cannot read the host's files) and to anything but a loopback socket without X-Forwarded-For/Forwarded; 400 EBADFIELD otherwise
|
||||
GET /api/run/drop/:name A dropped png/jpg/gif/webp, or txt/md/csv/tsv/log/json/xml/yaml as text/plain, served back (nosniff, sandbox CSP) from the requester's own drop dir (S3: their member account's, owner-checked — for an admin, then the host's, where their host-folder workspaces drop); only a name drop-image/drop-file made → 404 otherwise
|
||||
GET /api/run/:id/permission-mode A live claude pane's mode, read off its mode line → {mode} (null while a menu hides it)
|
||||
POST /api/run/:id/permission-mode {mode} → Shift+Tab steps, each read back, until the pane shows it; 409 EMODEUNKNOWN/EMODEUNAVAILABLE/EBUSY
|
||||
@@ -2905,7 +2933,7 @@ GET /api/ai-tools/:id/icon The tool's favicon, fetched once by the se
|
||||
|
||||
Each tool: `{ id, label, command, homepageUrl, installed, path, enabled, source: "builtin"|"custom", launcher, prompt: { mode }, resume: bool, bypass: bool, proxyCapable: bool }` — flags only, never the arg arrays. Each tool also carries `modelFlag` (the CLI takes a model: Claude, Codex, or a catalog `model: { flag, vendor? }` field) and `defaultModel` (what a new run starts with, `null` = the CLI's own; a `PUT` with `""`/`null` clears it, an id that is not a plain model name is `400 EBADMODEL`). The config files are only those a built-in or release catalog file lists in `config` (`~/`-relative, no `..`, no credential-looking name); a read or save answers `404 ENOFILE` (no such index) / `ENOENT` (missing — never created), `403 ESECRET` (the name, or a symlink target's, looks like a credential), `413 ETOOBIG` (> 1 MB), `415 EBINARY`, `400 EBADSYNTAX` (JSON/JSONC that does not parse), `409 ESTALE` (the file is no longer at `baseSha`); a save backs the old text up under `<data dir>/ai-tool-config-backups/<id>/` and is same-origin only. `GET /:id/models` reads each tool's own source: Codex's `$CODEX_HOME/models_cache.json` (listed entries by priority — nothing else from that file), Claude's aliases named after the newest priced version plus the priced pinned versions, a catalog tool's vendor-priced ids plus the models its own sessions used; `flag: false` and `[]` for a tool with no model flag, `404 ENOTOOL` for an unknown id. `default` is the effective default: the stored one if installed and enabled, else `claude` if installed and enabled, else the first installed enabled tool, else `null`. `PUT` answers `404 ENOTOOL` for an unknown id, `400 EBADDEFAULT` for a default that is not installed or disabled, `400 EBADFIELD` for a non-boolean `enabled`; disabling the default clears it. Writes are same-origin only. State lives in `<data dir>/ai-tools.json`. Each tool also carries `install` (`{kind:"npm", package}` or `{kind:"script", url}`, else null) and `update` (`{kind:"npm"}`, `{kind:"script"}`, `{kind:"self", args}`, else null), and the body carries `catalog: { source: "builtin"|"remote", tag, fetchedAt }` — `remote` while a copy of the newest release's catalog, newer than this build, is in use (synced daily, off with `AGENTHUB_AI_TOOLS_SYNC=0`).
|
||||
|
||||
**Install / update jobs.** Commands come only from built-in or release catalog files, never from the request or `DASHBOARD_AI_TOOLS_DIR`. npm is the login shell's (`npm install -g <package>@latest`); a script is downloaded by the server (https, same site as the tool's homepage, ≤ 1 MB) and run as `bash <file>`; `self` runs `<detected path> ...args`. One job server-wide; the job is `{ id, tool, action, status: "running"|"ok"|"failed", startedAt, endedAt, exitCode, log }` (last 256 KB, 10-minute timeout) and lives in memory. A successful install enables the tool. Errors: `404 ENOTOOL`; `400 ENOINSTALL`/`ENOUPDATE`/`ENOTHING`; `409 EINSTALLED`/`ENOTINSTALLED`/`ENOTNPM` (npm update of a tool whose binary is not under `npm prefix -g`)/`ENONPM`/`EBUSY`. Icons are the root `/favicon.ico`, raster only by magic bytes (never SVG), ≤ 256 KB, cached in `<data dir>/runtime/ai-tool-icons/`, served with `nosniff` and `Content-Security-Policy: sandbox`; an `<img>` passes the dashboard token as `?token=`. The client shows its own shipped mark instead where it has one (`client/public/ai-tools/<id>.svg` — claude, codex, openclaw, and hermes as a `-black`/`-white` pair swapped by the theme; `BUNDLED` in `client/src/components/AiToolIcon.tsx`), so this route is asked only for the other tools.
|
||||
**Install / update jobs.** Commands come only from built-in or release catalog files, never from the request or `DASHBOARD_AI_TOOLS_DIR`. npm is the login shell's (`npm install -g <package>@latest`, plus `--prefix ~/.local` when npm's global prefix is not writable by the server's account — a system-wide Node; a tool under `~/.local/lib/node_modules` is updated the same way); a script is downloaded by the server (https, same site as the tool's homepage, ≤ 1 MB) and run as `bash <file>`; `self` runs `<detected path> ...args`. One job server-wide; the job is `{ id, tool, action, status: "running"|"ok"|"failed", startedAt, endedAt, exitCode, log }` (last 256 KB, 10-minute timeout) and lives in memory. A successful install enables the tool. Errors: `404 ENOTOOL`; `400 ENOINSTALL`/`ENOUPDATE`/`ENOTHING`; `409 EINSTALLED`/`ENOTINSTALLED`/`ENOTNPM` (npm update of a tool whose binary is under neither `npm prefix -g` nor `~/.local/lib/node_modules`)/`ENONPM`/`EBUSY`. Icons are the root `/favicon.ico`, raster only by magic bytes (never SVG), ≤ 256 KB, cached in `<data dir>/runtime/ai-tool-icons/`, served with `nosniff` and `Content-Security-Policy: sandbox`; an `<img>` passes the dashboard token as `?token=`. The client shows its own shipped mark instead where it has one (`client/public/ai-tools/<id>.svg` — claude, codex, openclaw, and hermes as a `-black`/`-white` pair swapped by the theme; `BUNDLED` in `client/src/components/AiToolIcon.tsx`), so this route is asked only for the other tools.
|
||||
|
||||
### AI Proxy profiles
|
||||
|
||||
@@ -3004,7 +3032,7 @@ A snapshot freezes a session as it is at that moment into one immutable `session
|
||||
### Artifacts hub
|
||||
|
||||
```http
|
||||
GET /api/artifacts?scope=mine|hub&kind=page|file|skill|plugin&q= {items}
|
||||
GET /api/artifacts?scope=all|mine|hub&kind=page|file|skill|plugin&q= {items} — each with `installed` (default scope `all`)
|
||||
GET /api/artifacts/settings {serverMode, canShare, installsEnabled, canManage}
|
||||
PUT /api/artifacts/settings {installsEnabled: boolean} — admin (any caller in local mode); the UI is Settings → AI tools → Artifacts Hub
|
||||
GET /api/artifacts/candidates?laneId= {items: [{path, sessionId, at, exists, pinned}]}
|
||||
@@ -3018,10 +3046,12 @@ POST /api/artifacts/skill/new {name, description, content, titl
|
||||
POST /api/artifacts/plugin/inspect {market} → {market, name, description, plugins: [{name, key, description, version, category}]}
|
||||
POST /api/artifacts/plugin {key: "name@marketplace", market: "owner/repo" | git URL | marketplace.json URL, title?, description?}
|
||||
GET /api/artifacts/:id {item}
|
||||
PATCH /api/artifacts/:id {title?, description?, shared?, source?, market?} (source/market: plugin only)
|
||||
DELETE /api/artifacts/:id
|
||||
PATCH /api/artifacts/:id {title?, description?, shared?, source?, market?, skill?: {name, description, content}} (source/market: plugin only; skill: a skill's SKILL.md frontmatter name/description and body — written as a new snapshot, other files kept, row `source`/`description` follow)
|
||||
DELETE /api/artifacts/:id {ok, kept} — kept: a shared row of yours left your library but stays in the Hub
|
||||
GET /api/artifacts/:id/raw?path=&download=1 one snapshot file (sandbox CSP, nosniff)
|
||||
POST /api/artifacts/:id/install {replace?} → {installed, replaced?}
|
||||
POST /api/artifacts/:id/uninstall → {uninstalled, paths}
|
||||
GET /api/artifacts/:id/installs {items: [{user, mine, at, replaced, paths?}], unrecorded}
|
||||
```
|
||||
|
||||
What agents produced, per OWNER (`server/lib/artifacts.js`, plan: `docs/plans/artifacts/README.md`):
|
||||
@@ -3032,9 +3062,10 @@ What agents produced, per OWNER (`server/lib/artifacts.js`, plan: `docs/plans/ar
|
||||
- **file upload** — one file from the user's computer (≤ 25 MB, `413 ETOOBIG`), snapshotted under its own base name (no folder kept).
|
||||
- **skill upload / new** — a skill made from files rather than picked: one Markdown/text file of any name becomes `SKILL.md`; several files or a picked/dropped folder (`paths` keeps the layout) need a `SKILL.md` (at the top or in one folder, which is stripped — `400 ENOSKILLMD` otherwise); a single `.zip`/`.skill` is unpacked (sizes checked from its directory first, a symlink entry `ESYMLINK`, `__MACOSX`/`.DS_Store`/`.git`/`node_modules` skipped); an upload holding several skill folders makes one artifact per skill, named by its folder, and stores NOTHING if any of them fails. The `name` the user typed (only for a single skill; else the frontmatter `name:`, the folder or the file name) is made a folder name (`skillSlug`: marks dropped, other characters → `-`; `400 EBADNAME` if nothing is left) and written into `SKILL.md`'s frontmatter as `name:`; a `description` given replaces the frontmatter's, and a skill with none anywhere is `400 ENODESCRIPTION`. Paths are relative with no `..` (`EBADPATH`); the same limits and `ESECRET` as a picked skill. `new` writes `SKILL.md` from `content` the same way.
|
||||
- **plugin** — only the spec and its marketplace source (`EBADPLUGIN`, `EBADMARKET`: `owner/repo`, a `validateRemoteUrl` URL, or an https link to a `marketplace.json`); no files. A GitHub web link becomes `owner/repo`, and a forge's `…/src/…`, `…/tree/…`, `…/blob/…` suffix or trailing slash is trimmed. The same `key` added again by its owner updates that row. **inspect** reads the marketplace's `.claude-plugin/marketplace.json` to list its plugins: a git link is cloned shallow, blob-less and without checkout into a temp dir that is removed after, with every credential helper switched off and no prompt (a repository that needs a login is `502 ENEEDLOGIN`; type the key by hand — installing then uses each user's own git login), https only (an ssh link is `400 ESSHMARKET`); a repo with only a `plugin.json` is `400 ENOTMARKET`; unreachable `502 EMARKETFETCH`. It reaches any https host a signed-in user names, like `publish` does — no login is ever sent with it.
|
||||
- **Visibility** — local mode: every row, no sharing (`400 ELOCALMODE`). Server mode: a developer sees their own rows and shared ones, admin/analyst every row; only the owner or an admin changes or deletes one (`403 EFORBIDDEN`). `shared` puts a row in every user's **Hub**.
|
||||
- **Visibility** — local mode: every row, no sharing (`400 ELOCALMODE`). Server mode: a developer sees their own rows and shared ones, admin/analyst every row; only the owner or an admin changes or deletes one (`403 EFORBIDDEN`). `shared` puts a row in every user's **Hub**. The page shows one list (`scope=all`: own rows plus shared ones) with an **All · My library · Hub** filter. Deleting a SHARED row of your own only takes it out of your library (`in_library = 0`, `inLibrary: false`, `{kept: true}`; it leaves `scope=mine` and shows as **Hub only**); unsharing such a row then deletes it (`PATCH` answers `{item, removed: true}`). Deleting an unshared row, or an admin deleting someone else's, deletes it outright.
|
||||
- **raw** — only a file the snapshot recorded (`404 ENOFILE` otherwise), never a request path; served with `Content-Security-Policy: …; sandbox` and `nosniff`, unknown types as an attachment.
|
||||
- **install** — a skill or plugin (`EBADKIND`), into the CALLER's own account: a skill is written to `~/.claude/skills/<name>/` (as the member through `write-as` under S3); a taken name is `409 EEXISTS` unless `replace: true`, which moves the old one to `~/.claude/agenthub-replaced-skills/<name>-<time>/`. A plugin runs `claude plugin marketplace add <market>` then `claude plugin install --scope user <key>` as the caller, never with `-y`. `403 EINSTALLSOFF` while an admin has turned installs off. Share, unshare, a shared row's delete and every install by someone else are written to `audit_log`.
|
||||
- **install** — a skill or plugin (`EBADKIND`), into the CALLER's own account: a skill is written to `~/.claude/skills/<name>/` AND `~/.agents/skills/<name>/` (the folder Codex, Gemini/Antigravity, OpenCode and Cursor read) of every account the caller's sessions run as — under S3 their member home (as them through `write-as`), plus the host's home for an admin, whose host-folder workspaces run as the host. The answer is `{installed, paths, replaced}` (`installed` = the paths joined). A name taken in any of them is `409 EEXISTS` unless `replace: true`, which moves each old one to `~/.claude/agenthub-replaced-skills/<name>[-agents]-<time>/`. A plugin runs `claude plugin marketplace add <market>` then `claude plugin install --scope user <key>` as the caller, never with `-y`. `403 EINSTALLSOFF` while an admin has turned installs off. A list row's `installed` is true when that skill (`~/.claude/skills/<name>`) or plugin (a key of `~/.claude/plugins/installed_plugins.json`) is already in EVERY one of those accounts — the page then shows **Installed** instead of Install; it is a pure read, and an account that cannot be resolved or read counts as not installed. Every install is also a row of `artifact_installs` (who, when, the folders written, whether it replaced); `GET …/installs` lists them newest first — the owner, staff and local mode see every install, anyone else only their own, and `paths` goes only to the installer and an admin (a plugin has none: it is `claude plugin install --scope user`). `unrecorded` is what `installs` counted before that table existed. The card's "N installs" opens this list. Share, unshare, a shared row's delete and every install or uninstall by someone else are written to `audit_log`.
|
||||
- **uninstall** — takes an installed skill or plugin out of the same accounts again (`EBADKIND` otherwise; allowed while installs are off). A skill's folders that exist (both skills folders of each account) are MOVED to `~/.claude/agenthub-replaced-skills/<name>[-agents]-uninstalled-<time>/`, never deleted; a plugin runs `claude plugin uninstall --scope user --keep-data <key>` as the caller (its data dir stays). Nothing of it there is `409 ENOTINSTALLED`. The answer is `{uninstalled, paths}` (`paths` = the skill folders moved; empty for a plugin). The `installs` count and `artifact_installs` history are left as they were. The page shows **Uninstall** (card icon, ⋯ menu, detail sheet) beside **Installed**.
|
||||
- Every write is same-origin. The `artifact_changed` broadcast (`{id, ownerId, shared}`) reaches everyone for a shared row, else its owner and staff.
|
||||
|
||||
### Query Explorer
|
||||
|
||||
+1
-1
@@ -213,7 +213,7 @@ CREATE TABLE sessions (
|
||||
| `awaiting_reason` | TEXT | YES | Why the row is waiting: `notification`, `stop`, `session_start`, or `interrupted`. Set/cleared in lock-step with `awaiting_input_since` (SessionStart→`session_start`, Stop→`stop`, permission/input Notification→`notification`, watchdog/Esc recovery→`interrupted`). NULL otherwise. Exception: a `compact`-source SessionStart preserves the existing value (neither stamps `session_start` nor clears it) |
|
||||
| `transcript_path` | TEXT | YES | Absolute path to the session's JSONL transcript. Written by `routes/hooks.js` on the first event that carries it (subsequent events no-op via a SQL guard) and read by the periodic compaction sweep — so the sweep touches only active session rows instead of scanning the entire `events` table for `json_extract(data,'$.transcript_path')`. Backfilled once from `events` by the `db.js` migration |
|
||||
| `source` | TEXT | NO | Data source this session was captured from. `'local'` for this machine's own Claude Code history (the default); otherwise the `remote_sources.id` of the remote SSH machine it was pulled from, or `push:<name>` for a session another machine pushed to `POST /api/hooks/ingest-batch` (a pushed hook may only extend a session its own source created). Powers the `sources` query filter on `/api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, and `/api/analytics`, and the `sources` facet on `/api/sessions/facets`. Indexed by `idx_sessions_source` |
|
||||
| `tool` | TEXT | NO | The AI coding CLI that produced the session. `'claude'` for every hook-created and imported row (the default); `'codex'` for a session ingested from Codex's rollout journals by `server/lib/codex-usage.js` (see `codex_rollouts`) or created by a Codex hook (`tool: "codex"`, `server/lib/codex-hooks.js`) — a Codex session keeps the status its hooks give it while they are current (its newest event at or after its newest journal write minus two minutes); once they stop (removed, untrusted) the journal decides again. A codex row has no `transcript_path`, and, going by the journal, its `status` is `active` only while one of its journals was written in the last two minutes. `'antigravity'` for an agy CLI conversation, ingested by `server/lib/antigravity-usage.js` (see `antigravity_conversations`) or created by an Antigravity hook (`server/lib/antigravity-hooks.js`); no `transcript_path`, and `active` until it has been quiet — no write to its conversation file (or a non-empty `-wal`) or transcript, no hook event — for ten minutes when it has hook events, two minutes when it has none; it then settles to `completed`, its agents too. `'cursor'` for a Cursor agent transcript, ingested by `server/lib/cursor-usage.js` (see `cursor_transcripts`): no `transcript_path`, no `model` and no `token_usage` (the transcripts record none), `active` while its transcript was written in the last two minutes, else `completed`. The startup cleanup of stale `active` sessions and a workspace purge touch only the matching tool's rows (a purge takes the sessions of the workspace's own `tool`). The Workspace's attach/resume list offers `'claude'` rows only |
|
||||
| `tool` | TEXT | NO | The AI coding CLI that produced the session. `'claude'` for every hook-created and imported row (the default); `'codex'` for a session ingested from Codex's rollout journals by `server/lib/codex-usage.js` (see `codex_rollouts`) or created by a Codex hook (`tool: "codex"`, `server/lib/codex-hooks.js`) — a Codex session keeps the status its hooks give it while they are current (its newest event at or after its newest journal write minus two minutes); once they stop (removed, untrusted) the journal decides again. A codex row has no `transcript_path`, and, going by the journal, its `status` is `active` only while one of its journals was written in the last two minutes. `'antigravity'` for an agy CLI conversation, ingested by `server/lib/antigravity-usage.js` (see `antigravity_conversations`) or created by an Antigravity hook (`server/lib/antigravity-hooks.js`); no `transcript_path`, and `active` until it has been quiet — no write to its conversation file (or a non-empty `-wal`) or transcript, no hook event — for ten minutes when it has hook events, two minutes when it has none; it then settles to `completed`, its agents too. `'cursor'` for a Cursor agent transcript, ingested by `server/lib/cursor-usage.js` (see `cursor_transcripts`): no `transcript_path`, no `model` and no `token_usage` (the transcripts record none); its conversation is read from the file `cursor_transcripts` recorded for it, `active` while its transcript was written in the last two minutes, else `completed`. The startup cleanup of stale `active` sessions and a workspace purge touch only the matching tool's rows (a purge takes the sessions of the workspace's own `tool`). The Workspace's attach/resume list offers `'claude'` rows only |
|
||||
| `lane_id` | INTEGER | YES | The workspace that OWNS the session — the only sessions a workspace lists: the lane whose dashboard pane held it, stamped by `bindRunSession` (`server/routes/hooks.js`) from the hook's `run_id` — first writer wins, a nested `claude -p` excluded — or, for a Codex/Antigravity session, by `claimToolSessions` (`server/lib/dashboard-runs.js`) from the same-tool run that was live in its folder when it started. NULL for a session no workspace started (a terminal, an import, a tool with no hooks). No FK: removing a workspace leaves its sessions. Backfilled once from `dashboard_runs`. Indexed by `idx_sessions_lane` |
|
||||
| `archived_at` | TEXT | YES | When the user archived the session out of its workspace's lists (`PATCH /api/sessions/:id {archived}`). Hides only — nothing is deleted |
|
||||
| `pinned_at` | TEXT | YES | When the user pinned the session to the top of its workspace's sidebar list (`PATCH /api/sessions/:id {pinned}`; `GET /api/sessions?pinned_first=1` orders by it). Order only; NULL = not pinned |
|
||||
|
||||
+6
-3
File diff suppressed because one or more lines are too long
+6
-2
@@ -63,7 +63,10 @@ The proxy has to:
|
||||
- pass the original `Host` (the dashboard checks it against
|
||||
`DASHBOARD_ALLOWED_HOSTS` and compares it with the browser's `Origin`);
|
||||
- forward WebSocket upgrades (`/ws` for live updates, `/ws-pty/*` for terminals);
|
||||
- set `X-Forwarded-For` and `X-Forwarded-Proto`.
|
||||
- set `X-Forwarded-For` and `X-Forwarded-Proto`;
|
||||
- accept request bodies as large as AgentHub's own limits (Files uploads up to
|
||||
500 MB, prompt drops 20 MB). nginx's default `client_max_body_size` is 1 MB,
|
||||
and its own 413 is an HTML page the UI can only report as a failed upload.
|
||||
|
||||
nginx:
|
||||
|
||||
@@ -83,6 +86,7 @@ server {
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 1d;
|
||||
client_max_body_size 500m;
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -594,7 +598,7 @@ The pieces, and what each is for (the install steps are in the
|
||||
| `agenthub.service` | A SYSTEM unit, `User=<host>`, with `SupplementaryGroups=agenthub agenthub-proc` and `AmbientCapabilities=CAP_DAC_READ_SEARCH`: the dashboard can read every member's tool files (Claude writes transcripts `0600`), but cannot write them. Every program it starts has the capability removed first (`setpriv`), so host panes, git and npm do not inherit it. It reads node and the plugin root from `<data dir>/runtime/agenthub.system.env`, a file the host owns, so a plugin update repoints it without root |
|
||||
| `/etc/sudoers.d/agenthub` | The host may run only `tmux`, `git`, `gh` (by the absolute paths recorded in `/etc/agenthub/server.conf`), `/opt/agenthub/libexec/tmux-member`, `/opt/agenthub/libexec/write-as` and `/opt/agenthub/tools/bin/claude` (the [plugin baseline](#plugin-baseline-opt-in)) AS a member, plus, as root, only `/opt/agenthub/sbin/agenthub-provision` and `systemctl start/stop/restart/enable/disable agenthub.service`. Members are never in sudoers |
|
||||
| `/etc/agenthub/server.conf` | Root's, `0644`: the host account and uid, the users root, the port, and the `tmux`/`git`/`gh` paths sudoers lists. Its presence turns S3 on |
|
||||
| `write-as`, `pane-launch`, `tmux-member` | `/opt/agenthub/libexec/`, root's. `tmux-member -S <socket> …` runs a member's tmux call with their running server's own binary (else `TMUX_BIN`). `write-as <path> [append\|replace\|private\|mkdir\|remove]` runs AS a member and does the one change, content on stdin. `pane-launch <file>` starts a member's pane from its launch file |
|
||||
| `write-as`, `pane-launch`, `tmux-member` | `/opt/agenthub/libexec/`, root's. `tmux-member -S <socket> …` runs a member's tmux call with their running server's own binary (else `TMUX_BIN`). `write-as <path> [append\|replace\|private\|mkdir\|remove\|extract]` runs AS a member and does the one change, content on stdin (`extract`: a tar the dashboard wrote, unpacked with `tar -k` — the Files page's batch upload). `pane-launch <file>` starts a member's pane from its launch file |
|
||||
| `agenthub-provision` | The one root command besides `systemctl` on this unit: `create`, `disable` or `enable` `ah-<name>`. It refuses an account that exists outside `agenthub-members` and never deletes anything |
|
||||
| `/etc/claude-code/managed-settings.json` | Members' Claude Code hooks, through `/opt/agenthub/libexec/member-hook`, which does nothing for the host account (its plugin reports). A foreign file there is left alone |
|
||||
| `hidepid=invisible` | `/proc` (now and in `/etc/fstab`): an account sees only its own processes, so no member can read another's command line. A token passed with `tmux -e` is in one |
|
||||
|
||||
+183
-1
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 2.8.12
|
||||
version: 2.8.13
|
||||
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ĩ
|
||||
@@ -466,6 +466,10 @@ components:
|
||||
subagent_type:
|
||||
type: string
|
||||
nullable: true
|
||||
tool_use_id:
|
||||
type: string
|
||||
nullable: true
|
||||
description: The Agent/Task tool_use that launched this subagent, from its meta file (Claude Code subagents only).
|
||||
has_transcript:
|
||||
type: boolean
|
||||
db_agent_id:
|
||||
@@ -15912,6 +15916,94 @@ paths:
|
||||
responses:
|
||||
'201':
|
||||
description: Done.
|
||||
/api/lanes/{id}/fs/upload-check:
|
||||
post:
|
||||
tags:
|
||||
- Files
|
||||
summary: 'Before a batch: the names already taken, and whether a batch can go there'
|
||||
description: 'On a workspace (`?session=` / `?source=` pick the session worktree / extra folder, like the git reads). `taken`: which of `paths` (relative to `dir`) exist; `batch: false` = send the files one by one (a member''s folder whose `write-as` predates `extract`). 400 EBADPATH/EBADNAME, 403 ESECRET/EFOREIGN/EREADONLY (another user''s home)/EBADORIGIN, 404 ENOFILE/ENOHOME, 409 EEXISTS (with `error.names`)/EUPGRADE/EMEMBERGONE, 413 ETOOBIG.'
|
||||
operationId: laneFsUploadCheck
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- paths
|
||||
properties:
|
||||
dir:
|
||||
type: string
|
||||
default: ''
|
||||
paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: '`{taken, batch}`'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
taken:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
batch:
|
||||
type: boolean
|
||||
/api/lanes/{id}/fs/upload-batch:
|
||||
post:
|
||||
tags:
|
||||
- Files
|
||||
summary: Upload many small files in one request (≤ 5,000 files, 16 MB each, 256 MB in all)
|
||||
description: 'On a workspace (`?session=` / `?source=` pick the session worktree / extra folder, like the git reads). Body: a 4-byte big-endian length, that many bytes of JSON `{files: [{path, size, mtime?}]}` (paths relative to `dir`), then each file''s bytes in that order. Planned whole before the first write — a taken name under `conflict=fail` is ONE 409 naming them all; a member''s batch is one `write-as extract` (409 EUPGRADE until `agenthub server install-system` is re-run). 400 EBADBODY for a malformed or short body. 400 EBADPATH/EBADNAME, 403 ESECRET/EFOREIGN/EREADONLY (another user''s home)/EBADORIGIN, 404 ENOFILE/ENOHOME, 409 EEXISTS (with `error.names`)/EUPGRADE/EMEMBERGONE, 413 ETOOBIG.'
|
||||
operationId: laneFsUploadBatch
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: dir
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
default: ''
|
||||
- name: conflict
|
||||
in: query
|
||||
schema:
|
||||
$ref: '#/components/schemas/FsConflict'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
responses:
|
||||
'201':
|
||||
description: '`{saved, skipped, paths}`'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
saved:
|
||||
type: integer
|
||||
skipped:
|
||||
type: integer
|
||||
paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
/api/lanes/{id}/fs/zip:
|
||||
get:
|
||||
tags:
|
||||
@@ -16265,6 +16357,96 @@ paths:
|
||||
responses:
|
||||
'201':
|
||||
description: Done.
|
||||
/api/homes/{who}/fs/upload-check:
|
||||
post:
|
||||
tags:
|
||||
- Files
|
||||
summary: 'Before a batch: the names already taken, and whether a batch can go there'
|
||||
description: 'On a user''s home. Every dot entry at the top of the home is hidden and refused. `taken`: which of `paths` (relative to `dir`) exist; `batch: false` = send the files one by one (a member''s folder whose `write-as` predates `extract`). 400 EBADPATH/EBADNAME, 403 ESECRET/EFOREIGN/EREADONLY (another user''s home)/EBADORIGIN, 404 ENOFILE/ENOHOME, 409 EEXISTS (with `error.names`)/EUPGRADE/EMEMBERGONE, 413 ETOOBIG.'
|
||||
operationId: homeFsUploadCheck
|
||||
parameters:
|
||||
- name: who
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: '`me`, or another user''s name (an admin''s read-only view).'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- paths
|
||||
properties:
|
||||
dir:
|
||||
type: string
|
||||
default: ''
|
||||
paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: '`{taken, batch}`'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
taken:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
batch:
|
||||
type: boolean
|
||||
/api/homes/{who}/fs/upload-batch:
|
||||
post:
|
||||
tags:
|
||||
- Files
|
||||
summary: Upload many small files in one request (≤ 5,000 files, 16 MB each, 256 MB in all)
|
||||
description: 'On a user''s home. Every dot entry at the top of the home is hidden and refused. Body: a 4-byte big-endian length, that many bytes of JSON `{files: [{path, size, mtime?}]}` (paths relative to `dir`), then each file''s bytes in that order. Planned whole before the first write — a taken name under `conflict=fail` is ONE 409 naming them all; a member''s batch is one `write-as extract` (409 EUPGRADE until `agenthub server install-system` is re-run). 400 EBADBODY for a malformed or short body. 400 EBADPATH/EBADNAME, 403 ESECRET/EFOREIGN/EREADONLY (another user''s home)/EBADORIGIN, 404 ENOFILE/ENOHOME, 409 EEXISTS (with `error.names`)/EUPGRADE/EMEMBERGONE, 413 ETOOBIG.'
|
||||
operationId: homeFsUploadBatch
|
||||
parameters:
|
||||
- name: who
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: '`me`, or another user''s name (an admin''s read-only view).'
|
||||
- name: dir
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
default: ''
|
||||
- name: conflict
|
||||
in: query
|
||||
schema:
|
||||
$ref: '#/components/schemas/FsConflict'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
responses:
|
||||
'201':
|
||||
description: '`{saved, skipped, paths}`'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
saved:
|
||||
type: integer
|
||||
skipped:
|
||||
type: integer
|
||||
paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
/api/homes/{who}/fs/zip:
|
||||
get:
|
||||
tags:
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.12",
|
||||
"version": "2.8.14",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.8.12",
|
||||
"version": "2.8.14",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.12",
|
||||
"version": "2.8.14",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -21,10 +21,13 @@
|
||||
# copy the path, recursively, to the 3rd argument (symlinks copied as
|
||||
# links, never followed; mode and times kept; never onto an
|
||||
# existing name)
|
||||
# extract a tar on stdin unpacked into the directory (the dashboard's
|
||||
# batch upload, a tar it wrote itself: folders and regular files
|
||||
# only); an existing file is never replaced (`-k` fails instead)
|
||||
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
set -eu
|
||||
usage() {
|
||||
echo "usage: write-as <absolute path> [append|replace|private|mkdir|remove] | <path> move|copy <absolute dest>" >&2
|
||||
echo "usage: write-as <absolute path> [append|replace|private|mkdir|remove|extract] | <path> move|copy <absolute dest>" >&2
|
||||
exit 2
|
||||
}
|
||||
case "${2:-}" in
|
||||
@@ -48,6 +51,7 @@ case "${2:-}" in
|
||||
private) swap "$1" 0600 ;;
|
||||
mkdir) exec mkdir -p -m 0755 -- "$1" ;;
|
||||
remove) exec rm -rf -- "$1" ;;
|
||||
extract) exec tar -x -k -p --no-same-owner -f - -C "$1" ;;
|
||||
move)
|
||||
[ -e "$3" ] || [ -L "$3" ] && { echo "write-as: $3 exists" >&2; exit 1; }
|
||||
exec mv -T -- "$1" "$3" ;;
|
||||
|
||||
+10
-3
@@ -632,12 +632,19 @@ An `antigravity` workspace carries no pipeline either.
|
||||
per-conversation SQLite files (`~/.gemini/antigravity-cli/conversations/<id>.db`)
|
||||
every `DASHBOARD_ANTIGRAVITY_POLL_MS` ms (default 15000, `0` disables). They
|
||||
become `sessions` rows (`tool = 'antigravity'`) and `token_usage` rows, and
|
||||
`antigravity_conversations` holds the mtime read for each file.
|
||||
`antigravity_conversations` holds the mtime read for each file. The
|
||||
session's conversation (`GET /api/sessions/:id/transcript`) is read from its
|
||||
step log (`brain/<id>/.system_generated/logs/transcript_full.jsonl`,
|
||||
`transcriptPath`): prompts, the model's text, thinking, tool calls and
|
||||
tokens, and each tool's result tied to its call by step.
|
||||
- **Cursor sessions:** `server/lib/cursor-usage.js` reads Cursor's agent
|
||||
transcripts (`~/.cursor/projects/*/agent-transcripts/<id>/<id>.jsonl`) every
|
||||
`DASHBOARD_CURSOR_POLL_MS` ms (default 15000, `0` disables) into `sessions`
|
||||
rows (`tool = 'cursor'`) — no `token_usage`, the transcripts carry none —
|
||||
and `cursor_transcripts` holds the mtime read for each file.
|
||||
and `cursor_transcripts` holds the mtime read for each file. The session's
|
||||
conversation (`GET /api/sessions/:id/transcript`) is read from that recorded
|
||||
file: prompts, replies, thinking, tool calls and results, with no timestamps
|
||||
or model (Cursor writes none).
|
||||
- **Oh My Pi sessions:** `server/lib/omp-usage.js` reads omp's session journals
|
||||
(`~/.omp/agent/sessions/<encoded cwd>/<timestamp>_<id>.jsonl`, plus every
|
||||
subagent journal under `<timestamp>_<id>/`) every `DASHBOARD_OMP_POLL_MS` ms
|
||||
@@ -803,7 +810,7 @@ HTTP surface for the tmux-backed terminal run: one session per workspace, named
|
||||
|
||||
Codex-only spawn options (`sandbox`, `approval`, `resumeLast`) are accepted by the workspace action `POST /api/lanes/:id/start`, not by `POST /api/run` — see [`docs/API.md`](../docs/API.md).
|
||||
|
||||
The pane itself is not JSON: its bytes stream over `/ws-pty/:runId` as binary WebSocket frames. The JSON WebSocket surface adds `cc_config_changed` (broadcast by `lib/cc-watcher.js` on `fs.watch` events under `~/.claude/` and by `routes/cc-config.js` after every successful PUT/DELETE — debounced at 500 ms, payload `{ source: "dashboard"|"fs", action?, scope?, type?, name?, paths? }`) and its Codex counterpart `codex_config_changed` (broadcast by `routes/codex-config.js` after every successful file mutation and CLI-backed mcp/plugin/marketplace action — always `source: "dashboard"`, there is no `$CODEX_HOME` filesystem watcher).
|
||||
The pane itself is not JSON: its bytes stream over `/ws-pty/:runId` as binary WebSocket frames. The JSON WebSocket surface adds `cc_config_changed` (broadcast by `lib/cc-watcher.js` on `fs.watch` events under `~/.claude/` and by `routes/cc-config.js` after every successful PUT/DELETE — debounced at 500 ms, payload `{ source: "dashboard"|"fs", action?, scope?, type?, name?, paths? }`) and its Codex counterpart `codex_config_changed` (broadcast by `routes/codex-config.js` after every successful file mutation and CLI-backed mcp/plugin/marketplace action — always `source: "dashboard"`, there is no `$CODEX_HOME` filesystem watcher), and `transcript_changed` `{ session_id }` — the session sync's `fs.watch` on the projects tree saw a Claude transcript grow (`pushTranscript` in `server/index.js`, at most every 250 ms per session; nothing is parsed for it), so a chat showing that session reads its new lines at once instead of at its next 3 s poll. It reaches whoever the session's own `new_event` reaches (`broadcastAudience`).
|
||||
|
||||
### Import History
|
||||
|
||||
|
||||
@@ -181,6 +181,58 @@ describe("stepsFor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("a system-wide npm prefix", () => {
|
||||
const ro = path.join(root, "ro-prefix");
|
||||
const user = path.join(root, "user-local");
|
||||
const asRoot = process.getuid?.() === 0;
|
||||
|
||||
before(() => {
|
||||
for (const d of [path.join(ro, "lib", "node_modules"), path.join(ro, "bin")]) {
|
||||
fs.mkdirSync(d, { recursive: true });
|
||||
fs.chmodSync(d, 0o555);
|
||||
}
|
||||
versions.__setUserPrefix(user);
|
||||
});
|
||||
after(() => {
|
||||
versions.__setUserPrefix(undefined);
|
||||
for (const d of [path.join(ro, "lib", "node_modules"), path.join(ro, "bin")]) {
|
||||
fs.chmodSync(d, 0o755);
|
||||
}
|
||||
});
|
||||
|
||||
it("installs into the account's ~/.local when npm's global prefix is not writable", async (t) => {
|
||||
if (asRoot) return t.skip("root writes anywhere");
|
||||
versions.__setExec(async (_f, args) => {
|
||||
if (args[0] === "prefix") return { stdout: `${ro}\n` };
|
||||
throw new Error("unexpected");
|
||||
});
|
||||
assert.deepEqual(await jobs.stepsFor("fresh", "install"), [
|
||||
{
|
||||
argv: [path.join(shellBin, "npm"), "install", "-g", "--prefix", user, "fresh-pkg@latest"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("updates a tool under ~/.local/lib/node_modules with the same --prefix", async () => {
|
||||
const pkg = path.join(user, "lib", "node_modules", "fresh-pkg", "cli.js");
|
||||
exe(pkg);
|
||||
fs.mkdirSync(path.join(user, "bin"), { recursive: true });
|
||||
fs.symlinkSync(pkg, path.join(serverBin, "fresh"));
|
||||
aiTools.refresh();
|
||||
try {
|
||||
assert.equal(await versions.managedBy("fresh"), "user");
|
||||
assert.deepEqual(await jobs.stepsFor("fresh", "update"), [
|
||||
{
|
||||
argv: [path.join(shellBin, "npm"), "install", "-g", "--prefix", user, "fresh-pkg@latest"],
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
fs.rmSync(path.join(serverBin, "fresh"));
|
||||
aiTools.refresh();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("versions", () => {
|
||||
it("marks npm-managed by realpath and badges a newer version", async () => {
|
||||
const v = await versions.versions({ fresh: true });
|
||||
|
||||
@@ -206,7 +206,7 @@ describe("install / update / icons", () => {
|
||||
it("maps refusals to statuses", async () => {
|
||||
jobs.__reset();
|
||||
assert.equal((await request("POST", "/api/ai-tools/nope/install")).status, 404);
|
||||
const noInstall = await request("POST", "/api/ai-tools/antigravity/install");
|
||||
const noInstall = await request("POST", "/api/ai-tools/claude/install");
|
||||
assert.equal(noInstall.status, 400);
|
||||
assert.equal(noInstall.body.error.code, "ENOINSTALL");
|
||||
const notInstalled = await request("POST", "/api/ai-tools/codex/update");
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* @file antigravity-transcript.test.js
|
||||
* @description An Antigravity (agy) session's conversation view: its
|
||||
* `transcript_full.jsonl` step log mapped to the Claude transcript shape —
|
||||
* the prompt out of `<USER_REQUEST>`, the model's thinking, text, tool calls
|
||||
* and tokens, each tool result tied to its call by step and an invalid call
|
||||
* marked an error — one "main" transcript, no subagent files, and a session
|
||||
* id that is no UUID finding nothing. The agy dir and the DB are temp dirs;
|
||||
* the steps are synthetic, in the shape agy 1.4 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-agy-transcript-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
|
||||
process.env.AGENTHUB_GEMINI_DIR = path.join(ROOT, "gemini");
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
|
||||
|
||||
const { describe, it, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { db } = require("../db");
|
||||
const agy = require("../lib/antigravity-usage");
|
||||
|
||||
let server;
|
||||
after(() => {
|
||||
server?.close();
|
||||
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const ID = "57154e1a-65e5-4b51-a116-1890f5ca831f";
|
||||
const AT = "2026-10-21T05:08:24Z";
|
||||
|
||||
function steps(id) {
|
||||
const dir = path.join(
|
||||
process.env.AGENTHUB_GEMINI_DIR,
|
||||
"antigravity-cli",
|
||||
"brain",
|
||||
id,
|
||||
".system_generated",
|
||||
"logs"
|
||||
);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const file = path.join(dir, "transcript_full.jsonl");
|
||||
const lines = [
|
||||
{
|
||||
step_index: 0,
|
||||
source: "USER_EXPLICIT",
|
||||
type: "USER_INPUT",
|
||||
status: "DONE",
|
||||
created_at: AT,
|
||||
content:
|
||||
"<USER_REQUEST>\nCreate c.txt\n</USER_REQUEST>\n<ADDITIONAL_METADATA>\nThe current local time is: x.\n</ADDITIONAL_METADATA>",
|
||||
},
|
||||
{
|
||||
step_index: 1,
|
||||
source: "MODEL",
|
||||
type: "PLANNER_RESPONSE",
|
||||
status: "DONE",
|
||||
created_at: AT,
|
||||
input_tokens: 3889,
|
||||
cache_read_tokens: 768,
|
||||
output_tokens: 12,
|
||||
thinking: "Two commands.",
|
||||
content: "Running them now.",
|
||||
tool_calls: [
|
||||
{ name: "run_command", args: { CommandLine: "echo ok > c.txt" } },
|
||||
{ name: "read", args: { path: "c.txt" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
step_index: 2,
|
||||
source: "MODEL",
|
||||
type: "GENERIC",
|
||||
status: "DONE",
|
||||
created_at: AT,
|
||||
content: "Created At: x\nCompleted At: y\n\nThe command exited with code 0.\n",
|
||||
},
|
||||
{
|
||||
step_index: 3,
|
||||
source: "MODEL",
|
||||
type: "GENERIC",
|
||||
status: "INVALID",
|
||||
error: 'unknown tool: "read"',
|
||||
created_at: AT,
|
||||
content: 'Created At: x\nCompleted At: y\nError invalid tool call: unknown tool: "read"',
|
||||
},
|
||||
{
|
||||
step_index: 4,
|
||||
source: "MODEL",
|
||||
type: "PLANNER_RESPONSE",
|
||||
status: "DONE",
|
||||
created_at: AT,
|
||||
content: "Done.",
|
||||
},
|
||||
];
|
||||
fs.writeFileSync(file, lines.map((l) => JSON.stringify(l)).join("\n") + "\n");
|
||||
return file;
|
||||
}
|
||||
|
||||
describe("an Antigravity session's conversation", () => {
|
||||
it("reads agy's step log in the Claude transcript shape", async () => {
|
||||
const file = steps(ID);
|
||||
assert.equal(agy.transcriptPath(ID), file);
|
||||
assert.equal(agy.transcriptPath("../../etc"), null);
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, started_at, tool) VALUES (?, 'c', 'completed', ?, 'antigravity')"
|
||||
).run(ID, AT);
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
server ||= await startServer(createApp(), 0);
|
||||
const BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
const list = await (await fetch(`${BASE}/api/sessions/${ID}/transcripts`)).json();
|
||||
assert.deepEqual(
|
||||
list.transcripts.map((t) => [t.id, t.type]),
|
||||
[["main", "main"]]
|
||||
);
|
||||
const body = await (await fetch(`${BASE}/api/sessions/${ID}/transcript`)).json();
|
||||
assert.deepEqual(
|
||||
body.messages.map((m) => [m.sender, m.content.map((c) => c.type)]),
|
||||
[
|
||||
["user", ["text"]],
|
||||
["assistant", ["thinking", "text", "tool_use", "tool_use"]],
|
||||
["tool", ["tool_result"]],
|
||||
["tool", ["tool_result"]],
|
||||
["assistant", ["text"]],
|
||||
]
|
||||
);
|
||||
const [user, plan, ok, bad] = body.messages;
|
||||
assert.equal(user.content[0].text, "Create c.txt");
|
||||
assert.equal(user.timestamp, AT);
|
||||
// Each result names the call it answers: call i of step s lands at step s+1+i.
|
||||
assert.deepEqual(
|
||||
plan.content.filter((c) => c.type === "tool_use").map((c) => c.id),
|
||||
[ok.content[0].id, bad.content[0].id]
|
||||
);
|
||||
assert.equal(ok.content[0].output, "The command exited with code 0.\n");
|
||||
assert.equal(ok.content[0].is_error, false);
|
||||
assert.equal(bad.content[0].is_error, true);
|
||||
assert.equal(plan.usage.input_tokens, 3889);
|
||||
assert.equal(plan.usage.cache_read_input_tokens, 768);
|
||||
const sub = await (await fetch(`${BASE}/api/sessions/${ID}/transcript?agent_id=x`)).json();
|
||||
assert.equal(sub.messages.length, 0);
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,8 @@
|
||||
* @file Tests for the Artifacts hub (server/lib/artifacts.js, routes/artifacts.js):
|
||||
* claude.ai pages (link allowlist, hook capture, one row per URL), workspace
|
||||
* file candidates and pins (a snapshot, served sandboxed), skills (snapshot,
|
||||
* symlink/secret refusal, install into the caller's Claude home with
|
||||
* replace moving the old one aside), plugins (marketplace allowlist, install
|
||||
* symlink/secret refusal, install into the caller's ~/.claude and ~/.agents skills with
|
||||
* replace and uninstall moving the old one aside), plugins (marketplace allowlist, install
|
||||
* argv; a marketplace link read into its plugin list, the same plugin added
|
||||
* twice kept as one row, key/market edited), skills uploaded (one Markdown
|
||||
* file, a folder, a zip, several skills at once — name and frontmatter set,
|
||||
@@ -161,7 +161,7 @@ describe("files", () => {
|
||||
});
|
||||
|
||||
describe("skills", () => {
|
||||
it("snapshots a workspace skill and installs it into the caller's Claude home", async () => {
|
||||
it("snapshots a workspace skill and installs it into ~/.claude/skills and ~/.agents/skills", async () => {
|
||||
const list = await request("GET", `/api/artifacts/skills?laneId=${lane.id}`);
|
||||
assert.deepEqual(list.body.items, [{ name: "deploy", from: "workspace" }]);
|
||||
const r = await request("POST", "/api/artifacts/skill", { laneId: lane.id, name: "deploy" });
|
||||
@@ -169,11 +169,25 @@ describe("skills", () => {
|
||||
assert.equal(r.body.item.description, "Ship it");
|
||||
assert.deepEqual(r.body.item.files.map((f) => f.path).sort(), ["SKILL.md", "scripts/run.sh"]);
|
||||
const id = r.body.item.id;
|
||||
const listed = async () =>
|
||||
(await request("GET", "/api/artifacts?kind=skill")).body.items.find((i) => i.id === id);
|
||||
assert.equal((await listed()).installed, false);
|
||||
|
||||
const first = await request("POST", `/api/artifacts/${id}/install`, {});
|
||||
assert.equal(first.status, 200, first.text);
|
||||
assert.equal((await listed()).installed, true);
|
||||
const who = await request("GET", `/api/artifacts/${id}/installs`);
|
||||
assert.equal(who.status, 200, who.text);
|
||||
assert.equal(who.body.items.length, 1);
|
||||
assert.equal(who.body.items[0].mine, true);
|
||||
assert.deepEqual(who.body.items[0].paths, first.body.paths);
|
||||
assert.equal(who.body.unrecorded, 0);
|
||||
const target = path.join(process.env.CLAUDE_HOME, "skills", "deploy");
|
||||
assert.equal(fs.readFileSync(path.join(target, "scripts", "run.sh"), "utf8"), "echo hi\n");
|
||||
// The cross-tool folder beside the Claude home (Codex, Gemini, OpenCode…).
|
||||
const shared = path.join(ROOT, ".agents", "skills", "deploy");
|
||||
assert.equal(fs.readFileSync(path.join(shared, "scripts", "run.sh"), "utf8"), "echo hi\n");
|
||||
assert.deepEqual(first.body.paths, [target, shared]);
|
||||
|
||||
const again = await request("POST", `/api/artifacts/${id}/install`, {});
|
||||
assert.equal(again.status, 409);
|
||||
@@ -183,8 +197,47 @@ describe("skills", () => {
|
||||
assert.equal(replaced.status, 200, replaced.text);
|
||||
assert.ok(!fs.existsSync(path.join(target, "mine.txt")));
|
||||
const aside = path.join(process.env.CLAUDE_HOME, "agenthub-replaced-skills");
|
||||
const [old] = fs.readdirSync(aside);
|
||||
const old = fs.readdirSync(aside).find((n) => !n.includes("-agents-"));
|
||||
assert.equal(fs.readFileSync(path.join(aside, old, "mine.txt"), "utf8"), "keep me");
|
||||
assert.ok(fs.readdirSync(aside).some((n) => n.startsWith("deploy-agents-")));
|
||||
|
||||
// Uninstall moves both copies aside too, never deletes them.
|
||||
const gone = await request("POST", `/api/artifacts/${id}/uninstall`, {});
|
||||
assert.equal(gone.status, 200, gone.text);
|
||||
assert.deepEqual(gone.body.paths, [target, shared]);
|
||||
assert.ok(!fs.existsSync(target) && !fs.existsSync(shared));
|
||||
assert.equal((await listed()).installed, false);
|
||||
const kept = fs.readdirSync(aside).filter((n) => n.includes("-uninstalled-"));
|
||||
assert.equal(kept.length, 2);
|
||||
const none = await request("POST", `/api/artifacts/${id}/uninstall`, {});
|
||||
assert.equal(none.status, 409);
|
||||
assert.equal(none.body.error.code, "ENOTINSTALLED");
|
||||
});
|
||||
|
||||
it("edits a skill's name, description and instructions into a new snapshot", async () => {
|
||||
const item = (await request("GET", "/api/artifacts?kind=skill")).body.items[0];
|
||||
const before = db.prepare("SELECT snapshot FROM artifacts WHERE id = ?").get(item.id).snapshot;
|
||||
const r = await request("PATCH", `/api/artifacts/${item.id}`, {
|
||||
title: "Ship",
|
||||
skill: { name: "ship it", description: "Use to ship", content: "# Ship\n\nDo it." },
|
||||
});
|
||||
assert.equal(r.status, 200, r.text);
|
||||
assert.equal(r.body.item.title, "Ship");
|
||||
assert.equal(r.body.item.source, "ship-it");
|
||||
assert.equal(r.body.item.description, "Use to ship");
|
||||
// Other files are kept; the old snapshot is gone.
|
||||
assert.ok(r.body.item.files.some((f) => f.path === "scripts/run.sh"));
|
||||
const after = db.prepare("SELECT snapshot FROM artifacts WHERE id = ?").get(item.id).snapshot;
|
||||
assert.notEqual(after, before);
|
||||
const md = await request("GET", `/api/artifacts/${item.id}/raw?path=SKILL.md`);
|
||||
assert.match(
|
||||
md.text,
|
||||
/^---\nname: ship-it\n[\s\S]*description: Use to ship\n---\n\n# Ship\n\nDo it\.$/
|
||||
);
|
||||
const empty = await request("PATCH", `/api/artifacts/${item.id}`, {
|
||||
skill: { name: "x", description: "d", content: " " },
|
||||
});
|
||||
assert.equal(empty.status, 400);
|
||||
});
|
||||
|
||||
it("refuses a skill holding a symlink or a secret-looking file", async () => {
|
||||
@@ -230,6 +283,12 @@ describe("plugins", () => {
|
||||
["claude", "plugin", "marketplace", "add", "o/r"],
|
||||
["claude", "plugin", "install", "--scope", "user", "tool@market"],
|
||||
]);
|
||||
calls.length = 0;
|
||||
const un = await request("POST", `/api/artifacts/${r.body.item.id}/uninstall`, {});
|
||||
assert.equal(un.status, 200, un.text);
|
||||
assert.deepEqual(calls, [
|
||||
["claude", "plugin", "uninstall", "--scope", "user", "--keep-data", "tool@market"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -476,6 +535,46 @@ describe("sharing and visibility", () => {
|
||||
assert.equal(artifacts.canSeeId(dev, hidden), false);
|
||||
const shared = db.prepare("SELECT id FROM artifacts WHERE title = 'b'").get().id;
|
||||
assert.throws(() => artifacts.update(dev, shared, { title: "x" }), { code: "EFORBIDDEN" });
|
||||
|
||||
// Deleting a shared row from its owner's library keeps it in the Hub;
|
||||
// only unsharing it then deletes it.
|
||||
const mine = db
|
||||
.prepare(
|
||||
"INSERT INTO artifacts (kind, title, owner_id, url, shared) VALUES ('page', 'd', 7, 'https://claude.ai/d', 1)"
|
||||
)
|
||||
.run().lastInsertRowid;
|
||||
const kept = artifacts.remove(dev, mine);
|
||||
assert.equal(kept.kept, true);
|
||||
assert.ok(!artifacts.list(dev, { scope: "mine" }).some((i) => i.title === "d"));
|
||||
const hubRow = artifacts.list(dev, { scope: "hub" }).find((i) => i.title === "d");
|
||||
assert.equal(hubRow.inLibrary, false);
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
let gone;
|
||||
try {
|
||||
gone = artifacts.update(dev, mine, { shared: false });
|
||||
} finally {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
}
|
||||
assert.equal(gone.removed, true);
|
||||
assert.equal(db.prepare("SELECT id FROM artifacts WHERE id = ?").get(mine), undefined);
|
||||
|
||||
// Installs of someone else's shared row: a developer sees only their own,
|
||||
// its owner sees everyone's but not where another account wrote it.
|
||||
db.prepare("UPDATE artifacts SET installs = 3 WHERE id = ?").run(shared);
|
||||
const add = db.prepare(
|
||||
"INSERT INTO artifact_installs (artifact_id, user_id, paths) VALUES (?, ?, '[\"/h/x\"]')"
|
||||
);
|
||||
add.run(shared, 7);
|
||||
add.run(shared, 9);
|
||||
const own = artifacts.installsOf(dev, shared);
|
||||
assert.equal(own.items.length, 1);
|
||||
assert.deepEqual(own.items[0].paths, ["/h/x"]);
|
||||
assert.equal(own.unrecorded, 0);
|
||||
const owner = { method: "GET", user: { id: 8, role: "developer", username: "o" } };
|
||||
const seen = artifacts.installsOf(owner, shared);
|
||||
assert.equal(seen.items.length, 2);
|
||||
assert.ok(seen.items.every((i) => i.paths === undefined));
|
||||
assert.equal(seen.unrecorded, 1);
|
||||
});
|
||||
|
||||
it("refuses a cross-origin write", async () => {
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
* project slug walked back onto the filesystem) and no tokens, an unchanged
|
||||
* file never re-read, a quiet one settled to completed, a session the user
|
||||
* deleted staying deleted, and a non-cursor row with the same id never
|
||||
* touched. The Cursor dir and the DB are temp dirs; the transcripts are
|
||||
* synthetic, in the shape Cursor writes.
|
||||
* touched, a session claimed by the Cursor pane it started in, and the
|
||||
* transcript mapped to the Claude transcript shape by the
|
||||
* transcript routes. The Cursor dir and the DB are temp dirs; the transcripts
|
||||
* are synthetic, in the shape Cursor writes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -16,13 +18,18 @@ const path = require("node:path");
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-cursor-usage-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
|
||||
process.env.AGENTHUB_CURSOR_DIR = path.join(ROOT, "cursor");
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
|
||||
|
||||
const { describe, it, beforeEach, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { db } = require("../db");
|
||||
const cursor = require("../lib/cursor-usage");
|
||||
|
||||
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
||||
let server;
|
||||
after(() => {
|
||||
server?.close();
|
||||
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const ID = "7481e37e-9157-4145-b985-2ea9a2117f52";
|
||||
const PROJECT = path.join(fs.realpathSync(ROOT), "work", "my app");
|
||||
@@ -65,7 +72,8 @@ const session = (id) => db.prepare("SELECT * FROM sessions WHERE id = ?").get(id
|
||||
beforeEach(() => {
|
||||
fs.rmSync(process.env.AGENTHUB_CURSOR_DIR, { recursive: true, force: true });
|
||||
db.exec(
|
||||
"DELETE FROM token_usage; DELETE FROM agents; DELETE FROM sessions; DELETE FROM cursor_transcripts;"
|
||||
"DELETE FROM token_usage; DELETE FROM agents; DELETE FROM sessions; DELETE FROM cursor_transcripts;" +
|
||||
" DELETE FROM dashboard_runs; DELETE FROM lanes;"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -175,4 +183,64 @@ describe("cursor usage", () => {
|
||||
assert.equal(session(ID).name, `Cursor ${ID.slice(0, 8)}`);
|
||||
assert.equal(session(ID).cwd, null);
|
||||
});
|
||||
|
||||
it("maps the transcript to the transcript shape", async () => {
|
||||
const file = transcript(ID);
|
||||
const more = [
|
||||
{
|
||||
role: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{ type: "text", text: "Reading it now." },
|
||||
{ type: "tool_use", id: "t1", name: "Grep", input: { pattern: "x" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
message: { content: [{ type: "tool_result", tool_use_id: "t1", content: "no match" }] },
|
||||
},
|
||||
{ role: "assistant", message: { content: [{ type: "text", text: "Done." }] } },
|
||||
];
|
||||
fs.appendFileSync(file, more.map((l) => JSON.stringify(l)).join("\n") + "\n");
|
||||
await cursor.ingestCursorUsage();
|
||||
assert.equal(cursor.transcriptPath(ID), file);
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
server ||= await startServer(createApp(), 0);
|
||||
const BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
const list = await (await fetch(`${BASE}/api/sessions/${ID}/transcripts`)).json();
|
||||
assert.deepEqual(
|
||||
list.transcripts.map((t) => [t.id, t.type]),
|
||||
[["main", "main"]]
|
||||
);
|
||||
const body = await (await fetch(`${BASE}/api/sessions/${ID}/transcript`)).json();
|
||||
assert.deepEqual(
|
||||
body.messages.map((m) => [m.sender, m.content.map((c) => c.type)]),
|
||||
[
|
||||
["user", ["text"]],
|
||||
["assistant", ["tool_use"]],
|
||||
["assistant", ["text", "tool_use"]],
|
||||
["tool", ["tool_result"]],
|
||||
["assistant", ["text"]],
|
||||
]
|
||||
);
|
||||
assert.equal(body.messages[0].content[0].text, "Review the campaign code\nplease");
|
||||
assert.equal(body.messages[3].content[0].output, "no match");
|
||||
const sub = await (await fetch(`${BASE}/api/sessions/${ID}/transcript?agent_id=x`)).json();
|
||||
assert.equal(sub.messages.length, 0);
|
||||
});
|
||||
|
||||
it("gives the session to the Cursor pane it started in", async () => {
|
||||
db.prepare("INSERT INTO lanes (id, title, cwd) VALUES (5, 'w', ?)").run(PROJECT);
|
||||
db.prepare(
|
||||
`INSERT INTO dashboard_runs (id, lane_id, cwd, status, started_at, tool)
|
||||
VALUES ('agenthub-lane-5', 5, ?, 'running', ?, 'cursor')`
|
||||
).run(PROJECT, new Date(Date.now() - 60_000).toISOString());
|
||||
transcript(ID);
|
||||
await cursor.ingestCursorUsage();
|
||||
assert.equal(session(ID).lane_id, 5);
|
||||
const run = db.prepare("SELECT session_id FROM dashboard_runs WHERE id = 'agenthub-lane-5'");
|
||||
assert.equal(run.get().session_id, ID);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
* @file Route tests for the workspace File Explorer (`/api/lanes/:id/fs/*`,
|
||||
* server/lib/file-explorer.js): list, mkdir/newfile, rename, move, copy (in
|
||||
* place, across workspaces), delete into the trash and restore/purge, a
|
||||
* streamed upload into new sub-folders with every conflict policy, a zip of
|
||||
* streamed upload into new sub-folders with every conflict policy, a batch
|
||||
* of many small files in one request (and, in a member's folder, as ONE
|
||||
* write-as `extract` of a tar that real `tar` unpacks), a zip of
|
||||
* files and folders that unzips back to the same bytes — and the guards: no
|
||||
* `..`, no symlink walking out, nothing in `.git`, no secret file, the
|
||||
* workspace folder itself never moved or deleted, cross-origin refused.
|
||||
@@ -220,6 +222,77 @@ describe("File Explorer", () => {
|
||||
assert.ok(!fs.existsSync(path.join(W1, "big.bin")));
|
||||
});
|
||||
|
||||
it("uploads many small files as one batch, planned whole before writing", async () => {
|
||||
const files = [
|
||||
["many/a.txt", "a"],
|
||||
["many/deep/b.txt", "bb"],
|
||||
["many/empty", ""],
|
||||
[`many/${"n".repeat(120)}/long.txt`, "long"],
|
||||
];
|
||||
const body = (list) => {
|
||||
const head = Buffer.from(
|
||||
JSON.stringify({ files: list.map(([p, c]) => ({ path: p, size: Buffer.byteLength(c) })) })
|
||||
);
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(head.length);
|
||||
return Buffer.concat([len, head, ...list.map(([, c]) => Buffer.from(c))]);
|
||||
};
|
||||
const batch = (list, conflict = "", dir = "up") =>
|
||||
request(
|
||||
"POST",
|
||||
`/api/lanes/${lane1}/fs/upload-batch?dir=${dir}${conflict ? `&conflict=${conflict}` : ""}`,
|
||||
body(list),
|
||||
{ "Content-Type": "application/octet-stream" }
|
||||
);
|
||||
assert.equal((await fsApi(lane1, "mkdir", { path: "up" })).status, 201);
|
||||
const check = await fsApi(lane1, "upload-check", { dir: "up", paths: files.map(([p]) => p) });
|
||||
assert.deepEqual(check.body, { taken: [], batch: true });
|
||||
const r = await batch(files);
|
||||
assert.equal(r.status, 201);
|
||||
assert.equal(r.body.saved, 4);
|
||||
for (const [p, c] of files) assert.equal(fs.readFileSync(path.join(W1, "up", p), "utf8"), c);
|
||||
// Taken names: one 409 naming them all, and nothing of the batch written.
|
||||
const again = await batch([
|
||||
["many/a.txt", "x"],
|
||||
["many/new.txt", "n"],
|
||||
["many/deep/b.txt", "y"],
|
||||
]);
|
||||
assert.equal(again.status, 409);
|
||||
assert.deepEqual(again.body.error.names, ["many/a.txt", "many/deep/b.txt"]);
|
||||
assert.ok(!fs.existsSync(path.join(W1, "up/many/new.txt")));
|
||||
const taken = await fsApi(lane1, "upload-check", { dir: "up", paths: ["many/a.txt", "z"] });
|
||||
assert.deepEqual(taken.body.taken, ["many/a.txt"]);
|
||||
// skip / keep / replace for the whole batch.
|
||||
const skip = await batch(
|
||||
[
|
||||
["many/a.txt", "x"],
|
||||
["many/new.txt", "n"],
|
||||
],
|
||||
"skip"
|
||||
);
|
||||
assert.deepEqual([skip.body.saved, skip.body.skipped], [1, 1]);
|
||||
const keep = await batch(
|
||||
[
|
||||
["many/a.txt", "k"],
|
||||
["many/a (1).txt", "k1"],
|
||||
],
|
||||
"keep"
|
||||
);
|
||||
assert.deepEqual(keep.body.paths, ["up/many/a (2).txt", "up/many/a (1).txt"]);
|
||||
await batch([["many/a.txt", "R"]], "replace");
|
||||
assert.equal(fs.readFileSync(path.join(W1, "up/many/a.txt"), "utf8"), "R");
|
||||
// The guards are upload's: no .git, no `..`, no file over a folder, no short body.
|
||||
assert.equal((await batch([[".git/hooks/x", "x"]], "", "")).status, 400);
|
||||
assert.equal((await batch([["../out", "x"]])).status, 400);
|
||||
assert.equal((await batch([["many", "x"]])).status, 409);
|
||||
const short = body([["s.txt", "12345"]]).subarray(0, -2);
|
||||
const cut = await request("POST", `/api/lanes/${lane1}/fs/upload-batch?dir=up`, short, {
|
||||
"Content-Type": "application/octet-stream",
|
||||
});
|
||||
assert.equal(cut.status, 400);
|
||||
assert.ok(!fs.existsSync(path.join(W1, "up/s.txt")));
|
||||
});
|
||||
|
||||
it("zips files and folders into one download that unzips to the same bytes", async () => {
|
||||
const r = await request("GET", `/api/lanes/${lane1}/fs/zip?p=src&p=site/css/app.css`);
|
||||
assert.equal(r.status, 200);
|
||||
@@ -286,6 +359,51 @@ describe("File Explorer in a member's folder (S3, run-as stubbed)", () => {
|
||||
assert.ok(fs.existsSync(path.join(M, "f.txt")), "the host itself moved nothing");
|
||||
});
|
||||
|
||||
it("writes a batch as ONE write-as extract whose tar real tar unpacks", async () => {
|
||||
const { spawn } = require("node:child_process");
|
||||
const prev = [runAs.writeAsAsync, runAs.writeAsCanExtract];
|
||||
const runs = [];
|
||||
runAs.writeAsCanExtract = () => true;
|
||||
runAs.writeAsAsync = (_a, file, args, { input }) => {
|
||||
runs.push([file, ...args]);
|
||||
return new Promise((resolve, reject) => {
|
||||
const tar = spawn("tar", ["-x", "-k", "-p", "--no-same-owner", "-f", "-", "-C", file]);
|
||||
let err = "";
|
||||
tar.stderr.on("data", (d) => (err += d));
|
||||
tar.on("close", (code) => (code ? reject(new Error(err)) : resolve()));
|
||||
input.pipe(tar.stdin);
|
||||
});
|
||||
};
|
||||
try {
|
||||
const long = `${"x".repeat(150)}/${"y".repeat(90)}.txt`;
|
||||
const list = [
|
||||
["b/one.txt", "1"],
|
||||
["b/sub/two.txt", "22"],
|
||||
[long, "L"],
|
||||
];
|
||||
const head = Buffer.from(
|
||||
JSON.stringify({ files: list.map(([p, c]) => ({ path: p, size: c.length })) })
|
||||
);
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(head.length);
|
||||
const { Readable } = require("node:stream");
|
||||
const out = await fx.uploadBatch(
|
||||
M,
|
||||
"d",
|
||||
Readable.from([Buffer.concat([len, head, ...list.map(([, c]) => Buffer.from(c))])])
|
||||
);
|
||||
assert.equal(out.saved, 3);
|
||||
assert.deepEqual(runs, [[path.join(M, "d"), "extract"]]);
|
||||
for (const [p, c] of list) assert.equal(fs.readFileSync(path.join(M, "d", p), "utf8"), c);
|
||||
assert.equal((fs.statSync(path.join(M, "d/b/one.txt")).mode & 0o777).toString(8), "644");
|
||||
// An old helper: the check says "no batch", the batch says EUPGRADE.
|
||||
runAs.writeAsCanExtract = () => false;
|
||||
assert.equal((await fx.uploadCheck(M, "d", ["z.txt"])).batch, false);
|
||||
} finally {
|
||||
[runAs.writeAsAsync, runAs.writeAsCanExtract] = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it("says the helper needs updating instead of failing obscurely", async () => {
|
||||
runAs.writeAsCanMove = () => false;
|
||||
try {
|
||||
|
||||
@@ -205,6 +205,9 @@ describe("lane-sources instructions and launch", () => {
|
||||
assert.equal(argv[5], "--permission-mode");
|
||||
assert.ok(!argv.some((a) => a.startsWith("--append-system-prompt")));
|
||||
assert.ok(newSession.includes("CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1"));
|
||||
// Claude Code hides its task tools from newer models without this, and the
|
||||
// chat Progress panel is drawn from those calls.
|
||||
assert.ok(newSession.includes("CLAUDE_CODE_ENABLE_TODO_TOOLS=1"));
|
||||
assert.deepEqual(fs.readdirSync(dir), ["CLAUDE.md"]);
|
||||
const text = fs.readFileSync(path.join(dir, "CLAUDE.md"), "utf8");
|
||||
assert.match(text, /^- always answer in Vietnamese/);
|
||||
|
||||
@@ -332,6 +332,81 @@ describe("POST /api/run/drop-image", () => {
|
||||
});
|
||||
assert.equal(r.status, 400);
|
||||
});
|
||||
|
||||
it("refuses a file declared over the limit before its body arrives", async () => {
|
||||
// 4 GB announced, 1 KB sent: the answer must come without the rest.
|
||||
const { status, body } = await new Promise((resolve, reject) => {
|
||||
const url = new URL("/api/run/drop-file?name=big.mp4", BASE);
|
||||
const req = http.request(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "video/mp4", "Content-Length": 4 * 1024 ** 3 },
|
||||
});
|
||||
req.on("response", (res) => {
|
||||
let text = "";
|
||||
res.on("data", (c) => (text += c));
|
||||
res.on("end", () => {
|
||||
req.destroy();
|
||||
resolve({ status: res.statusCode, body: JSON.parse(text) });
|
||||
});
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.write(Buffer.alloc(1024));
|
||||
});
|
||||
assert.equal(status, 413);
|
||||
assert.equal(body.error.code, "ETOOBIG");
|
||||
// The UI shows the server's limit: a loopback browser's, here.
|
||||
assert.equal(body.error.max_bytes, 2 * 1024 ** 3);
|
||||
});
|
||||
|
||||
it("streams a loopback drop over the remote limit to disk", async () => {
|
||||
const buf = Buffer.alloc(21 * 1024 * 1024, 7);
|
||||
const r = await fetchJson("/api/run/drop-file?name=big.bin", {
|
||||
method: "POST",
|
||||
raw: { type: "application/octet-stream", buf },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.match(r.body.path, /[\\/]drops[\\/][0-9a-f]{16}-big\.bin$/);
|
||||
assert.equal(fs.statSync(r.body.path).size, buf.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/run/local-paths", () => {
|
||||
let srv;
|
||||
before(async () => {
|
||||
srv = http.createServer(createApp());
|
||||
await new Promise((r) => srv.listen(0, "127.0.0.1", r));
|
||||
BASE = `http://127.0.0.1:${srv.address().port}`;
|
||||
});
|
||||
after(() => new Promise((r) => srv.close(r)));
|
||||
|
||||
it("answers the size of each regular file, null for anything else", async () => {
|
||||
const file = path.join(TMP, "clip.mp4");
|
||||
fs.writeFileSync(file, "12345");
|
||||
const r = await fetchJson("/api/run/local-paths", {
|
||||
method: "POST",
|
||||
body: { paths: [file, TMP, path.join(TMP, "missing.bin")] },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.deepEqual(r.body.sizes, [5, null, null]);
|
||||
});
|
||||
|
||||
it("refuses a relative path", async () => {
|
||||
const r = await fetchJson("/api/run/local-paths", {
|
||||
method: "POST",
|
||||
body: { paths: ["clip.mp4"] },
|
||||
});
|
||||
assert.equal(r.status, 400);
|
||||
});
|
||||
|
||||
it("refuses a request a proxy forwarded: that browser is elsewhere", async () => {
|
||||
const r = await fetchJson("/api/run/local-paths", {
|
||||
method: "POST",
|
||||
headers: { "X-Forwarded-For": "10.0.0.5" },
|
||||
body: { paths: [TMP] },
|
||||
});
|
||||
assert.equal(r.status, 403);
|
||||
assert.equal(r.body.error.code, "EREMOTE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/run/drop/:name", () => {
|
||||
|
||||
@@ -227,6 +227,9 @@ describe("broadcastAudience", () => {
|
||||
assert.equal(reaches("agent_updated", { session_id: "s3" }, m1), false);
|
||||
assert.equal(reaches("new_event", { session_id: "s2" }, admin.user), true);
|
||||
assert.equal(reaches("new_event", { session_id: "s2" }, undefined), true);
|
||||
// A transcript's growth notice goes where the session's events go.
|
||||
assert.equal(reaches("transcript_changed", { session_id: "s2" }, m2), true);
|
||||
assert.equal(reaches("transcript_changed", { session_id: "s2" }, m1), false);
|
||||
});
|
||||
|
||||
it("routes workspace messages by owner and keeps unknown types from members", () => {
|
||||
|
||||
@@ -437,6 +437,82 @@ describe("the top of a member's home", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the run form's `/` list", () => {
|
||||
it("reads the user skills, commands and plugins of the account the pane runs as", async () => {
|
||||
const skill = (claude, name) => {
|
||||
fs.mkdirSync(path.join(claude, "skills", name), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(claude, "skills", name, "SKILL.md"),
|
||||
`---\nname: ${name}\n---\nx\n`
|
||||
);
|
||||
};
|
||||
const hostClaude = process.env.CLAUDE_HOME;
|
||||
skill(hostClaude, "host-only");
|
||||
skill(path.join(MIA, ".claude"), "mia-skill");
|
||||
skill(path.join(BOB, ".claude"), "bob-skill");
|
||||
fs.mkdirSync(path.join(MIA, ".claude", "commands"), { recursive: true });
|
||||
fs.writeFileSync(path.join(MIA, ".claude", "commands", "mia-cmd.md"), "do it\n");
|
||||
fs.mkdirSync(path.join(MIA, ".claude", "plugins"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(MIA, ".claude", "plugins", "installed_plugins.json"),
|
||||
JSON.stringify({ plugins: { "mp@market": [{ scope: "user" }] } })
|
||||
);
|
||||
const names = (r) => r.body.items.map((i) => i.name);
|
||||
|
||||
const mia = await call("mia", "GET", "/api/cc-config/skills?scope=user");
|
||||
assert.deepEqual(names(mia), ["mia-skill"], JSON.stringify(mia.body));
|
||||
const cmds = await call("mia", "GET", "/api/cc-config/commands?scope=user");
|
||||
assert.deepEqual(names(cmds), ["mia-cmd"]);
|
||||
// Plugins stay staff-only; an admin's run form in a member folder lists that member's.
|
||||
const plugins = await call(
|
||||
"boss",
|
||||
"GET",
|
||||
`/api/cc-config/plugins?cwd=${encodeURIComponent(path.join(MIA, "proj"))}`
|
||||
);
|
||||
assert.deepEqual(
|
||||
plugins.body.plugins.map((p) => p.key),
|
||||
["mp@market"],
|
||||
JSON.stringify(plugins.body)
|
||||
);
|
||||
// A folder picks its own account's home.
|
||||
const bob = await call(
|
||||
"bob",
|
||||
"GET",
|
||||
`/api/cc-config/skills?scope=user&cwd=${encodeURIComponent(BOB)}`
|
||||
);
|
||||
assert.deepEqual(names(bob), ["bob-skill"], JSON.stringify(bob.body));
|
||||
// An admin's Config Explorer view, no folder named, stays the host's.
|
||||
const boss = await call("boss", "GET", "/api/cc-config/skills?scope=user");
|
||||
assert.deepEqual(names(boss), ["host-only"]);
|
||||
});
|
||||
|
||||
it("never lists another member's or the host's skills as someone's own", async () => {
|
||||
const names = (r) => r.body.items.map((i) => i.name);
|
||||
const bob = await call("bob", "GET", "/api/cc-config/skills?scope=user");
|
||||
assert.deepEqual(names(bob), ["bob-skill"]);
|
||||
const intoMia = await call(
|
||||
"bob",
|
||||
"GET",
|
||||
`/api/cc-config/skills?cwd=${encodeURIComponent(path.join(MIA, "proj"))}`
|
||||
);
|
||||
assert.equal(intoMia.status, 403);
|
||||
assert.equal(intoMia.body.error.code, "EFOLDER");
|
||||
// A developer with no Linux account of their own gets nothing, not the host's.
|
||||
await auth.createUser("eve", "eve-pass-123", "developer");
|
||||
const login = await fetch(`${BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: "eve", password: "eve-pass-123" }),
|
||||
});
|
||||
cookies.eve = login.headers.get("set-cookie").split(";")[0];
|
||||
for (const kind of ["skills", "commands"]) {
|
||||
const r = await call("eve", "GET", `/api/cc-config/${kind}`);
|
||||
assert.equal(r.status, 200, JSON.stringify(r.body));
|
||||
assert.deepEqual(r.body.items, [], kind);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("a disabled member's session", () => {
|
||||
it("is still read from their home, and does not stop the liveness reaper", () => {
|
||||
db.prepare("UPDATE users SET disabled = 1 WHERE username = 'dan'").run();
|
||||
|
||||
@@ -18,7 +18,12 @@ const fs = require("node:fs");
|
||||
const TEST_DB = path.join(os.tmpdir(), `transcript-sender-${Date.now()}-${process.pid}.db`);
|
||||
process.env.DASHBOARD_DB_PATH = TEST_DB;
|
||||
|
||||
const { classifyTranscriptSender, patchCounts, askAnswers } = require("../routes/sessions");
|
||||
const {
|
||||
classifyTranscriptSender,
|
||||
patchCounts,
|
||||
askAnswers,
|
||||
agentInfo,
|
||||
} = require("../routes/sessions");
|
||||
const { db } = require("../db");
|
||||
|
||||
after(() => {
|
||||
@@ -187,3 +192,44 @@ describe("askAnswers — what the user picked or typed for an AskUserQuestion",
|
||||
assert.equal(askAnswers("text"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("agentInfo — what an Agent call's result says about its subagent", () => {
|
||||
it("reads a foreground agent's id, model, time, tokens and tool calls", () => {
|
||||
const r = {
|
||||
status: "completed",
|
||||
agentId: "a2f215755130779a8",
|
||||
resolvedModel: "claude-opus-5-5",
|
||||
totalDurationMs: 132350,
|
||||
totalTokens: 88007,
|
||||
totalToolUseCount: 31,
|
||||
content: [{ type: "text", text: "report" }],
|
||||
};
|
||||
assert.deepEqual(agentInfo(r), {
|
||||
id: "a2f215755130779a8",
|
||||
model: "claude-opus-5-5",
|
||||
status: "completed",
|
||||
duration_ms: 132350,
|
||||
tokens: 88007,
|
||||
tool_uses: 31,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves unknown facts null for a background launch", () => {
|
||||
const r = { isAsync: true, status: "async_launched", agentId: "ab12", description: "x" };
|
||||
assert.deepEqual(agentInfo(r), {
|
||||
id: "ab12",
|
||||
model: null,
|
||||
status: "async_launched",
|
||||
duration_ms: null,
|
||||
tokens: null,
|
||||
tool_uses: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("answers null for a result that is no agent, or an id that is no plain name", () => {
|
||||
assert.equal(agentInfo({ stdout: "ok" }), null);
|
||||
assert.equal(agentInfo({ agentId: "../etc" }), null);
|
||||
assert.equal(agentInfo("text"), null);
|
||||
assert.equal(agentInfo(undefined), null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
],
|
||||
"homepageUrl": "https://antigravity.google/docs/cli/overview",
|
||||
"faviconDomain": "antigravity.google",
|
||||
"install": {
|
||||
"kind": "script",
|
||||
"url": "https://antigravity.google/cli/install.sh"
|
||||
},
|
||||
"config": [
|
||||
{
|
||||
"path": "~/.gemini/GEMINI.md"
|
||||
|
||||
@@ -952,6 +952,31 @@ db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_artifacts_shared ON artifacts(shared, kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_artifacts_session ON artifacts(session_id);
|
||||
`);
|
||||
// `in_library = 0`: its owner removed it from their library while it was
|
||||
// shared, so it lives on in the Hub only (`remove`); unsharing it then deletes
|
||||
// it. Existing rows are in their owner's library.
|
||||
try {
|
||||
db.prepare("SELECT in_library FROM artifacts LIMIT 1").get();
|
||||
} catch {
|
||||
db.prepare("ALTER TABLE artifacts ADD COLUMN in_library INTEGER NOT NULL DEFAULT 1").run();
|
||||
}
|
||||
|
||||
// One row per install of an artifact (server/lib/artifacts.js `install`):
|
||||
// who, when, and the folders it was written to — `artifacts.installs` only
|
||||
// counts. Installs before this table existed stay counted, not listed.
|
||||
// Additive; rows go with their artifact.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS artifact_installs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
artifact_id INTEGER NOT NULL,
|
||||
user_id INTEGER,
|
||||
paths TEXT NOT NULL DEFAULT '[]',
|
||||
replaced INTEGER NOT NULL DEFAULT 0,
|
||||
installed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
FOREIGN KEY (artifact_id) REFERENCES artifacts(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_artifact_installs_artifact ON artifact_installs(artifact_id, id);
|
||||
`);
|
||||
|
||||
// Seed lane_stage_events from the snapshots that predate it, so upgrading does
|
||||
// not read as "every pipeline has never run". Each lane's live `stages` blob
|
||||
@@ -1802,6 +1827,15 @@ try {
|
||||
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN pane_token_hash TEXT").run();
|
||||
}
|
||||
|
||||
// A realtime chat run's bridge secret (`AGENTHUB_CHAT_TOKEN`), SHA-256: only the
|
||||
// pane's own bridge may stream that run's events (server/routes/chat.js).
|
||||
// Never listed.
|
||||
try {
|
||||
db.prepare("SELECT chat_token_hash FROM dashboard_runs LIMIT 1").get();
|
||||
} catch {
|
||||
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN chat_token_hash TEXT").run();
|
||||
}
|
||||
|
||||
// Server mode: whose browser a push subscription is (`users.id`); NULL in
|
||||
// local mode. A user's notifications go only to their own subscriptions.
|
||||
try {
|
||||
|
||||
+33
-5
@@ -1032,7 +1032,9 @@ function startWorkflowPoll(broadcast) {
|
||||
* sessions show up immediately rather than on the next poll. Events for
|
||||
* files already in `mtimeCache` (active transcripts being appended to) are
|
||||
* ignored, so a busy session never thrashes the importer — the poll picks
|
||||
* up its growth. Recursive watching is used only on macOS/Windows (native,
|
||||
* up its growth; such an append only broadcasts `transcript_changed`
|
||||
* `{session_id}` (at most every 250 ms per session), so an open chat reads
|
||||
* the new lines at once. Recursive watching is used only on macOS/Windows (native,
|
||||
* stable); on Linux, where Node's userland recursive watcher trips on the
|
||||
* high-churn projects tree (see lib/cc-watcher.js), we watch the root plus
|
||||
* each immediate child folder non-recursively instead.
|
||||
@@ -1148,11 +1150,37 @@ function startSessionSync(broadcast) {
|
||||
}, DEBOUNCE_MS);
|
||||
if (debounce.unref) debounce.unref();
|
||||
}
|
||||
// Only a path we don't already track is interesting (a new session file or a
|
||||
// new project folder). Appends to a known active transcript are left to the
|
||||
// poll, so the watcher never re-parses a busy session every write.
|
||||
// An append to a session's own transcript: a chat showing that session reads
|
||||
// it now instead of at its next poll. One `transcript_changed` per session
|
||||
// per PUSH_MS — a notice only, nothing is parsed here.
|
||||
const PUSH_MS = 250;
|
||||
const pushing = new Map(); // session id → pending timer
|
||||
function pushTranscript(fullPath) {
|
||||
const m =
|
||||
/(?:^|[\\/])([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i.exec(
|
||||
fullPath
|
||||
);
|
||||
if (!m || pushing.has(m[1])) return;
|
||||
const sessionId = m[1];
|
||||
const timer = setTimeout(() => {
|
||||
pushing.delete(sessionId);
|
||||
try {
|
||||
broadcast("transcript_changed", { session_id: sessionId });
|
||||
} catch {
|
||||
/* best-effort — the chat's poll still catches up */
|
||||
}
|
||||
}, PUSH_MS);
|
||||
if (timer.unref) timer.unref();
|
||||
pushing.set(sessionId, timer);
|
||||
}
|
||||
|
||||
// Only a path we don't already track is interesting to the importer (a new
|
||||
// session file or a new project folder). Appends to a known active
|
||||
// transcript are left to the poll, so the watcher never re-parses a busy
|
||||
// session every write — it only tells the chats (`pushTranscript`).
|
||||
function onFsEvent(fullPath) {
|
||||
if (fullPath && mtimeCache.has(fullPath)) return;
|
||||
if (fullPath && mtimeCache.has(fullPath)) return void pushTranscript(fullPath);
|
||||
if (fullPath) pushTranscript(fullPath);
|
||||
scheduleSweep();
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* @file The one-at-a-time install/update runner behind Settings → AI Tools.
|
||||
* Every command comes from a catalog file (never a request) and runs as an
|
||||
* argv with stdin closed and the login shell's PATH first: `npm install -g
|
||||
* <pkg>@latest`, a tool's own updater, or a vendor script the server
|
||||
* <pkg>@latest` (with `--prefix ~/.local` when npm's global prefix is not the
|
||||
* account's to write — a system-wide Node), a tool's own updater, or a vendor script the server
|
||||
* downloads (https, same site as the tool's homepage, ≤ 1 MB) and runs with
|
||||
* `bash <file>`. The job lives in memory — a child of the server, so it dies
|
||||
* with it — and it reports its end only AFTER the PATH is re-read and the tool
|
||||
@@ -27,7 +28,8 @@ let jobTimeoutMs = 10 * 60 * 1000;
|
||||
const SCRIPT_MAX = 1024 * 1024;
|
||||
const EACCES_HINT =
|
||||
"\n[agenthub] npm could not write its global directory (EACCES): this Node is installed " +
|
||||
"system-wide. Use a user-level Node (nvm, fnm) or install the tool by hand.\n";
|
||||
"system-wide and ~/.local is not writable either. Use a user-level Node (nvm, fnm) or install " +
|
||||
"the tool by hand.\n";
|
||||
|
||||
const EXIT_GRACE_MS = 500; // after 'exit', how long to wait for the pipes to close
|
||||
const KILL_GRACE_MS = 5000; // SIGTERM → SIGKILL
|
||||
@@ -44,10 +46,11 @@ function makeErr(code, message) {
|
||||
return err;
|
||||
}
|
||||
|
||||
const npmArgv = (pkg) => {
|
||||
/** `prefix`: the `--prefix` args (none for npm's own global prefix). */
|
||||
const npmArgv = (pkg, prefix = []) => {
|
||||
const npm = versions.npmBin();
|
||||
if (!npm) throw makeErr("ENONPM", "npm was not found on the login shell's PATH");
|
||||
return [npm, "install", "-g", `${pkg}@latest`];
|
||||
return [npm, "install", "-g", ...prefix, `${pkg}@latest`];
|
||||
};
|
||||
|
||||
/** The steps one action on one tool would run — `{argv}` or `{script: url}`. */
|
||||
@@ -59,20 +62,22 @@ async function stepsFor(id, action) {
|
||||
if (!tool.install) throw makeErr("ENOINSTALL", `${tool.label} has no install command`);
|
||||
if (d.installed) throw makeErr("EINSTALLED", `${tool.label} is already installed`);
|
||||
return tool.install.kind === "npm"
|
||||
? [{ argv: npmArgv(tool.install.package) }]
|
||||
? [{ argv: npmArgv(tool.install.package, await versions.installPrefixArgs()) }]
|
||||
: [{ script: tool.install.url }];
|
||||
}
|
||||
if (!tool.update) throw makeErr("ENOUPDATE", `${tool.label} has no update command`);
|
||||
if (!d.installed) throw makeErr("ENOTINSTALLED", `${tool.label} is not installed`);
|
||||
if (tool.update.kind === "self") return [{ argv: [d.path, ...tool.update.args] }];
|
||||
if (tool.update.kind === "script") return [{ script: tool.install.url }];
|
||||
if (!(await versions.npmManaged(id))) {
|
||||
const by = await versions.managedBy(id);
|
||||
if (!by) {
|
||||
throw makeErr(
|
||||
"ENOTNPM",
|
||||
`${tool.label} was installed outside npm; update it the way it was installed`
|
||||
);
|
||||
}
|
||||
return [{ argv: npmArgv(tool.install.package) }];
|
||||
const prefix = by === "user" ? ["--prefix", versions.userPrefix()] : [];
|
||||
return [{ argv: npmArgv(tool.install.package, prefix) }];
|
||||
}
|
||||
|
||||
function append(j, text) {
|
||||
|
||||
@@ -5,10 +5,15 @@
|
||||
* standalone build, say — is never offered an npm update: it would install a
|
||||
* second copy that the PATH order shadows. npm is the LOGIN SHELL's (its PATH
|
||||
* first), not the pinned Node's beside the server. Cached for a day.
|
||||
* A system-wide Node (`/usr/local`, root's) leaves npm's global prefix
|
||||
* unwritable to the dashboard's account, so there a global install goes to the
|
||||
* account's own `~/.local` (`--prefix`, bin in `~/.local/bin`) instead, and a
|
||||
* tool found under `~/.local/lib/node_modules` counts as npm-managed there.
|
||||
* @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 { execFile } = require("node:child_process");
|
||||
const aiTools = require("./ai-tools");
|
||||
@@ -27,6 +32,8 @@ const defaultExec = (file, args, opts) =>
|
||||
)
|
||||
);
|
||||
let exec = defaultExec;
|
||||
let userPrefixImpl = () =>
|
||||
process.platform === "win32" ? null : path.join(os.homedir(), ".local");
|
||||
|
||||
/** The PATH a job runs with: the shell's own order first, then the server's. */
|
||||
const jobPath = () => [shellPath(), process.env.PATH].filter(Boolean).join(path.delimiter);
|
||||
@@ -62,17 +69,63 @@ function under(prefix, file) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether tool `id` is installed AND lives under npm's global prefix. */
|
||||
async function npmManaged(id, prefix) {
|
||||
/** The account's own npm prefix (`~/.local`), or null where npm has none (Windows). */
|
||||
const userPrefix = () => userPrefixImpl();
|
||||
|
||||
/** Whether npm can write `dir` — or, when it is not there yet, its nearest existing parent. */
|
||||
function writable(dir) {
|
||||
let d = dir;
|
||||
for (;;) {
|
||||
try {
|
||||
fs.accessSync(d, fs.constants.W_OK);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err.code !== "ENOENT") return false;
|
||||
}
|
||||
const up = path.dirname(d);
|
||||
if (up === d) return false;
|
||||
d = up;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a global install into `prefix` would succeed: its package and bin folders. */
|
||||
const prefixWritable = (prefix) =>
|
||||
process.platform === "win32"
|
||||
? writable(prefix)
|
||||
: writable(path.join(prefix, "lib", "node_modules")) && writable(path.join(prefix, "bin"));
|
||||
|
||||
/**
|
||||
* The `--prefix` a global install needs: none while npm's own global prefix is
|
||||
* writable, else the account's `~/.local` (a system-wide Node: EACCES otherwise).
|
||||
*/
|
||||
async function installPrefixArgs() {
|
||||
const npm = npmBin();
|
||||
const prefix = npm ? await npmPrefix(npm) : null;
|
||||
const up = userPrefix();
|
||||
if (!prefix || !up || prefixWritable(prefix)) return [];
|
||||
return ["--prefix", up];
|
||||
}
|
||||
|
||||
/**
|
||||
* Which npm prefix holds tool `id`: `"global"` (npm's own), `"user"` (the
|
||||
* account's `~/.local/lib/node_modules`), or null — not installed, or not by npm.
|
||||
*/
|
||||
async function managedBy(id, prefix) {
|
||||
const d = aiTools.detect(id);
|
||||
if (!d.installed) return false;
|
||||
if (!d.installed) return null;
|
||||
if (prefix === undefined) {
|
||||
const npm = npmBin();
|
||||
prefix = npm ? await npmPrefix(npm) : null;
|
||||
}
|
||||
return !!prefix && under(prefix, d.path);
|
||||
if (prefix && under(prefix, d.path)) return "global";
|
||||
const up = userPrefix();
|
||||
if (up && under(path.join(up, "lib", "node_modules"), d.path)) return "user";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Whether tool `id` is installed AND lives under npm's global or the account's prefix. */
|
||||
const npmManaged = async (id, prefix) => !!(await managedBy(id, prefix));
|
||||
|
||||
async function compute() {
|
||||
const npm = npmBin();
|
||||
const prefix = npm ? await npmPrefix(npm) : null;
|
||||
@@ -114,10 +167,30 @@ function invalidate() {
|
||||
cache = null;
|
||||
}
|
||||
|
||||
/** Test seam: the account's npm prefix (a path, or null for none); undefined restores it. */
|
||||
function __setUserPrefix(p) {
|
||||
userPrefixImpl =
|
||||
p === undefined
|
||||
? () => (process.platform === "win32" ? null : path.join(os.homedir(), ".local"))
|
||||
: () => p;
|
||||
}
|
||||
|
||||
/** Test seam: `fn(file, args, opts) → Promise<{stdout}>`, or null for execFile. */
|
||||
function __setExec(fn) {
|
||||
exec = fn || defaultExec;
|
||||
cache = null;
|
||||
}
|
||||
|
||||
module.exports = { versions, invalidate, npmManaged, npmBin, jobPath, parseVersion, __setExec };
|
||||
module.exports = {
|
||||
versions,
|
||||
invalidate,
|
||||
npmManaged,
|
||||
managedBy,
|
||||
installPrefixArgs,
|
||||
userPrefix,
|
||||
npmBin,
|
||||
jobPath,
|
||||
parseVersion,
|
||||
__setExec,
|
||||
__setUserPrefix,
|
||||
};
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* 5 cache read.
|
||||
* The session's name and start come from its transcript
|
||||
* (`brain/<id>/.system_generated/logs/transcript_full.jsonl`, first
|
||||
* `USER_INPUT`). A file is re-read whole when its mtime (or its non-empty
|
||||
* `USER_INPUT`), and the same file is the conversation view's
|
||||
* (`transcriptPath`, read by `GET /api/sessions/:id/transcript`). A file is re-read whole when its mtime (or its non-empty
|
||||
* `-wal`'s) changes — they are small — and the mtime per file lives in
|
||||
* `antigravity_conversations`, so a session the user deleted stays deleted
|
||||
* until agy writes to it again. Opening read-only still lets SQLite leave an
|
||||
@@ -157,6 +158,21 @@ const transcriptFiles = (id) =>
|
||||
return ["transcript_full.jsonl", "transcript.jsonl"].map((name) => path.join(dir, name));
|
||||
});
|
||||
|
||||
/** The transcript of conversation `id` (this account's or a member's) the
|
||||
* conversation view reads, or null. Only a UUID names one. */
|
||||
function transcriptPath(id) {
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(id || "")))
|
||||
return null;
|
||||
for (const file of transcriptFiles(id)) {
|
||||
try {
|
||||
if (fs.statSync(file).isFile()) return file;
|
||||
} catch {
|
||||
/* try the next name */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The transcript's last write, 0 when it has none. */
|
||||
function transcriptMtime(id) {
|
||||
for (const file of transcriptFiles(id)) {
|
||||
@@ -531,4 +547,5 @@ module.exports = {
|
||||
readConversation,
|
||||
ingestAntigravityUsage,
|
||||
startAntigravityUsagePoll,
|
||||
transcriptPath,
|
||||
};
|
||||
|
||||
+357
-64
@@ -23,15 +23,23 @@
|
||||
* their own rows plus shared ones, admin and analyst see every row (like
|
||||
* sessions), and only the owner (or an admin) changes or deletes one.
|
||||
* - Installing a shared skill or plugin is the viewer's click, into the
|
||||
* VIEWER's own account only (`installTarget`): a member's home under S3
|
||||
* VIEWER's own accounts only (`installAccounts`): a member's home under S3
|
||||
* (written AS them through `write-as`, `claude` run as them through sudo),
|
||||
* the host's Claude home for an admin or in local mode. An existing skill
|
||||
* plus the host's home for an admin (their host-folder workspaces run as
|
||||
* the host), or the host alone in local mode. A skill goes into BOTH
|
||||
* `~/.claude/skills/` and the cross-tool `~/.agents/skills/` of each, so
|
||||
* every AI tool and every session of the viewer finds it. An existing skill
|
||||
* is never overwritten without `replace`, and the old one is moved aside to
|
||||
* `~/.claude/agenthub-replaced-skills/`, never deleted. A plugin is
|
||||
* installed without `-y`, so one whose marketplace declares an install
|
||||
* command fails rather than runs unseen. An admin can turn installs off
|
||||
* for everyone (`setInstallsEnabled`). Share, unshare and install are
|
||||
* written to `audit_log`.
|
||||
* for everyone (`setInstallsEnabled`). Uninstall (`uninstall`) takes it out
|
||||
* of the same accounts again — a skill moved aside the same way, a plugin
|
||||
* `claude plugin uninstall --keep-data` — and is allowed with installs off.
|
||||
* Share, unshare, install and uninstall are written to `audit_log`.
|
||||
* - Editing a skill's name, description or instructions (`editSkill`) writes
|
||||
* a NEW snapshot and swaps the row to it; the old snapshot is dropped, and
|
||||
* copies already installed stay as they were (Reinstall picks it up).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -133,6 +141,7 @@ function view(row, req, owners = null) {
|
||||
size: row.size,
|
||||
shared: row.shared === 1,
|
||||
sharedAt: row.shared_at,
|
||||
inLibrary: row.in_library !== 0,
|
||||
installs: row.installs,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
@@ -142,13 +151,14 @@ function view(row, req, owners = null) {
|
||||
/**
|
||||
* The rows this request may see. `scope`: `mine` (own rows; every row in
|
||||
* local mode), `hub` (shared rows, server mode), anything else = all visible.
|
||||
* Each row also carries `installed` (`installedCheck`).
|
||||
*/
|
||||
function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } = {}) {
|
||||
const v = visibleWhere(req);
|
||||
const where = [v.where];
|
||||
const params = [...v.params];
|
||||
if (scope === "mine" && req?.user) {
|
||||
where.push("a.owner_id = ?");
|
||||
where.push("a.owner_id = ? AND a.in_library = 1");
|
||||
params.push(req.user.id);
|
||||
} else if (scope === "hub") {
|
||||
where.push("a.shared = 1");
|
||||
@@ -182,7 +192,8 @@ function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } =
|
||||
`SELECT a.* FROM artifacts a WHERE ${where.join(" AND ")} ORDER BY a.updated_at DESC, a.id DESC LIMIT ${n}`
|
||||
).all(...params);
|
||||
const owners = new Map();
|
||||
return rows.map((r) => view(r, req, owners));
|
||||
const installed = installedCheck(req);
|
||||
return rows.map((r) => ({ ...view(r, req, owners), installed: installed(r) }));
|
||||
}
|
||||
|
||||
// ── Small helpers ─────────────────────────────────────────────────────────
|
||||
@@ -255,7 +266,7 @@ function addPage(req, { url, title, description, laneId, sessionId }) {
|
||||
);
|
||||
if (same) {
|
||||
sql(
|
||||
"UPDATE artifacts SET title = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
|
||||
"UPDATE artifacts SET title = ?, in_library = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
|
||||
).run(cleanTitle(title, same.title), same.id);
|
||||
return view(sql("SELECT * FROM artifacts WHERE id = ?").get(same.id), req);
|
||||
}
|
||||
@@ -1114,7 +1125,7 @@ function addPlugin(req, { key, market, title, description }) {
|
||||
).get(k, ownerId);
|
||||
if (same) {
|
||||
sql(
|
||||
`UPDATE artifacts SET market = ?, title = ?, description = ?,
|
||||
`UPDATE artifacts SET market = ?, title = ?, description = ?, in_library = 1,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
|
||||
).run(
|
||||
src,
|
||||
@@ -1154,8 +1165,14 @@ function audit(req, row, action) {
|
||||
}
|
||||
|
||||
function update(req, id, body = {}) {
|
||||
const row = load(req, id);
|
||||
let row = load(req, id);
|
||||
if (!canChange(req, row)) throw fail("EFORBIDDEN", "only its owner changes an artifact");
|
||||
if (body.skill && typeof body.skill === "object") {
|
||||
editSkill(req, row, body.skill);
|
||||
row = sql("SELECT * FROM artifacts WHERE id = ?").get(row.id);
|
||||
if (!("title" in body) && !("shared" in body) && !("description" in body))
|
||||
return view(row, req);
|
||||
}
|
||||
const sets = [];
|
||||
const params = [];
|
||||
if ("title" in body) {
|
||||
@@ -1187,6 +1204,12 @@ function update(req, id, body = {}) {
|
||||
}
|
||||
}
|
||||
if (!sets.length) return view(row, req);
|
||||
if (shareChange === "unshared" && row.in_library === 0) {
|
||||
// Off the Hub and out of its owner's library: nothing keeps it.
|
||||
deleteRow(row);
|
||||
audit(req, row, "unshared");
|
||||
return { ...view(row, req), shared: false, removed: true };
|
||||
}
|
||||
sets.push("updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')");
|
||||
sql(`UPDATE artifacts SET ${sets.join(", ")} WHERE id = ?`).run(...params, row.id);
|
||||
const next = sql("SELECT * FROM artifacts WHERE id = ?").get(row.id);
|
||||
@@ -1194,15 +1217,86 @@ function update(req, id, body = {}) {
|
||||
return view(next, req);
|
||||
}
|
||||
|
||||
function deleteRow(row) {
|
||||
sql("DELETE FROM artifact_installs WHERE artifact_id = ?").run(row.id);
|
||||
sql("DELETE FROM artifacts WHERE id = ?").run(row.id);
|
||||
dropSnapshot(row.snapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete from the caller's library. A SHARED row its owner deletes stays in
|
||||
* the Hub (`in_library = 0`, `{kept: true}`) — only unsharing takes it off
|
||||
* there. Anything else (unshared, already Hub-only, or an admin removing
|
||||
* someone else's) is deleted with its snapshot.
|
||||
*/
|
||||
function remove(req, id) {
|
||||
const row = load(req, id);
|
||||
if (!canChange(req, row)) throw fail("EFORBIDDEN", "only its owner deletes an artifact");
|
||||
sql("DELETE FROM artifacts WHERE id = ?").run(row.id);
|
||||
dropSnapshot(row.snapshot);
|
||||
const owner = !req?.user || row.owner_id === req.user.id;
|
||||
if (owner && row.shared === 1 && row.in_library !== 0) {
|
||||
sql(
|
||||
"UPDATE artifacts SET in_library = 0, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
|
||||
).run(row.id);
|
||||
return { ...row, in_library: 0, kept: true };
|
||||
}
|
||||
deleteRow(row);
|
||||
if (row.shared === 1) audit(req, row, "deleted");
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* New contents for a skill row: `name` (the folder and /command),
|
||||
* `description` (frontmatter — also the card's description) and `content`
|
||||
* (SKILL.md's body, frontmatter excluded). The other frontmatter keys and
|
||||
* every other file are kept. A new snapshot replaces the old one.
|
||||
*/
|
||||
function editSkill(req, row, { name, description, content }) {
|
||||
if (row.kind !== "skill") throw fail("EBADKIND", "only a skill's contents are edited");
|
||||
if (!row.snapshot || !SNAPSHOT_RE.test(row.snapshot)) throw fail("ENOFILE", "no files");
|
||||
const n = validSkillName(name);
|
||||
const body = typeof content === "string" ? content : "";
|
||||
if (!body.trim()) throw fail("EBADSKILL", "write the skill's instructions");
|
||||
const recorded = row.files ? JSON.parse(row.files) : [];
|
||||
const files = recorded.map((f) => ({
|
||||
path: f.path,
|
||||
data: fs.readFileSync(path.join(root(), row.snapshot, ...f.path.split("/"))),
|
||||
}));
|
||||
const md = files.find((f) => f.path === "SKILL.md");
|
||||
const old = md ? md.data.toString("utf8").replace(/^\uFEFF/, "") : "";
|
||||
const fm = /^---\r?\n[\s\S]*?\r?\n---[^\n]*(\r?\n|$)/.exec(old);
|
||||
const text = withFrontmatter(
|
||||
`${fm ? fm[0] : ""}${fm ? "\n" : ""}${body.replace(/^\s+/, "")}`,
|
||||
n,
|
||||
description
|
||||
);
|
||||
const all = [
|
||||
{ path: "SKILL.md", data: Buffer.from(text, "utf8") },
|
||||
...files.filter((f) => f !== md),
|
||||
];
|
||||
checkSkillFiles(all);
|
||||
const snap = newSnapshotDir();
|
||||
let total = 0;
|
||||
const next = [];
|
||||
try {
|
||||
for (const f of all) {
|
||||
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 (err) {
|
||||
dropSnapshot(snap.id);
|
||||
throw err;
|
||||
}
|
||||
const desc = frontmatterField(text, "description");
|
||||
sql(
|
||||
`UPDATE artifacts SET source = ?, snapshot = ?, files = ?, size = ?, description = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
|
||||
).run(n, snap.id, JSON.stringify(next), total, cleanDescription(desc), row.id);
|
||||
dropSnapshot(row.snapshot);
|
||||
}
|
||||
|
||||
/** The absolute path of one RECORDED file of a row's snapshot (`rel` must match). */
|
||||
function snapshotFile(req, id, rel) {
|
||||
const row = load(req, id);
|
||||
@@ -1237,55 +1331,150 @@ function installable(req, row) {
|
||||
if (!installsEnabled()) throw fail("EINSTALLSOFF", "an admin turned Hub installs off");
|
||||
}
|
||||
|
||||
/** Install a skill snapshot into the viewer's own `~/.claude/skills/<name>`. */
|
||||
async function installSkill(req, row, { replace = false } = {}) {
|
||||
const { acct, claudeHome } = ownAccount(req);
|
||||
const runAs = require("./run-as");
|
||||
const skills = path.join(claudeHome, "skills");
|
||||
const target = path.join(skills, row.source);
|
||||
const taken = (() => {
|
||||
try {
|
||||
fs.lstatSync(target);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
if (taken && !replace) throw fail("EEXISTS", `a skill named ${row.source} is already installed`);
|
||||
if (taken) {
|
||||
const aside = path.join(
|
||||
claudeHome,
|
||||
"agenthub-replaced-skills",
|
||||
`${row.source}-${new Date().toISOString().replace(/[:.]/g, "-")}`
|
||||
);
|
||||
if (acct) {
|
||||
if (!runAs.writeAsCanMove()) {
|
||||
throw fail(
|
||||
"EUPGRADE",
|
||||
"replacing needs the updated helper: run `agenthub server install-system` once"
|
||||
);
|
||||
/**
|
||||
* Every account a request's sessions run as: `[{acct, home, claudeHome}]`.
|
||||
* Under S3 a pane runs as its FOLDER's account, so an admin with a member
|
||||
* account of their own also runs host-folder workspaces as the host — both
|
||||
* get the skill, or half their sessions never see it.
|
||||
*/
|
||||
function installAccounts(req) {
|
||||
const hostHome = path.dirname(getClaudeHome());
|
||||
const host = { acct: null, home: hostHome, claudeHome: getClaudeHome() };
|
||||
if (!req?.user) return [host];
|
||||
const own = ownAccount(req);
|
||||
const out = [];
|
||||
if (own.acct) out.push({ acct: own.acct, home: own.acct.home, claudeHome: own.claudeHome });
|
||||
if (req.user.role === "admin") out.push(host);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an account's AI tools look for user skills: Claude Code's
|
||||
* `~/.claude/skills` (also read by OpenCode, Cursor, omp, Grok) and the
|
||||
* cross-tool `~/.agents/skills` (Codex, Gemini/Antigravity, OpenCode, Cursor).
|
||||
*/
|
||||
const skillDirsOf = (a) => [
|
||||
path.join(a.claudeHome, "skills"),
|
||||
path.join(a.home, ".agents", "skills"),
|
||||
];
|
||||
|
||||
const exists = (p) => {
|
||||
try {
|
||||
fs.lstatSync(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* `row => boolean`: is this skill/plugin already in every account the
|
||||
* viewer's sessions run as (`installAccounts`) — a skill as
|
||||
* `~/.claude/skills/<name>`, a plugin as a key of `installed_plugins.json`.
|
||||
* The list shows "Installed" instead of Install for those. Pure reads; an
|
||||
* account that cannot be resolved or read counts as not installed.
|
||||
*/
|
||||
function installedCheck(req) {
|
||||
let accounts = null;
|
||||
const plugins = new Map();
|
||||
const pluginKeys = (a) => {
|
||||
if (!plugins.has(a.claudeHome)) {
|
||||
const file = path.join(a.claudeHome, "plugins", "installed_plugins.json");
|
||||
let keys = new Set();
|
||||
try {
|
||||
const text = a.acct
|
||||
? require("./run-as").readFileSafe(file, "utf8", { max: 4 << 20 })
|
||||
: fs.readFileSync(file, "utf8");
|
||||
keys = new Set(Object.keys(JSON.parse(text)?.plugins || {}));
|
||||
} catch {
|
||||
/* none installed, or unreadable */
|
||||
}
|
||||
runAs.mkdirAs(acct, path.dirname(aside));
|
||||
await runAs.writeAsAsync(acct, target, ["move", aside]);
|
||||
plugins.set(a.claudeHome, keys);
|
||||
}
|
||||
return plugins.get(a.claudeHome);
|
||||
};
|
||||
return (row) => {
|
||||
if ((row.kind !== "skill" && row.kind !== "plugin") || !row.source) return false;
|
||||
if (accounts === null) {
|
||||
try {
|
||||
accounts = installAccounts(req);
|
||||
} catch {
|
||||
accounts = [];
|
||||
}
|
||||
}
|
||||
if (!accounts.length) return false;
|
||||
return accounts.every((a) =>
|
||||
row.kind === "skill"
|
||||
? exists(path.join(a.claudeHome, "skills", row.source))
|
||||
: pluginKeys(a).has(row.source)
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Move installed skill folders (`{acct, claudeHome, target}`) aside to
|
||||
* `~/.claude/agenthub-replaced-skills/<name>[-agents][-uninstalled]-<stamp>`,
|
||||
* as their account — a replace or an uninstall never deletes a user's copy.
|
||||
*/
|
||||
async function moveAside(taken, row, label = "") {
|
||||
const runAs = require("./run-as");
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
for (const t of taken) {
|
||||
const tag = t.target.startsWith(t.claudeHome + path.sep) ? "" : "-agents";
|
||||
const aside = path.join(
|
||||
t.claudeHome,
|
||||
"agenthub-replaced-skills",
|
||||
`${row.source}${tag}${label}-${stamp}`
|
||||
);
|
||||
if (t.acct) {
|
||||
runAs.mkdirAs(t.acct, path.dirname(aside));
|
||||
await runAs.writeAsAsync(t.acct, t.target, ["move", aside]);
|
||||
} else {
|
||||
fs.mkdirSync(path.dirname(aside), { recursive: true });
|
||||
fs.renameSync(target, aside);
|
||||
fs.renameSync(t.target, aside);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a skill snapshot as `<name>` into every skills folder of every
|
||||
* account the viewer's sessions run as (`installAccounts` × `skillDirsOf`).
|
||||
*/
|
||||
async function installSkill(req, row, { replace = false } = {}) {
|
||||
const runAs = require("./run-as");
|
||||
const targets = installAccounts(req).flatMap((a) =>
|
||||
skillDirsOf(a).map((dir) => ({ ...a, target: path.join(dir, row.source) }))
|
||||
);
|
||||
const taken = targets.filter((t) => exists(t.target));
|
||||
if (taken.length && !replace) {
|
||||
throw fail("EEXISTS", `a skill named ${row.source} is already installed`);
|
||||
}
|
||||
if (taken.some((t) => t.acct) && !runAs.writeAsCanMove()) {
|
||||
throw fail(
|
||||
"EUPGRADE",
|
||||
"replacing needs the updated helper: run `agenthub server install-system` once"
|
||||
);
|
||||
}
|
||||
await moveAside(taken, row);
|
||||
const files = row.files ? JSON.parse(row.files) : [];
|
||||
for (const f of files) {
|
||||
const from = path.join(root(), row.snapshot, ...f.path.split("/"));
|
||||
const to = path.join(target, ...f.path.split("/"));
|
||||
const content = fs.readFileSync(from);
|
||||
if (acct) {
|
||||
runAs.mkdirAs(acct, path.dirname(to));
|
||||
runAs.writeFileAs(to, content);
|
||||
} else {
|
||||
fs.mkdirSync(path.dirname(to), { recursive: true });
|
||||
fs.writeFileSync(to, content);
|
||||
const contents = files.map((f) => ({
|
||||
rel: f.path.split("/"),
|
||||
content: fs.readFileSync(path.join(root(), row.snapshot, ...f.path.split("/"))),
|
||||
}));
|
||||
for (const t of targets) {
|
||||
for (const f of contents) {
|
||||
const to = path.join(t.target, ...f.rel);
|
||||
if (t.acct) {
|
||||
runAs.mkdirAs(t.acct, path.dirname(to));
|
||||
runAs.writeFileAs(to, f.content);
|
||||
} else {
|
||||
fs.mkdirSync(path.dirname(to), { recursive: true });
|
||||
fs.writeFileSync(to, f.content);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { installed: target, replaced: taken };
|
||||
const paths = targets.map((t) => t.target);
|
||||
return { installed: paths.join(", "), paths, replaced: taken.length > 0 };
|
||||
}
|
||||
|
||||
let execImpl = promisify(require("node:child_process").execFile);
|
||||
@@ -1293,14 +1482,9 @@ function __setExecImpl(fn) {
|
||||
execImpl = fn;
|
||||
}
|
||||
|
||||
/** `claude plugin marketplace add` + `claude plugin install --scope user`, as the viewer. */
|
||||
async function installPlugin(req, row) {
|
||||
/** `args => Promise`: runs `claude <args>` as the viewer's own account. */
|
||||
function claudeAsViewer(req) {
|
||||
const { acct } = ownAccount(req);
|
||||
const steps = [
|
||||
["plugin", "marketplace", "add", validMarket(row.market)],
|
||||
["plugin", "install", "--scope", "user", row.source],
|
||||
];
|
||||
if (!PLUGIN_RE.test(row.source)) throw fail("EBADPLUGIN", "plugin: name@marketplace");
|
||||
let run;
|
||||
if (acct) {
|
||||
const runAs = require("./run-as");
|
||||
@@ -1316,11 +1500,23 @@ async function installPlugin(req, row) {
|
||||
if (process.env.CLAUDE_HOME) env.CLAUDE_CONFIG_DIR = getClaudeHome();
|
||||
run = (args) => execImpl("claude", args, { timeout: 300_000, encoding: "utf8", env });
|
||||
}
|
||||
const lastLine = (e) =>
|
||||
String(e?.stderr || e?.stdout || e?.message || "")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.pop();
|
||||
return run;
|
||||
}
|
||||
|
||||
const lastLine = (e) =>
|
||||
String(e?.stderr || e?.stdout || e?.message || "")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.pop();
|
||||
|
||||
/** `claude plugin marketplace add` + `claude plugin install --scope user`, as the viewer. */
|
||||
async function installPlugin(req, row) {
|
||||
const steps = [
|
||||
["plugin", "marketplace", "add", validMarket(row.market)],
|
||||
["plugin", "install", "--scope", "user", row.source],
|
||||
];
|
||||
if (!PLUGIN_RE.test(row.source)) throw fail("EBADPLUGIN", "plugin: name@marketplace");
|
||||
const run = claudeAsViewer(req);
|
||||
try {
|
||||
await run(steps[0]);
|
||||
} catch (e) {
|
||||
@@ -1343,12 +1539,108 @@ async function install(req, id, opts = {}) {
|
||||
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);
|
||||
sql(
|
||||
"INSERT INTO artifact_installs (artifact_id, user_id, paths, replaced) VALUES (?, ?, ?, ?)"
|
||||
).run(row.id, req?.user?.id ?? null, JSON.stringify(result.paths || []), result.replaced ? 1 : 0);
|
||||
if (req?.user && row.owner_id !== req.user.id) audit(req, row, "installed");
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take an installed skill or plugin out of the viewer's own accounts again
|
||||
* (`installAccounts`). A skill's folders are moved aside like a replace
|
||||
* (`moveAside`, `-uninstalled`), never deleted; a plugin is `claude plugin
|
||||
* uninstall --scope user --keep-data`, as the viewer, so its data dir stays.
|
||||
* Allowed while installs are off — it only removes. `ENOTINSTALLED` when
|
||||
* there is nothing of it to remove.
|
||||
*/
|
||||
async function uninstall(req, id) {
|
||||
const row = load(req, id);
|
||||
if (row.kind !== "skill" && row.kind !== "plugin") {
|
||||
throw fail("EBADKIND", "only a skill or a plugin is uninstalled");
|
||||
}
|
||||
let result;
|
||||
if (row.kind === "skill") {
|
||||
const runAs = require("./run-as");
|
||||
const taken = installAccounts(req)
|
||||
.flatMap((a) => skillDirsOf(a).map((dir) => ({ ...a, target: path.join(dir, row.source) })))
|
||||
.filter((t) => exists(t.target));
|
||||
if (!taken.length) throw fail("ENOTINSTALLED", `${row.source} is not installed`);
|
||||
if (taken.some((t) => t.acct) && !runAs.writeAsCanMove()) {
|
||||
throw fail(
|
||||
"EUPGRADE",
|
||||
"uninstalling needs the updated helper: run `agenthub server install-system` once"
|
||||
);
|
||||
}
|
||||
await moveAside(taken, row, "-uninstalled");
|
||||
result = { uninstalled: row.source, paths: taken.map((t) => t.target) };
|
||||
} else {
|
||||
if (!PLUGIN_RE.test(row.source)) throw fail("EBADPLUGIN", "plugin: name@marketplace");
|
||||
try {
|
||||
await claudeAsViewer(req)([
|
||||
"plugin",
|
||||
"uninstall",
|
||||
"--scope",
|
||||
"user",
|
||||
"--keep-data",
|
||||
row.source,
|
||||
]);
|
||||
} catch (e) {
|
||||
const msg = lastLine(e);
|
||||
if (/not (found|installed)/i.test(msg)) {
|
||||
throw fail("ENOTINSTALLED", `${row.source} is not installed`);
|
||||
}
|
||||
throw fail("EPLUGIN", `uninstall: ${msg}`);
|
||||
}
|
||||
result = { uninstalled: row.source, paths: [] };
|
||||
}
|
||||
if (req?.user && row.owner_id !== req.user.id) audit(req, row, "uninstalled");
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who installed artifact `id`, newest first: `{items: [{user, mine, at,
|
||||
* replaced, paths?}], unrecorded}`. The owner, staff and local mode see every
|
||||
* install; anyone else only their own. `paths` (where a skill was written; a
|
||||
* plugin has none — it is `claude plugin install --scope user`) goes only to
|
||||
* the installer and an admin. `unrecorded` = installs counted before this
|
||||
* list was kept.
|
||||
*/
|
||||
function installsOf(req, id) {
|
||||
const row = load(req, id);
|
||||
const u = req?.user;
|
||||
const all = !u || u.role === "admin" || u.role === "analyst" || row.owner_id === u.id;
|
||||
const rows = sql(
|
||||
`SELECT i.*, us.username, us.display_name FROM artifact_installs i
|
||||
LEFT JOIN users us ON us.id = i.user_id
|
||||
WHERE i.artifact_id = ? ${all ? "" : "AND i.user_id = ?"}
|
||||
ORDER BY i.id DESC LIMIT 500`
|
||||
).all(...(all ? [row.id] : [row.id, u.id]));
|
||||
const items = rows.map((r) => {
|
||||
const mine = !u || r.user_id === u.id;
|
||||
let paths = [];
|
||||
try {
|
||||
paths = JSON.parse(r.paths) || [];
|
||||
} catch {
|
||||
paths = [];
|
||||
}
|
||||
return {
|
||||
user: r.user_id == null ? null : r.display_name || r.username || null,
|
||||
mine,
|
||||
at: r.installed_at,
|
||||
replaced: r.replaced === 1,
|
||||
...(mine || u?.role === "admin" ? { paths } : {}),
|
||||
};
|
||||
});
|
||||
const total = sql("SELECT COUNT(*) AS n FROM artifact_installs WHERE artifact_id = ?").get(
|
||||
row.id
|
||||
).n;
|
||||
return { items, unrecorded: all ? Math.max(0, (row.installs || 0) - total) : 0 };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
KINDS,
|
||||
installsOf,
|
||||
DOC_EXT,
|
||||
list,
|
||||
load,
|
||||
@@ -1372,6 +1664,7 @@ module.exports = {
|
||||
remove,
|
||||
snapshotFile,
|
||||
install,
|
||||
uninstall,
|
||||
installsEnabled,
|
||||
setInstallsEnabled,
|
||||
__setExecImpl,
|
||||
|
||||
@@ -215,10 +215,15 @@ function readSkillsAt(scope, claudeDir) {
|
||||
return skills.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{scope?: string, cwd?: string, userClaudeDir?: string}} opts
|
||||
* `userClaudeDir` = whose user scope to read (a member's `~/.claude` under
|
||||
* S3); the host's Claude home when absent.
|
||||
*/
|
||||
function readSkills(opts = {}) {
|
||||
const out = [];
|
||||
if (opts.scope !== "project") {
|
||||
out.push(...readSkillsAt("user", getClaudeHome()));
|
||||
out.push(...readSkillsAt("user", opts.userClaudeDir || getClaudeHome()));
|
||||
}
|
||||
if (opts.scope !== "user") {
|
||||
out.push(...readSkillsAt("project", getProjectClaudeDir(opts.cwd)));
|
||||
@@ -257,7 +262,7 @@ function readSimpleMdSurface(subdir) {
|
||||
return (opts = {}) => {
|
||||
const out = [];
|
||||
if (opts.scope !== "project") {
|
||||
out.push(...readMdFilesAt("user", getClaudeHome(), subdir));
|
||||
out.push(...readMdFilesAt("user", opts.userClaudeDir || getClaudeHome(), subdir));
|
||||
}
|
||||
if (opts.scope !== "user") {
|
||||
out.push(...readMdFilesAt("project", getProjectClaudeDir(opts.cwd), subdir));
|
||||
@@ -423,18 +428,18 @@ function readPluginContributions(installPath) {
|
||||
};
|
||||
}
|
||||
|
||||
function readEnabledPluginsMap() {
|
||||
const userSettings = readJson(path.join(getClaudeHome(), "settings.json"));
|
||||
function readEnabledPluginsMap(home = getClaudeHome()) {
|
||||
const userSettings = readJson(path.join(home, "settings.json"));
|
||||
if (!userSettings.ok || !userSettings.data) return {};
|
||||
const ep = userSettings.data.enabledPlugins;
|
||||
return ep && typeof ep === "object" ? ep : {};
|
||||
}
|
||||
|
||||
function readPlugins() {
|
||||
const home = getClaudeHome();
|
||||
/** @param {string} [home] whose Claude home (a member's under S3); the host's by default. */
|
||||
function readPlugins(home = getClaudeHome()) {
|
||||
const manifestPath = path.join(home, "plugins", "installed_plugins.json");
|
||||
const manifest = readJson(manifestPath);
|
||||
const enabledMap = readEnabledPluginsMap();
|
||||
const enabledMap = readEnabledPluginsMap(home);
|
||||
const plugins = [];
|
||||
if (manifest.ok && manifest.data && manifest.data.plugins) {
|
||||
for (const [pluginKey, instances] of Object.entries(manifest.data.plugins)) {
|
||||
@@ -847,7 +852,11 @@ function isSecretConfigPath(resolved) {
|
||||
const base = path.basename(resolved);
|
||||
if (!base.endsWith(".md") && require("./ai-tools").CONFIG_SECRET_RE.test(base)) return true;
|
||||
const rel = path.relative(getClaudeHome(), resolved);
|
||||
if (!rel.startsWith("..") && !path.isAbsolute(rel) && rel.split(path.sep)[0].startsWith("agenthub-"))
|
||||
if (
|
||||
!rel.startsWith("..") &&
|
||||
!path.isAbsolute(rel) &&
|
||||
rel.split(path.sep)[0].startsWith("agenthub-")
|
||||
)
|
||||
return true;
|
||||
// AgentHub's data dir is no Claude config: it holds the database, the push
|
||||
// keys and every gateway pane's settings file WITH its live pane token.
|
||||
|
||||
@@ -18,7 +18,11 @@
|
||||
* is resolved only when exactly such a path still exists, else left empty.
|
||||
* A file is re-read when its mtime changes, and the mtime per file lives in
|
||||
* `cursor_transcripts`, so a session the user deleted stays deleted until
|
||||
* Cursor writes to it again. No transcript is ever stored as `transcript_path`.
|
||||
* Cursor writes to it again. A session a dashboard pane started is claimed
|
||||
* for that pane's workspace and run (`claimToolSessions`), which gives the
|
||||
* pane its session id and so its Chat view. No transcript is ever stored as
|
||||
* `transcript_path`; the conversation view finds the file through
|
||||
* `transcriptPath` instead.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -26,6 +30,7 @@ const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { db, stmts } = require("../db");
|
||||
const { claimToolSessions } = require("./dashboard-runs");
|
||||
|
||||
const ACTIVE_MS = 120_000; // the window codex-usage.js calls a session active
|
||||
const FUTURE_SLACK_MS = 1000;
|
||||
@@ -148,6 +153,14 @@ function transcriptFilesIn(cursorDir) {
|
||||
}
|
||||
|
||||
const getSeen = db.prepare("SELECT * FROM cursor_transcripts WHERE path = ?");
|
||||
const getPathOf = db.prepare(
|
||||
"SELECT path FROM cursor_transcripts WHERE session_id = ? ORDER BY mtime_ms DESC LIMIT 1"
|
||||
);
|
||||
|
||||
/** The transcript a Cursor session was read from (recorded by the poller), or null. */
|
||||
function transcriptPath(sessionId) {
|
||||
return getPathOf.get(sessionId)?.path || null;
|
||||
}
|
||||
const upsertSeen = db.prepare(
|
||||
`INSERT INTO cursor_transcripts (path, session_id, mtime_ms, updated_at)
|
||||
VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
@@ -235,6 +248,7 @@ function ingestCursorUsage({ broadcast = () => {}, now = Date.now() } = {}) {
|
||||
upsertSeen.run(f.path, f.id, f.mtime);
|
||||
if (type) {
|
||||
written += 1;
|
||||
claimToolSessions(f.id); // its workspace and live pane, before the broadcast
|
||||
broadcast(type, stmts.getSession.get(f.id));
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -273,4 +287,5 @@ module.exports = {
|
||||
firstPrompt,
|
||||
ingestCursorUsage,
|
||||
startCursorUsagePoll,
|
||||
transcriptPath,
|
||||
};
|
||||
|
||||
@@ -90,7 +90,7 @@ function bindable(v) {
|
||||
*/
|
||||
/** A run row as it may leave this machine: never a pane credential's hash. */
|
||||
function exportableRun(row) {
|
||||
const { pane_token_hash: _secret, ...rest } = row;
|
||||
const { pane_token_hash: _secret, chat_token_hash: _chat, ...rest } = row;
|
||||
return rest;
|
||||
}
|
||||
|
||||
@@ -185,7 +185,9 @@ function importExportBundle(db, bundle) {
|
||||
events: makeInserter(db, "events", { omit: ["id"], wrap: { data: "trim_event_data" } }),
|
||||
token_usage: makeInserter(db, "token_usage"),
|
||||
workflows: makeInserter(db, "workflows"),
|
||||
dashboard_runs: makeInserter(db, "dashboard_runs", { omit: ["pane_token_hash"] }),
|
||||
dashboard_runs: makeInserter(db, "dashboard_runs", {
|
||||
omit: ["pane_token_hash", "chat_token_hash"],
|
||||
}),
|
||||
alert_rules: makeInserter(db, "alert_rules"),
|
||||
model_pricing: makeInserter(db, "model_pricing"),
|
||||
};
|
||||
|
||||
+351
-2
@@ -4,7 +4,8 @@
|
||||
* or into another of the same account), delete into a per-account TRASH that
|
||||
* can be restored (kept TRASH_DAYS) — and, for `/api/homes`, one recycle bin
|
||||
* per USER over every store their deletes went to — upload a file streamed from the request
|
||||
* into any (new) sub-folder, and download files or folders as one streamed zip.
|
||||
* into any (new) sub-folder (or many small ones as ONE batch request), and
|
||||
* download files or folders as one streamed zip.
|
||||
*
|
||||
* Every path is the read path's own (git-diff.js): `lanePath` (lexical, no
|
||||
* `..`), the PARENT resolved by `resolveInLane` (realpath-confined, so no
|
||||
@@ -37,7 +38,8 @@ const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const zlib = require("node:zlib");
|
||||
const { Readable, Transform } = require("node:stream");
|
||||
const { PassThrough, Readable, Transform } = require("node:stream");
|
||||
const { once } = require("node:events");
|
||||
const { AsyncLocalStorage } = require("node:async_hooks");
|
||||
const { pipeline } = require("node:stream/promises");
|
||||
|
||||
@@ -706,6 +708,351 @@ async function upload(dir, rel, stream, { conflict, length, trash } = {}) {
|
||||
return { path: loc.rel };
|
||||
}
|
||||
|
||||
// ── Batch upload ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// Many small files cost per FILE, not per byte: one request, one path walk
|
||||
// and — in a member's folder — one sudo write-as each. A batch carries them
|
||||
// in ONE request: a 4-byte big-endian length, that many bytes of JSON
|
||||
// `{files: [{path, size, mtime?}]}` (paths relative to `?dir=`), then each
|
||||
// file's bytes in that order. Every name is checked like `upload`'s and the
|
||||
// whole batch is planned (folders, conflicts) before the first write; a
|
||||
// member's batch is written by ONE `write-as <dir> extract` from a tar this
|
||||
// module writes itself (only folders and regular files, names it checked).
|
||||
|
||||
const BATCH_MAX_FILES = 5000;
|
||||
/** A file in a batch is held in memory whole; bigger ones go through `upload`. */
|
||||
const BATCH_FILE_MAX = 16 * 1024 * 1024;
|
||||
const BATCH_MAX_BYTES = 256 * 1024 * 1024;
|
||||
const BATCH_HEAD_MAX = 4 * 1024 * 1024;
|
||||
|
||||
/** Whether a member's batch can be written (write-as `extract`, install-system re-run). */
|
||||
function needExtract(acct) {
|
||||
if (acct && !runAs.writeAsCanExtract()) {
|
||||
throw bad(
|
||||
"EUPGRADE",
|
||||
"uploading many files at once into a member's folder needs the updated helper: run `agenthub server install-system` once"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads exact byte counts from a stream (the request body). */
|
||||
function byteReader(stream) {
|
||||
const it = stream[Symbol.asyncIterator]();
|
||||
let chunks = [];
|
||||
let have = 0;
|
||||
return {
|
||||
async read(n) {
|
||||
while (have < n) {
|
||||
const { value, done } = await it.next();
|
||||
if (done) throw bad("EBADBODY", "the upload ended early");
|
||||
const b = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
||||
chunks.push(b);
|
||||
have += b.length;
|
||||
}
|
||||
const all = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks, have);
|
||||
const rest = all.subarray(n);
|
||||
chunks = rest.length ? [rest] : [];
|
||||
have = rest.length;
|
||||
return all.subarray(0, n);
|
||||
},
|
||||
/** Consume whatever is left, so an answer can still be sent. */
|
||||
async drain() {
|
||||
chunks = [];
|
||||
have = 0;
|
||||
for (;;) if ((await it.next()).done) return;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The `files` list of a batch, each `{path, size, mtime}` checked for shape. */
|
||||
function batchFiles(list) {
|
||||
if (!Array.isArray(list) || !list.length) throw bad("EBADBODY", "a batch names its files");
|
||||
if (list.length > BATCH_MAX_FILES) {
|
||||
throw bad("ETOOBIG", `a batch holds at most ${BATCH_MAX_FILES} files`);
|
||||
}
|
||||
let total = 0;
|
||||
return list.map((f) => {
|
||||
const size = f?.size ?? 0;
|
||||
if (typeof f?.path !== "string" || !Number.isSafeInteger(size) || size < 0) {
|
||||
throw bad("EBADBODY", "each file is {path, size}");
|
||||
}
|
||||
if (size > BATCH_FILE_MAX) {
|
||||
throw bad("ETOOBIG", `a file in a batch may be at most ${BATCH_FILE_MAX / 1024 / 1024} MB`);
|
||||
}
|
||||
total += size;
|
||||
if (total > BATCH_MAX_BYTES) {
|
||||
throw bad("ETOOBIG", `a batch may be at most ${BATCH_MAX_BYTES / 1024 / 1024} MB`);
|
||||
}
|
||||
const mtime = Number.isFinite(f.mtime) && f.mtime > 0 ? f.mtime : Date.now();
|
||||
return { path: f.path, size, mtime };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Where each file of a batch goes, under `conflict`: the folders to make
|
||||
* (parents first) and per file its target, or `skip`/`replace`. `taken`
|
||||
* lists the names already there (paths relative to the batch's folder);
|
||||
* under `fail` they are not resolved — the caller refuses or reports them.
|
||||
*/
|
||||
async function planBatch(dir, dirRel, files, conflict) {
|
||||
const base = await folder(dir, dirRel || "");
|
||||
if (gitInside(base.rel)) throw bad("EBADPATH", "nothing is uploaded into .git");
|
||||
const full = (r) => (base.rel ? `${base.rel}/${r}` : r);
|
||||
// rel under base → {rel (workspace), abs, missing}
|
||||
const dirs = new Map([["", { rel: base.rel, abs: base.abs, missing: false }]]);
|
||||
const make = [];
|
||||
const dirOf = async (r) => {
|
||||
if (dirs.has(r)) return dirs.get(r);
|
||||
const upRel = path.posix.dirname(r) === "." ? "" : path.posix.dirname(r);
|
||||
const up = await dirOf(upRel);
|
||||
const name = path.posix.basename(r);
|
||||
const d = { rel: full(r), abs: path.join(up.abs, name), missing: true };
|
||||
if (up.missing) {
|
||||
if (topDot(base.root, d.abs)) throw hidden();
|
||||
if (await isSecretOnDisk(dir, d.rel)) throw bad("ESECRET", "that path holds secrets");
|
||||
} else {
|
||||
const loc = await locate(dir, d.rel);
|
||||
if (loc.st && !loc.st.isDirectory()) {
|
||||
throw bad("EEXISTS", `${d.rel} is a file, not a folder`, { names: [r] });
|
||||
}
|
||||
d.missing = !loc.st;
|
||||
}
|
||||
if (d.missing) make.push(d);
|
||||
dirs.set(r, d);
|
||||
return d;
|
||||
};
|
||||
|
||||
const seen = new Set();
|
||||
// A free name for `keep` is free on disk AND not another file of this batch.
|
||||
const claimed = new Set();
|
||||
const own = new Set(
|
||||
files.map((f) => {
|
||||
try {
|
||||
return lanePath(f.path);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
})
|
||||
);
|
||||
const out = [];
|
||||
const taken = [];
|
||||
for (const f of files) {
|
||||
const r = lanePath(f.path);
|
||||
for (const seg of r.split("/")) validName(seg);
|
||||
if (gitInside(full(r))) throw bad("EBADPATH", "nothing is uploaded into .git");
|
||||
if (seen.has(r)) throw bad("EBADPATH", `${r} is named twice`);
|
||||
seen.add(r);
|
||||
const upRel = path.posix.dirname(r) === "." ? "" : path.posix.dirname(r);
|
||||
const up = await dirOf(upRel);
|
||||
let name = path.posix.basename(r);
|
||||
let st = null;
|
||||
if (up.missing) {
|
||||
if (await isSecretOnDisk(dir, full(r))) throw bad("ESECRET", "that path holds secrets");
|
||||
} else {
|
||||
const loc = await locate(dir, full(r));
|
||||
st = loc.st;
|
||||
}
|
||||
const step = { ...f, rel: r, name, up, skip: false, replace: false };
|
||||
if (st) {
|
||||
if (st.isDirectory()) throw bad("EEXISTS", `${full(r)} is a folder`, { names: [r] });
|
||||
taken.push(r);
|
||||
if (conflict === "skip") step.skip = true;
|
||||
else if (conflict === "replace") step.replace = true;
|
||||
else if (conflict === "keep") {
|
||||
const ext = name.startsWith(".") ? "" : path.extname(name);
|
||||
const stem = ext ? name.slice(0, -ext.length) : name;
|
||||
for (let i = 1; ; i++) {
|
||||
if (i >= 10_000) throw bad("EEXISTS", `no free name for ${name}`);
|
||||
const n = `${stem} (${i})${ext}`;
|
||||
const abs = path.join(up.abs, n);
|
||||
const nRel = upRel ? `${upRel}/${n}` : n;
|
||||
if (claimed.has(abs) || own.has(nRel)) continue;
|
||||
if (await fs.promises.lstat(abs).catch(() => null)) continue;
|
||||
name = n;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
step.name = name;
|
||||
step.abs = path.join(up.abs, name);
|
||||
step.full = up.rel ? `${up.rel}/${name}` : name;
|
||||
claimed.add(step.abs);
|
||||
out.push(step);
|
||||
}
|
||||
for (const r of seen) {
|
||||
if (dirs.has(r)) throw bad("EBADPATH", `${r} is both a file and a folder`);
|
||||
}
|
||||
return { base, make, files: out, taken };
|
||||
}
|
||||
|
||||
/**
|
||||
* `{taken, batch}` for a batch about to be sent: the names already in
|
||||
* `dirRel` (relative to it), and whether a batch can be written there at all
|
||||
* (false: send the files one by one).
|
||||
*/
|
||||
async function uploadCheck(dir, dirRel, paths) {
|
||||
if (!Array.isArray(paths) || !paths.every((p) => typeof p === "string")) {
|
||||
throw bad("EBADBODY", "paths is a list of relative paths");
|
||||
}
|
||||
const files = batchFiles(paths.map((p) => ({ path: p, size: 0 })));
|
||||
const plan = await planBatch(dir, dirRel, files, "fail");
|
||||
let batch = true;
|
||||
try {
|
||||
needExtract(accountAt(plan.base.abs));
|
||||
} catch {
|
||||
batch = false;
|
||||
}
|
||||
return { taken: plan.taken, batch };
|
||||
}
|
||||
|
||||
/** One octal tar field of `len` bytes. */
|
||||
function tarOctal(buf, off, len, n) {
|
||||
buf.write(`${n.toString(8).padStart(len - 1, "0")}\0`, off, len, "ascii");
|
||||
}
|
||||
|
||||
/** A ustar header block for `name` (a pax `path` record first when it is long). */
|
||||
function tarHeader(name, { type, size, mode, mtime }) {
|
||||
const block = (n, t, sz) => {
|
||||
const h = Buffer.alloc(512);
|
||||
h.write(n, 0, 100, "utf8");
|
||||
tarOctal(h, 100, 8, mode);
|
||||
tarOctal(h, 108, 8, 0);
|
||||
tarOctal(h, 116, 8, 0);
|
||||
tarOctal(h, 124, 12, sz);
|
||||
tarOctal(h, 136, 12, Math.floor(mtime / 1000));
|
||||
h.fill(" ", 148, 156);
|
||||
h.write(t, 156, 1, "ascii");
|
||||
h.write("ustar\0" + "00", 257, 8, "ascii");
|
||||
let sum = 0;
|
||||
for (const b of h) sum += b;
|
||||
h.write(`${sum.toString(8).padStart(6, "0")}\0 `, 148, 8, "ascii");
|
||||
return h;
|
||||
};
|
||||
if (Buffer.byteLength(name) <= 100) return block(name, type, size);
|
||||
// "<len> path=<name>\n", where <len> counts its own digits.
|
||||
const body = ` path=${name}\n`;
|
||||
let len = Buffer.byteLength(body);
|
||||
while (String(len).length + Buffer.byteLength(body) !== len) {
|
||||
len = String(len).length + Buffer.byteLength(body);
|
||||
}
|
||||
const rec = Buffer.from(`${len}${body}`, "utf8");
|
||||
return Buffer.concat([block("PaxHeader", "x", rec.length), tarPad(rec), block("", type, size)]);
|
||||
}
|
||||
|
||||
function tarPad(buf) {
|
||||
const rest = buf.length % 512;
|
||||
return rest ? Buffer.concat([buf, Buffer.alloc(512 - rest)]) : buf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a batch streamed from `stream` (see above) into `dirRel`. Returns
|
||||
* `{saved, skipped, paths}`; under `conflict: "fail"` a taken name is 409
|
||||
* EEXISTS with every taken path in `names`.
|
||||
*/
|
||||
async function uploadBatch(dir, dirRel, stream, { conflict, trash } = {}) {
|
||||
const reader = byteReader(stream);
|
||||
try {
|
||||
const n = (await reader.read(4)).readUInt32BE(0);
|
||||
if (n > BATCH_HEAD_MAX) throw bad("ETOOBIG", "a batch's file list is too long");
|
||||
let head;
|
||||
try {
|
||||
head = JSON.parse((await reader.read(n)).toString("utf8"));
|
||||
} catch (e) {
|
||||
if (e.code) throw e;
|
||||
throw bad("EBADBODY", "a batch starts with its file list as JSON");
|
||||
}
|
||||
const files = batchFiles(head?.files);
|
||||
const policy = conflictOf(conflict);
|
||||
const plan = await planBatch(dir, dirRel, files, policy);
|
||||
if (policy === "fail" && plan.taken.length) {
|
||||
throw bad("EEXISTS", `${plan.taken.length} name(s) already exist`, { names: plan.taken });
|
||||
}
|
||||
const acct = accountAt(plan.base.abs);
|
||||
needExtract(acct);
|
||||
for (const f of plan.files) {
|
||||
if (f.replace) await trash(f.full);
|
||||
}
|
||||
const done = [];
|
||||
let skipped = 0;
|
||||
if (acct) {
|
||||
const tar = new PassThrough();
|
||||
const put = async (buf) => {
|
||||
// write-as gone (it failed): a destroyed stream never drains.
|
||||
if (tar.destroyed) throw bad("EWRITEAS", "the batch could not be written");
|
||||
if (!tar.write(buf)) await once(tar, "drain");
|
||||
};
|
||||
const now = Date.now();
|
||||
const extract = runAs.writeAsAsync(acct, plan.base.abs, ["extract"], { input: tar });
|
||||
const feed = (async () => {
|
||||
for (const d of plan.make) {
|
||||
const name = `${path.relative(plan.base.abs, d.abs).split(path.sep).join("/")}/`;
|
||||
await put(tarHeader(name, { type: "5", size: 0, mode: 0o755, mtime: now }));
|
||||
}
|
||||
for (const f of plan.files) {
|
||||
const data = await reader.read(f.size);
|
||||
if (f.skip) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const name = path.relative(plan.base.abs, f.abs).split(path.sep).join("/");
|
||||
await put(tarHeader(name, { type: "0", size: f.size, mode: 0o644, mtime: f.mtime }));
|
||||
await put(tarPad(data));
|
||||
done.push(f.full);
|
||||
}
|
||||
tar.end(Buffer.alloc(1024));
|
||||
})();
|
||||
await Promise.all([
|
||||
feed.catch((e) => {
|
||||
tar.destroy(e);
|
||||
throw e;
|
||||
}),
|
||||
extract.catch((e) => {
|
||||
tar.destroy(e);
|
||||
throw e;
|
||||
}),
|
||||
]);
|
||||
} else {
|
||||
for (const d of plan.make) {
|
||||
await fs.promises.mkdir(d.abs).catch(async (e) => {
|
||||
const st = e.code === "EEXIST" && (await fs.promises.lstat(d.abs).catch(() => null));
|
||||
if (!st?.isDirectory()) throw e;
|
||||
});
|
||||
}
|
||||
for (const f of plan.files) {
|
||||
const data = await reader.read(f.size);
|
||||
if (f.skip) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const tmp = path.join(
|
||||
f.up.abs,
|
||||
`.${f.name}.agenthub-upload-${crypto.randomBytes(4).toString("hex")}`
|
||||
);
|
||||
try {
|
||||
await fs.promises.writeFile(tmp, data, { flag: "wx" });
|
||||
const t = new Date(f.mtime);
|
||||
await fs.promises.utimes(tmp, t, t);
|
||||
// Linked, not renamed: a name taken meanwhile is refused, never replaced.
|
||||
await fs.promises.link(tmp, f.abs).catch(async (e) => {
|
||||
if (e.code === "EEXIST") {
|
||||
throw bad("EEXISTS", `${f.rel} already exists`, { names: [f.rel] });
|
||||
}
|
||||
if (e.code === "EPERM" || e.code === "ENOTSUP") return fs.promises.rename(tmp, f.abs);
|
||||
throw e;
|
||||
});
|
||||
} finally {
|
||||
await fs.promises.rm(tmp, { force: true });
|
||||
}
|
||||
done.push(f.full);
|
||||
}
|
||||
}
|
||||
return { saved: done.length, skipped, paths: done };
|
||||
} catch (e) {
|
||||
await reader.drain().catch(() => {});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One file for a download (the Files tab's `openDownload`, after this
|
||||
* module's own checks — the home rule included, on the file's REAL path).
|
||||
@@ -893,6 +1240,8 @@ module.exports = {
|
||||
restore,
|
||||
purgeTrash,
|
||||
upload,
|
||||
uploadBatch,
|
||||
uploadCheck,
|
||||
zip,
|
||||
collect,
|
||||
UPLOAD_MAX_BYTES,
|
||||
|
||||
@@ -866,8 +866,10 @@ async function cloneNow({ url, parent, name, once = null, partial = false }, thr
|
||||
const account = accountForDir(parent);
|
||||
const remote = remoteOf(url);
|
||||
// index-pack's delta threads: this clone's share of the cores, not all of
|
||||
// them (clone-queue.js). Not persisted — a later fetch keeps git's default.
|
||||
const pre = ["-c", `pack.threads=${threads}`];
|
||||
// them (clone-queue.js) — and as many checkout workers: a tree of many
|
||||
// small files is written one file at a time otherwise (~4x slower on 4
|
||||
// cores). Not persisted — a later fetch or checkout keeps git's default.
|
||||
const pre = ["-c", `pack.threads=${threads}`, "-c", `checkout.workers=${threads}`];
|
||||
const persist = [];
|
||||
const env = {};
|
||||
let restore = null;
|
||||
|
||||
@@ -640,6 +640,11 @@ function spawnRun(args) {
|
||||
...(entry.launcher === "claude" && systemPromptFile
|
||||
? { CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: "1" }
|
||||
: {}),
|
||||
// Claude Code hides TaskCreate/TaskUpdate from its newer models unless
|
||||
// this is set, and those calls are all the chat Progress panel draws
|
||||
// (routes/sessions.js sessionOutline). A user's settings.json `env` still
|
||||
// wins, so `"0"` there turns them back off.
|
||||
...(entry.launcher === "claude" ? { CLAUDE_CODE_ENABLE_TODO_TOOLS: "1" } : {}),
|
||||
// No CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: an inline claude duplicated its
|
||||
// output whenever the pane narrowed (tmux re-wraps the full-width rules and
|
||||
// pushes lines into history its redraw cannot clear). The user's own
|
||||
|
||||
@@ -301,6 +301,15 @@ function writeAsCanMove() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the installed write-as knows `extract` (a batch upload; install-system re-run). */
|
||||
function writeAsCanExtract() {
|
||||
try {
|
||||
return /^\s*extract\)/m.test(fs.readFileSync(WRITE_AS, "utf8"));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save `content` to `file` — as the member who owns the folder (S3), else
|
||||
* directly. In place, keeping the file's mode; `append` adds to the end.
|
||||
@@ -634,6 +643,7 @@ module.exports = {
|
||||
writeAs,
|
||||
writeAsAsync,
|
||||
writeAsCanMove,
|
||||
writeAsCanExtract,
|
||||
__setSpawnImpl,
|
||||
mkdirAs,
|
||||
makeMemberFolder,
|
||||
|
||||
@@ -288,6 +288,7 @@ function broadcastAudience(type, data) {
|
||||
case "agent_updated":
|
||||
case "new_event":
|
||||
case "workflow_upserted":
|
||||
case "transcript_changed":
|
||||
return { owners: sessionOwners(d.session_id) };
|
||||
case "alert_triggered":
|
||||
case "alert_updated":
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* @file Turns one Claude Code transcript line (or a stream-json event shaped
|
||||
* like one) into the conversation message the chat renders: sender, text,
|
||||
* thinking, tool calls and results (with an edit's +/- counts, an
|
||||
* AskUserQuestion's answers, a subagent's facts), model, usage. Shared by the
|
||||
* transcript route and the realtime chat runtime so both draw the same thing.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
/**
|
||||
* Classify the TRUE sender of a transcript entry. A JSONL `type:"user"` line is
|
||||
* not always the human: it also carries tool results, harness-injected
|
||||
* task-notifications, /loop re-injections (`isMeta`), and — in a subagent
|
||||
* transcript — the task prompt handed down by the orchestrator. Attributing all
|
||||
* of those to "User" is wrong; the UI styles each sender distinctly.
|
||||
*
|
||||
* Returns: "user" | "assistant" | "orchestrator" | "system" | "tool".
|
||||
* user — a real message typed by the human
|
||||
* assistant — the agent's own turn
|
||||
* orchestrator — a subagent's task, assigned by its parent/main agent
|
||||
* system — harness/tooling injection (task-notification, /loop meta, …)
|
||||
* tool — a tool_result echoed back on a `user` line
|
||||
*/
|
||||
function classifyTranscriptSender(entry, isSubagentFile) {
|
||||
if (entry.type === "assistant") return "assistant";
|
||||
// `system`/local_command lines are surfaced as the human's slash-command I/O.
|
||||
if (entry.type !== "user") return "user";
|
||||
|
||||
const content = entry.message ? entry.message.content : undefined;
|
||||
const onlyToolResults =
|
||||
Array.isArray(content) &&
|
||||
content.length > 0 &&
|
||||
content.every((b) => b && b.type === "tool_result");
|
||||
if (entry.toolUseResult !== undefined || onlyToolResults) return "tool";
|
||||
|
||||
const text =
|
||||
typeof content === "string"
|
||||
? content
|
||||
: Array.isArray(content)
|
||||
? (content.find((b) => b && b.type === "text") || {}).text || ""
|
||||
: "";
|
||||
const lead = text.replace(/^\s+/, "");
|
||||
|
||||
// Harness injections that masquerade as a user line.
|
||||
if (entry.isMeta === true) return "system";
|
||||
if (lead.startsWith("<task-notification>") || lead.startsWith("<task-notification ")) {
|
||||
return "system";
|
||||
}
|
||||
// Background-task event banner ("[SYSTEM NOTIFICATION - NOT USER INPUT] …")
|
||||
// that newer harness builds prefix ahead of the <task-notification> payload.
|
||||
if (lead.startsWith("[SYSTEM NOTIFICATION")) return "system";
|
||||
|
||||
// In a subagent transcript, a user line with no human prompt provenance is the
|
||||
// task injected by the Task/Agent tool. A real human message to the subagent
|
||||
// (rare, but allowed) carries promptSource/origin and stays "user".
|
||||
if (isSubagentFile && entry.promptSource === undefined && entry.origin === undefined) {
|
||||
return "orchestrator";
|
||||
}
|
||||
|
||||
return "user";
|
||||
}
|
||||
|
||||
/** The +/- line counts of a Write/Edit/MultiEdit result (`toolUseResult`):
|
||||
* its `structuredPatch` hunks' `+`/`-` lines, or every line of a file it
|
||||
* created. Null for any other result. */
|
||||
function patchCounts(r) {
|
||||
if (!r || typeof r !== "object") return null;
|
||||
if (Array.isArray(r.structuredPatch) && r.structuredPatch.length) {
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
for (const h of r.structuredPatch) {
|
||||
for (const l of Array.isArray(h?.lines) ? h.lines : []) {
|
||||
if (typeof l !== "string") continue;
|
||||
if (l.startsWith("+")) added++;
|
||||
else if (l.startsWith("-")) removed++;
|
||||
}
|
||||
}
|
||||
return { added, removed };
|
||||
}
|
||||
if (r.type === "create" && typeof r.content === "string") {
|
||||
return { added: r.content.split("\n").length - (r.content.endsWith("\n") ? 1 : 0), removed: 0 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** An AskUserQuestion result's answers (`toolUseResult.answers`): question
|
||||
* text → the option picked, or what the user typed. Null for any other result. */
|
||||
function askAnswers(r) {
|
||||
if (!r || typeof r !== "object" || !r.answers || typeof r.answers !== "object") return null;
|
||||
if (Array.isArray(r.answers)) return null;
|
||||
const out = {};
|
||||
for (const [q, a] of Object.entries(r.answers)) if (typeof a === "string") out[q] = a;
|
||||
return Object.keys(out).length ? out : null;
|
||||
}
|
||||
|
||||
/** An Agent/Task result's facts (`toolUseResult`): the subagent's id (its
|
||||
* transcript is `agent-<id>.jsonl`), the model it ran on and, once it
|
||||
* finished in the foreground, its time, tokens and tool calls. Null for any
|
||||
* other result. */
|
||||
function agentInfo(r) {
|
||||
if (!r || typeof r !== "object" || typeof r.agentId !== "string") return null;
|
||||
if (!/^[\w-]{1,200}$/.test(r.agentId)) return null;
|
||||
const num = (v) => (Number.isFinite(v) ? v : null);
|
||||
return {
|
||||
id: r.agentId,
|
||||
model: typeof r.resolvedModel === "string" ? r.resolvedModel : null,
|
||||
status: typeof r.status === "string" ? r.status : null,
|
||||
duration_ms: num(r.totalDurationMs),
|
||||
tokens: num(r.totalTokens),
|
||||
tool_uses: num(r.totalToolUseCount),
|
||||
};
|
||||
}
|
||||
|
||||
// A conversation turn — a prompt, an answer — is shown whole: it is bounded by
|
||||
// the model's output and what the user typed. Only tool payloads and thinking
|
||||
// (the parts that can run to megabytes) keep the 10 KB cap. This one guards
|
||||
// against a pathological line, not a long answer.
|
||||
const TEXT_MAX = 1024 * 1024;
|
||||
// The tools whose input carries a file's text (artifactVersions on the client).
|
||||
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit"]);
|
||||
const EDIT_INPUT_MAX = 256 * 1024;
|
||||
|
||||
function truncate(str, maxLen) {
|
||||
if (!str || str.length <= maxLen) return str;
|
||||
return str.slice(0, maxLen) + "[truncated]";
|
||||
}
|
||||
|
||||
function truncateObj(obj, maxLen) {
|
||||
if (!obj) return obj;
|
||||
const json = JSON.stringify(obj);
|
||||
if (json.length <= maxLen) return obj;
|
||||
return { _truncated: truncate(json, maxLen) };
|
||||
}
|
||||
|
||||
/** A parser for one transcript: it remembers the last rename it surfaced, so
|
||||
* a repeated `custom-title` line shows once. `isSubagentFile` tells a
|
||||
* subagent's task (orchestrator) from a human prompt. */
|
||||
function createClaudeLineParser(isSubagentFile = false) {
|
||||
// Dedupe state for synthetic rename markers: custom-title lines can repeat
|
||||
// with the same value across a transcript, so only emit when the title
|
||||
// actually changes from the last one we surfaced.
|
||||
let lastRenameTitle = null;
|
||||
|
||||
// Helper: parse a JSONL line into a message object, or null if not a displayable message
|
||||
function parseMessage(entry, num) {
|
||||
// /rename, `claude -n`, picker Ctrl+R → custom-title metadata line. These
|
||||
// commands produce no user/assistant turn, so without this they'd be
|
||||
// invisible. Surface a compact "renamed" marker instead.
|
||||
if (entry.type === "custom-title") {
|
||||
const title = typeof entry.customTitle === "string" ? entry.customTitle.trim() : "";
|
||||
if (!title || title === lastRenameTitle) return null;
|
||||
lastRenameTitle = title;
|
||||
return {
|
||||
type: "session_event",
|
||||
event_kind: "rename",
|
||||
title,
|
||||
timestamp: entry.timestamp || null,
|
||||
content: [],
|
||||
line: num,
|
||||
};
|
||||
}
|
||||
|
||||
// Local slash-command I/O. Newer Claude Code builds write the command
|
||||
// invocation and its captured output as `system`/`local_command` lines
|
||||
// with the TUI markup in a top-level `content` string (older builds used
|
||||
// `user` messages, handled below). Surface those as user-side text so the
|
||||
// client's tuiSegments parser renders the command pill + stdout/stderr
|
||||
// (e.g. /color → "/color" pill + "Session color set to: cyan"). Skip
|
||||
// every other system subtype (turn_duration, stop_hook_summary, …) and
|
||||
// empty local_command lines (e.g. /clear writes a content-less one).
|
||||
if (entry.type === "system") {
|
||||
if (entry.subtype !== "local_command") return null;
|
||||
const sysText = typeof entry.content === "string" ? entry.content : "";
|
||||
if (!sysText.trim()) return null;
|
||||
return {
|
||||
type: "user",
|
||||
sender: "user", // local slash-command I/O is the human's own action
|
||||
timestamp: entry.timestamp || null,
|
||||
content: [{ type: "text", text: truncate(sysText, TEXT_MAX) }],
|
||||
line: num,
|
||||
};
|
||||
}
|
||||
|
||||
// Mid-turn queued message: journaled as a `queued_command` attachment
|
||||
// (never as a `user` line). Surface it at the position the model actually
|
||||
// received it. NOT everything in the queue is the human, though — the
|
||||
// harness delivers its own injections (task-notifications from background
|
||||
// agents, "[SYSTEM NOTIFICATION …]" banners) through the same queue, and
|
||||
// those attachments carry NO `origin` field, while a genuinely typed
|
||||
// message carries `origin.kind = "human"`. So: harness-marker text or a
|
||||
// non-human origin → "system"; everything else → "user". Other attachment
|
||||
// subtypes are harness noise → dropped.
|
||||
if (entry.type === "attachment") {
|
||||
const att = entry.attachment;
|
||||
if (!att || att.type !== "queued_command") return null;
|
||||
const prompt = typeof att.prompt === "string" ? att.prompt : "";
|
||||
if (!prompt.trim()) return null;
|
||||
const lead = prompt.replace(/^\s+/, "");
|
||||
// Same harness markers classifyTranscriptSender strips off user lines.
|
||||
const isHarnessText =
|
||||
lead.startsWith("<task-notification") || lead.startsWith("[SYSTEM NOTIFICATION");
|
||||
const kind = att.origin && typeof att.origin.kind === "string" ? att.origin.kind : null;
|
||||
const isSystem = isHarnessText || (kind !== null && kind !== "human");
|
||||
return {
|
||||
type: "user",
|
||||
sender: isSystem ? "system" : "user",
|
||||
timestamp: entry.timestamp || att.timestamp || null,
|
||||
content: [{ type: "text", text: truncate(prompt, TEXT_MAX) }],
|
||||
line: num,
|
||||
};
|
||||
}
|
||||
|
||||
const msg = entry.type === "assistant" ? entry.message || {} : {};
|
||||
const content = [];
|
||||
|
||||
if (entry.type === "user") {
|
||||
const msgContent = entry.message?.content;
|
||||
if (typeof msgContent === "string") {
|
||||
content.push({ type: "text", text: truncate(msgContent, TEXT_MAX) });
|
||||
} else if (Array.isArray(msgContent)) {
|
||||
for (const block of msgContent) {
|
||||
if (block.type === "text" && block.text) {
|
||||
content.push({ type: "text", text: truncate(block.text, TEXT_MAX) });
|
||||
} else if (block.type === "tool_result") {
|
||||
// A line carries one tool result; an edit's +/- counts ride on it.
|
||||
const diff = patchCounts(entry.toolUseResult);
|
||||
const answers = askAnswers(entry.toolUseResult);
|
||||
const agent = agentInfo(entry.toolUseResult);
|
||||
// A subagent's report is a reply, read as prose: its text blocks
|
||||
// whole, not a 10 KB slice of their JSON.
|
||||
const report =
|
||||
agent && Array.isArray(block.content)
|
||||
? block.content
|
||||
.filter((c) => c?.type === "text" && typeof c.text === "string")
|
||||
.map((c) => c.text)
|
||||
.join("\n\n")
|
||||
: null;
|
||||
content.push({
|
||||
type: "tool_result",
|
||||
id: block.tool_use_id || null,
|
||||
output:
|
||||
report !== null
|
||||
? truncate(report, TEXT_MAX)
|
||||
: truncate(
|
||||
typeof block.content === "string"
|
||||
? block.content
|
||||
: JSON.stringify(block.content || ""),
|
||||
10240
|
||||
),
|
||||
is_error: !!block.is_error,
|
||||
...(diff ? { diff } : {}),
|
||||
...(answers ? { answers } : {}),
|
||||
...(agent ? { agent } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (msgContent === undefined || msgContent === null) {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
const msgContent = msg.content || [];
|
||||
if (Array.isArray(msgContent)) {
|
||||
for (const block of msgContent) {
|
||||
if (block.type === "text" && block.text) {
|
||||
content.push({ type: "text", text: truncate(block.text, TEXT_MAX) });
|
||||
} else if (block.type === "thinking" && block.thinking) {
|
||||
content.push({ type: "thinking", text: truncate(block.thinking, 10240) });
|
||||
} else if (block.type === "tool_use") {
|
||||
content.push({
|
||||
type: "tool_use",
|
||||
name: block.name || "unknown",
|
||||
id: block.id || null,
|
||||
// A file a Write/Edit made is an artifact the chat can show in
|
||||
// versions, rebuilt from these inputs: they keep up to 256 KB.
|
||||
input: truncateObj(block.input, EDIT_TOOLS.has(block.name) ? EDIT_INPUT_MAX : 10240),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (content.length === 0) return null;
|
||||
|
||||
const message = {
|
||||
type: entry.type,
|
||||
sender: classifyTranscriptSender(entry, isSubagentFile),
|
||||
timestamp: entry.timestamp
|
||||
? typeof entry.timestamp === "number"
|
||||
? new Date(entry.timestamp).toISOString()
|
||||
: entry.timestamp
|
||||
: null,
|
||||
content,
|
||||
line: num,
|
||||
};
|
||||
|
||||
if (entry.type === "assistant") {
|
||||
if (msg.model) message.model = msg.model;
|
||||
// "end_turn" closes the turn; "tool_use" means more work follows.
|
||||
if (typeof msg.stop_reason === "string") message.stop_reason = msg.stop_reason;
|
||||
if (msg.usage) {
|
||||
message.usage = {
|
||||
input_tokens: msg.usage.input_tokens || 0,
|
||||
output_tokens: msg.usage.output_tokens || 0,
|
||||
cache_read_input_tokens: msg.usage.cache_read_input_tokens || 0,
|
||||
cache_creation_input_tokens: msg.usage.cache_creation_input_tokens || 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
return parseMessage;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
classifyTranscriptSender,
|
||||
patchCounts,
|
||||
askAnswers,
|
||||
agentInfo,
|
||||
truncate,
|
||||
truncateObj,
|
||||
TEXT_MAX,
|
||||
createClaudeLineParser,
|
||||
};
|
||||
@@ -149,6 +149,58 @@ const OPS = [
|
||||
status: 201,
|
||||
},
|
||||
],
|
||||
[
|
||||
"post",
|
||||
"/upload-check",
|
||||
"Before a batch: the names already taken, and whether a batch can go there",
|
||||
{
|
||||
note: "`taken`: which of `paths` (relative to `dir`) exist; `batch: false` = send the files one by one (a member's folder whose `write-as` predates `extract`).",
|
||||
body: json({ dir: { type: "string", default: "" }, paths: pathsArr }, ["paths"]),
|
||||
ok: {
|
||||
description: "`{taken, batch}`",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { taken: pathsArr, batch: { type: "boolean" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"post",
|
||||
"/upload-batch",
|
||||
"Upload many small files in one request (≤ 5,000 files, 16 MB each, 256 MB in all)",
|
||||
{
|
||||
note: "Body: a 4-byte big-endian length, that many bytes of JSON `{files: [{path, size, mtime?}]}` (paths relative to `dir`), then each file's bytes in that order. Planned whole before the first write — a taken name under `conflict=fail` is ONE 409 naming them all; a member's batch is one `write-as extract` (409 EUPGRADE until `agenthub server install-system` is re-run). 400 EBADBODY for a malformed or short body.",
|
||||
parameters: [
|
||||
{ name: "dir", in: "query", schema: { type: "string", default: "" } },
|
||||
{ name: "conflict", in: "query", schema: conflict },
|
||||
],
|
||||
body: {
|
||||
required: true,
|
||||
content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } },
|
||||
},
|
||||
status: 201,
|
||||
ok: {
|
||||
description: "`{saved, skipped, paths}`",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
saved: { type: "integer" },
|
||||
skipped: { type: "integer" },
|
||||
paths: pathsArr,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"get",
|
||||
"/zip",
|
||||
@@ -180,8 +232,8 @@ function operations(prefix, idParam, opPrefix, note) {
|
||||
out[key][method] = {
|
||||
tags: ["Files"],
|
||||
summary,
|
||||
description: `${note} ${errorCodes}`,
|
||||
operationId: `${opPrefix}${sub.replace(/\/(\w)/g, (_, c) => c.toUpperCase())}`,
|
||||
description: `${note} ${x.note ? `${x.note} ` : ""}${errorCodes}`,
|
||||
operationId: `${opPrefix}${sub.replace(/[/-](\w)/g, (_, c) => c.toUpperCase())}`,
|
||||
parameters: [idParam, ...(x.parameters || [])],
|
||||
...(x.body ? { requestBody: x.body } : {}),
|
||||
responses: { [x.status || 200]: x.ok || { description: "Done." } },
|
||||
|
||||
@@ -393,6 +393,12 @@ function createOpenApiSpec() {
|
||||
name: { type: "string" },
|
||||
type: { type: "string", enum: ["main", "subagent", "compaction"] },
|
||||
subagent_type: { type: "string", nullable: true },
|
||||
tool_use_id: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
description:
|
||||
"The Agent/Task tool_use that launched this subagent, from its meta file (Claude Code subagents only).",
|
||||
},
|
||||
has_transcript: { type: "boolean" },
|
||||
db_agent_id: {
|
||||
type: "string",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* rename/share/unshare, delete, read a snapshot file (preview/download, under
|
||||
* a `sandbox` CSP with `nosniff`, so nothing shared runs on the dashboard's
|
||||
* origin), and install a shared skill or plugin into the caller's own
|
||||
* account. Every write is same-origin. A path in a request only ever names a
|
||||
* account (or uninstall it from there again). Every write is same-origin. A path in a request only ever names a
|
||||
* file inside a workspace (pin, under the read path's guards) or one of a
|
||||
* snapshot's own recorded files.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -30,6 +30,7 @@ const STATUS = {
|
||||
EFOREIGN: 403,
|
||||
EINSTALLSOFF: 403,
|
||||
EEXISTS: 409,
|
||||
ENOTINSTALLED: 409,
|
||||
ENOACCOUNT: 409,
|
||||
EUPGRADE: 409,
|
||||
ENOTSHARED: 409,
|
||||
@@ -46,13 +47,19 @@ function sendError(res, err) {
|
||||
.json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
console.warn("[artifacts]", err);
|
||||
return res.status(500).json({ error: { code: "EINTERNAL", message: String(err?.message || err) } });
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: { code: "EINTERNAL", message: String(err?.message || err) } });
|
||||
}
|
||||
|
||||
/** Tell clients a row changed: everyone when shared, else its owner (+ staff). */
|
||||
function changed(row) {
|
||||
if (!row) return;
|
||||
broadcast("artifact_changed", { id: row.id, ownerId: row.ownerId ?? row.owner_id ?? null, shared: !!(row.shared === true || row.shared === 1) });
|
||||
broadcast("artifact_changed", {
|
||||
id: row.id,
|
||||
ownerId: row.ownerId ?? row.owner_id ?? null,
|
||||
shared: !!(row.shared === true || row.shared === 1),
|
||||
});
|
||||
}
|
||||
|
||||
const wrap = (fn) => async (req, res) => {
|
||||
@@ -101,7 +108,9 @@ router.put(
|
||||
}
|
||||
const on = req.body?.installsEnabled;
|
||||
if (typeof on !== "boolean") {
|
||||
return res.status(400).json({ error: { code: "EBADFIELD", message: "installsEnabled: boolean" } });
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "EBADFIELD", message: "installsEnabled: boolean" } });
|
||||
}
|
||||
artifacts.setInstallsEnabled(on);
|
||||
if (req.user) {
|
||||
@@ -230,7 +239,9 @@ router.post(
|
||||
wrap((req, res) => {
|
||||
const f = (req.files || [])[0];
|
||||
if (!f) {
|
||||
return res.status(400).json({ error: { code: "ENOFILE", message: "attach the file as `files`" } });
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "ENOFILE", message: "attach the file as `files`" } });
|
||||
}
|
||||
const item = artifacts.uploadFile(req, {
|
||||
name: f.originalname,
|
||||
@@ -264,7 +275,8 @@ router.patch(
|
||||
wrap((req, res) => {
|
||||
const item = artifacts.update(req, req.params.id, req.body || {});
|
||||
changed(item);
|
||||
res.json({ item });
|
||||
// `removed`: unsharing a Hub-only row deleted it.
|
||||
res.json(item.removed ? { item, removed: true } : { item });
|
||||
})
|
||||
);
|
||||
|
||||
@@ -274,7 +286,8 @@ router.delete(
|
||||
wrap((req, res) => {
|
||||
const row = artifacts.remove(req, req.params.id);
|
||||
changed(row);
|
||||
res.json({ ok: true });
|
||||
// `kept`: a shared row left the owner's library but stays in the Hub.
|
||||
res.json({ ok: true, kept: !!row.kept });
|
||||
})
|
||||
);
|
||||
|
||||
@@ -318,7 +331,8 @@ router.get(
|
||||
const download = req.query.download === "1" || !type;
|
||||
res.set({
|
||||
"Content-Type": download ? "application/octet-stream" : type,
|
||||
"Content-Security-Policy": "default-src 'none'; img-src data:; style-src 'unsafe-inline'; media-src 'self'; sandbox",
|
||||
"Content-Security-Policy":
|
||||
"default-src 'none'; img-src data:; style-src 'unsafe-inline'; media-src 'self'; sandbox",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Cache-Control": "private, max-age=60",
|
||||
...(download
|
||||
@@ -331,6 +345,12 @@ router.get(
|
||||
})
|
||||
);
|
||||
|
||||
// Who installed it, when and where (`artifacts.installsOf`).
|
||||
router.get(
|
||||
"/:id/installs",
|
||||
wrap((req, res) => res.json(artifacts.installsOf(req, req.params.id)))
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/:id/install",
|
||||
sameOriginGuard,
|
||||
@@ -342,4 +362,10 @@ router.post(
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/:id/uninstall",
|
||||
sameOriginGuard,
|
||||
wrap(async (req, res) => res.json(await artifacts.uninstall(req, req.params.id)))
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -95,16 +95,45 @@ function cwdOf(req) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `{scope, cwd}` for the two lists a member may read (the run form's `/`
|
||||
* The Claude home whose USER skills, commands and plugins a request's sessions
|
||||
* load — so the run form's `/` list matches what its pane's Claude can call.
|
||||
* Under S3 a pane runs as its FOLDER's account: `?cwd=` picks that account; a
|
||||
* non-admin with no `?cwd=` reads their own. Undefined = the host's (local
|
||||
* mode, or an admin in a host folder / the Config Explorer); null = none, for
|
||||
* a non-admin with no account of their own — another user's or the host's
|
||||
* skills are never listed as theirs.
|
||||
*/
|
||||
function userClaudeDir(req) {
|
||||
if (!req.user) return undefined;
|
||||
const runAs = require("../lib/run-as");
|
||||
const cwd = typeof req.query.cwd === "string" && req.query.cwd;
|
||||
let acct = null;
|
||||
try {
|
||||
if (cwd && path.isAbsolute(cwd)) acct = runAs.ownerForRead(path.resolve(cwd));
|
||||
else if (req.user.role !== "admin") acct = runAs.accountForUser(req.user.username);
|
||||
} catch {
|
||||
acct = null;
|
||||
}
|
||||
if (acct) return path.join(acct.home, ".claude");
|
||||
return req.user.role === "admin" ? undefined : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `{scope, cwd, userClaudeDir}` for the two lists a member may read (the run form's `/`
|
||||
* autocomplete). Server mode keeps a member's project items to their own
|
||||
* workspaces: another folder answers 403 EFOLDER, and with no `?cwd=` there is
|
||||
* no project at all (never the dashboard's own working directory). Returns null
|
||||
* once it has answered; `none` = nothing to list.
|
||||
*/
|
||||
function listOpts(req, res) {
|
||||
const opts = { scope: scopeOf(req), cwd: cwdOf(req) };
|
||||
if (scope.memberId(req) == null) return opts;
|
||||
const opts = { scope: scopeOf(req), cwd: cwdOf(req), userClaudeDir: userClaudeDir(req) };
|
||||
const asked = typeof req.query.cwd === "string" && req.query.cwd;
|
||||
// No account of their own: only a named workspace's project items, if any.
|
||||
if (opts.userClaudeDir === null) {
|
||||
if (opts.scope === "user" || !asked) return { none: true };
|
||||
opts.scope = "project";
|
||||
}
|
||||
if (scope.memberId(req) == null) return opts;
|
||||
if (!asked) return opts.scope === "project" ? { none: true } : { ...opts, scope: "user" };
|
||||
if (!path.isAbsolute(asked) || !scope.insideOwnWorkspace(req, path.resolve(asked))) {
|
||||
res.status(403).json({
|
||||
@@ -137,8 +166,10 @@ router.get("/output-styles", (req, res) => {
|
||||
res.json({ items: cc.readOutputStyles({ scope: scopeOf(req), cwd: cwdOf(req) }) });
|
||||
});
|
||||
|
||||
router.get("/plugins", (_req, res) => {
|
||||
res.json(cc.readPlugins());
|
||||
router.get("/plugins", (req, res) => {
|
||||
const home = userClaudeDir(req);
|
||||
if (home === null) return res.json({ manifestPath: null, manifestExists: false, plugins: [] });
|
||||
res.json(cc.readPlugins(home));
|
||||
});
|
||||
|
||||
router.get("/mcp", (req, res) => {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* GET /api/homes/trash every Trash the caller may open, merged
|
||||
* GET /api/homes/:who/fs/list|trash|zip|download
|
||||
* POST /api/homes/:who/fs/mkdir|newfile|rename|move|copy|delete|upload|trash/restore|trash/purge
|
||||
* POST /api/homes/:who/fs/upload-batch|upload-check many small files in one request
|
||||
*
|
||||
* `:who` is `me`, or — for an admin — another user's name: read-only
|
||||
* (`403 EREADONLY` on every write) and written to the audit log. The one
|
||||
@@ -36,6 +37,7 @@ function fail(res, err) {
|
||||
ENOHOME: 404,
|
||||
EBADPATH: 400,
|
||||
EBADNAME: 400,
|
||||
EBADBODY: 400,
|
||||
ENOFILE: 404,
|
||||
ESECRET: 403,
|
||||
EFOREIGN: 403,
|
||||
@@ -314,5 +316,38 @@ router.post(
|
||||
}
|
||||
}, W)
|
||||
);
|
||||
router.post(
|
||||
"/:who/fs/upload-batch",
|
||||
sameOriginGuard,
|
||||
handle(async (req, res, home) => {
|
||||
try {
|
||||
const out = await fx.uploadBatch(
|
||||
home.dir,
|
||||
typeof req.query.dir === "string" ? req.query.dir : "",
|
||||
req,
|
||||
{ conflict: req.query.conflict, trash: trashFor(req, home) }
|
||||
);
|
||||
res.status(201).json(out);
|
||||
} catch (err) {
|
||||
req.resume();
|
||||
throw err;
|
||||
}
|
||||
}, W)
|
||||
);
|
||||
router.post(
|
||||
"/:who/fs/upload-check",
|
||||
sameOriginGuard,
|
||||
handle(
|
||||
async (req, res, home) =>
|
||||
res.json(
|
||||
await fx.uploadCheck(
|
||||
home.dir,
|
||||
typeof req.body?.dir === "string" ? req.body.dir : "",
|
||||
req.body?.paths
|
||||
)
|
||||
),
|
||||
W
|
||||
)
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1586,6 +1586,7 @@ function fsFail(res, err) {
|
||||
{
|
||||
EBADPATH: 400,
|
||||
EBADNAME: 400,
|
||||
EBADBODY: 400,
|
||||
ENOFILE: 404,
|
||||
ESECRET: 403,
|
||||
EFOREIGN: 403,
|
||||
@@ -1782,6 +1783,40 @@ router.post("/:id/fs/upload", sameOriginGuard, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Many small files in ONE request (`fileExplorer.uploadBatch`): `?dir=` is the
|
||||
* folder their paths are relative to. A batch is planned whole before the
|
||||
* first write, so a taken name under `conflict=fail` is one 409 naming them all.
|
||||
*/
|
||||
router.post("/:id/fs/upload-batch", sameOriginGuard, async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
if (!t) return;
|
||||
try {
|
||||
const out = await fileExplorer.uploadBatch(
|
||||
t.cwd,
|
||||
typeof req.query.dir === "string" ? req.query.dir : "",
|
||||
req,
|
||||
{ conflict: req.query.conflict, trash: fsTrashFor(req, t) }
|
||||
);
|
||||
res.status(201).json(out);
|
||||
} catch (err) {
|
||||
req.resume();
|
||||
fsFail(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
/** Before a batch: `{taken, batch}` for `{dir, paths}` — no body bytes wasted on a 409. */
|
||||
router.post("/:id/fs/upload-check", sameOriginGuard, async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
if (!t) return;
|
||||
try {
|
||||
const dir = typeof req.body?.dir === "string" ? req.body.dir : "";
|
||||
res.json(await fileExplorer.uploadCheck(t.cwd, dir, req.body?.paths));
|
||||
} catch (err) {
|
||||
fsFail(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
/** Files and folders as one zip, streamed (`?p=` once per path). */
|
||||
router.get("/:id/fs/zip", async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
|
||||
+144
-25
@@ -29,7 +29,7 @@ const path = require("node:path");
|
||||
const runs = require("../lib/pty-run");
|
||||
const tmux = require("../lib/tmux");
|
||||
const lanesLib = require("../lib/lanes");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
const { sameOriginGuard, isLocalRequest } = require("../lib/same-origin");
|
||||
const aiTools = require("../lib/ai-tools");
|
||||
const { resumeProxy } = require("../lib/proxy-apply");
|
||||
const scope = require("../lib/scope");
|
||||
@@ -114,7 +114,31 @@ const DROP_TYPES = {
|
||||
"image/webp": "webp",
|
||||
};
|
||||
const DROP_MAX_BYTES = 20 * 1024 * 1024;
|
||||
// A browser on this machine (loopback, never server mode) crosses no network:
|
||||
// its drop is streamed to disk, so a video or a dump is no reason to refuse.
|
||||
// Chrome hands a dropped file no `file://` path, so it cannot attach by path.
|
||||
const DROP_LOCAL_MAX_BYTES = 2 * 1024 ** 3;
|
||||
const DROP_TTL_MS = 24 * 60 * 60_000;
|
||||
const localDrop = (req) => !require("../lib/auth").isServerMode() && isLocalRequest(req);
|
||||
const dropLimit = (req) => (localDrop(req) ? DROP_LOCAL_MAX_BYTES : DROP_MAX_BYTES);
|
||||
function tooBig(res, max) {
|
||||
res.set("Connection", "close");
|
||||
res.status(413).json({
|
||||
error: {
|
||||
code: "ETOOBIG",
|
||||
message: `a dropped file may be at most ${max / 1024 / 1024} MB`,
|
||||
max_bytes: max,
|
||||
},
|
||||
});
|
||||
}
|
||||
/** A body declared over the limit is refused before a byte of it is read, and
|
||||
* the connection is closed: express.raw would read (and discard) all of it
|
||||
* first, so a 4 GB drop crossed the network in full just to be told 413. */
|
||||
function refuseOversize(req, res, next) {
|
||||
const max = dropLimit(req);
|
||||
if (!(Number(req.headers["content-length"]) > max)) return next();
|
||||
tooBig(res, max);
|
||||
}
|
||||
/** Where a drop goes: the handoff dir of the account the PANE runs as — the
|
||||
* workspace `?laneId=` names (scopeGuard already refused one this user may
|
||||
* not see), else the requester's own — written AS that member under S3, so
|
||||
@@ -133,28 +157,33 @@ function dropDir(req) {
|
||||
: { dir: path.join(require("../lib/claude-home").getDataDir(), "drops"), member: null };
|
||||
}
|
||||
|
||||
// Saves one dropped body under the drop dir and answers its path. Pruned on
|
||||
// write: files older than a day go.
|
||||
// The drop dir, made and pruned: files older than a day go.
|
||||
function prepareDropDir(req) {
|
||||
const { dir, member } = dropDir(req);
|
||||
const runAs = require("../lib/run-as");
|
||||
if (member) runAs.mkdirAs(member, dir);
|
||||
else fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
// ponytail: prune-on-write, a day old; a sweep job only if drops pile up.
|
||||
const cutoff = Date.now() - DROP_TTL_MS;
|
||||
for (const entry of fs.readdirSync(dir)) {
|
||||
const f = path.join(dir, entry);
|
||||
try {
|
||||
if (fs.lstatSync(f).mtimeMs >= cutoff) continue;
|
||||
if (member) runAs.removeAs(member, f);
|
||||
else fs.unlinkSync(f);
|
||||
} catch {
|
||||
/* raced with another prune — fine */
|
||||
}
|
||||
}
|
||||
return { dir, member };
|
||||
}
|
||||
|
||||
// Saves one dropped body under the drop dir and answers its path.
|
||||
function saveDrop(req, res, name, body) {
|
||||
try {
|
||||
const { dir, member } = dropDir(req);
|
||||
const runAs = require("../lib/run-as");
|
||||
if (member) runAs.mkdirAs(member, dir);
|
||||
else fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
// ponytail: prune-on-write, a day old; a sweep job only if drops pile up.
|
||||
const cutoff = Date.now() - DROP_TTL_MS;
|
||||
for (const entry of fs.readdirSync(dir)) {
|
||||
const f = path.join(dir, entry);
|
||||
try {
|
||||
if (fs.lstatSync(f).mtimeMs >= cutoff) continue;
|
||||
if (member) runAs.removeAs(member, f);
|
||||
else fs.unlinkSync(f);
|
||||
} catch {
|
||||
/* raced with another prune — fine */
|
||||
}
|
||||
}
|
||||
const { dir, member } = prepareDropDir(req);
|
||||
const file = path.join(dir, name);
|
||||
if (member) runAs.handoffWrite(member, path.join("drops", name), body);
|
||||
if (member) require("../lib/run-as").handoffWrite(member, path.join("drops", name), body);
|
||||
else fs.writeFileSync(file, body, { mode: 0o600, flag: "wx" });
|
||||
res.json({ path: file });
|
||||
} catch (err) {
|
||||
@@ -164,6 +193,7 @@ function saveDrop(req, res, name, body) {
|
||||
|
||||
router.post(
|
||||
"/drop-image",
|
||||
refuseOversize,
|
||||
express.raw({ type: Object.keys(DROP_TYPES), limit: DROP_MAX_BYTES }),
|
||||
(req, res) => {
|
||||
const ext =
|
||||
@@ -183,16 +213,105 @@ router.post(
|
||||
// so its own name is kept — reduced to a basename of [A-Za-z0-9._-] behind a
|
||||
// random prefix, so it can neither leave the drop dir nor collide, and the
|
||||
// space-joined paste of several paths stays parseable.
|
||||
router.post("/drop-file", express.raw({ type: () => true, limit: DROP_MAX_BYTES }), (req, res) => {
|
||||
const safe = path
|
||||
const dropName = (req) =>
|
||||
path
|
||||
.basename(String(req.query.name || ""))
|
||||
.replace(/[^A-Za-z0-9._-]/g, "_")
|
||||
.replace(/^\.+/, "")
|
||||
.slice(-100);
|
||||
if (!safe || !Buffer.isBuffer(req.body) || req.body.length === 0) {
|
||||
return res.status(400).json({ error: "?name= and a non-empty file body required" });
|
||||
|
||||
/** A local browser's drop is piped to its file as it arrives, never held in
|
||||
* memory whole; a body that runs past the limit (or is empty) leaves no file. */
|
||||
function streamLocalDrop(req, res, next) {
|
||||
if (!localDrop(req)) return next();
|
||||
const safe = dropName(req);
|
||||
if (!safe) return res.status(400).json({ error: "?name= and a non-empty file body required" });
|
||||
let file;
|
||||
let out;
|
||||
try {
|
||||
const { dir, member } = prepareDropDir(req);
|
||||
if (member) return next(); // a member's file is written as them, by saveDrop
|
||||
file = path.join(dir, `${crypto.randomBytes(8).toString("hex")}-${safe}`);
|
||||
out = fs.createWriteStream(file, { mode: 0o600, flags: "wx" });
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
saveDrop(req, res, `${crypto.randomBytes(8).toString("hex")}-${safe}`, req.body);
|
||||
let bytes = 0;
|
||||
let failed = null;
|
||||
const fail = (status, send) => {
|
||||
if (failed) return;
|
||||
failed = status;
|
||||
req.unpipe(out);
|
||||
out.destroy();
|
||||
fs.rm(file, { force: true }, () => {});
|
||||
if (!res.headersSent) send();
|
||||
};
|
||||
req.on("data", (c) => {
|
||||
bytes += c.length;
|
||||
if (bytes > DROP_LOCAL_MAX_BYTES) fail(413, () => tooBig(res, DROP_LOCAL_MAX_BYTES));
|
||||
});
|
||||
req.on("aborted", () => fail(400, () => {}));
|
||||
out.on("error", (err) => fail(500, () => res.status(500).json({ error: err.message })));
|
||||
out.on("finish", () => {
|
||||
if (failed) return;
|
||||
if (bytes === 0) {
|
||||
return fail(400, () =>
|
||||
res.status(400).json({ error: "?name= and a non-empty file body required" })
|
||||
);
|
||||
}
|
||||
res.json({ path: file });
|
||||
});
|
||||
req.pipe(out);
|
||||
}
|
||||
|
||||
router.post(
|
||||
"/drop-file",
|
||||
refuseOversize,
|
||||
streamLocalDrop,
|
||||
express.raw({ type: () => true, limit: DROP_MAX_BYTES }),
|
||||
(req, res) => {
|
||||
const safe = dropName(req);
|
||||
if (!safe || !Buffer.isBuffer(req.body) || req.body.length === 0) {
|
||||
return res.status(400).json({ error: "?name= and a non-empty file body required" });
|
||||
}
|
||||
saveDrop(req, res, `${crypto.randomBytes(8).toString("hex")}-${safe}`, req.body);
|
||||
}
|
||||
);
|
||||
|
||||
// A browser on this machine is handed a file manager's files as `file://`
|
||||
// paths beside their bytes; the composer attaches such a path instead of
|
||||
// uploading — but only once this answers that the path is a regular file of
|
||||
// the dropped size HERE. "localhost" in the address bar is not proof: an SSH
|
||||
// tunnel's browser is on another machine, and a server-mode pane runs as an
|
||||
// account that cannot read the host's files, so server mode and anything not
|
||||
// from loopback (a proxy counts as remote) get 403 and the client uploads.
|
||||
router.post("/local-paths", (req, res) => {
|
||||
if (require("../lib/auth").isServerMode() || !isLocalRequest(req)) {
|
||||
return res.status(403).json({
|
||||
error: { code: "EREMOTE", message: "local paths are checked only for a local browser" },
|
||||
});
|
||||
}
|
||||
const paths = req.body?.paths;
|
||||
if (
|
||||
!Array.isArray(paths) ||
|
||||
paths.length === 0 ||
|
||||
paths.length > 100 ||
|
||||
!paths.every((p) => typeof p === "string" && path.isAbsolute(p))
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: { code: "EBADFIELD", message: "paths must be 1-100 absolute paths" },
|
||||
});
|
||||
}
|
||||
res.json({
|
||||
sizes: paths.map((p) => {
|
||||
try {
|
||||
const st = fs.statSync(p);
|
||||
return st.isFile() ? st.size : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/history", (req, res) => {
|
||||
|
||||
+192
-265
@@ -22,6 +22,16 @@ const {
|
||||
findTranscriptPath,
|
||||
findSubagentTranscriptPath,
|
||||
} = require("../lib/claude-home");
|
||||
const {
|
||||
classifyTranscriptSender,
|
||||
patchCounts,
|
||||
askAnswers,
|
||||
agentInfo,
|
||||
truncate,
|
||||
truncateObj,
|
||||
TEXT_MAX,
|
||||
createClaudeLineParser,
|
||||
} = require("../lib/transcript-message");
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -52,59 +62,6 @@ const TRANSCRIPT_RENDER_TYPES = new Set([
|
||||
"attachment",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Classify the TRUE sender of a transcript entry. A JSONL `type:"user"` line is
|
||||
* not always the human: it also carries tool results, harness-injected
|
||||
* task-notifications, /loop re-injections (`isMeta`), and — in a subagent
|
||||
* transcript — the task prompt handed down by the orchestrator. Attributing all
|
||||
* of those to "User" is wrong; the UI styles each sender distinctly.
|
||||
*
|
||||
* Returns: "user" | "assistant" | "orchestrator" | "system" | "tool".
|
||||
* user — a real message typed by the human
|
||||
* assistant — the agent's own turn
|
||||
* orchestrator — a subagent's task, assigned by its parent/main agent
|
||||
* system — harness/tooling injection (task-notification, /loop meta, …)
|
||||
* tool — a tool_result echoed back on a `user` line
|
||||
*/
|
||||
function classifyTranscriptSender(entry, isSubagentFile) {
|
||||
if (entry.type === "assistant") return "assistant";
|
||||
// `system`/local_command lines are surfaced as the human's slash-command I/O.
|
||||
if (entry.type !== "user") return "user";
|
||||
|
||||
const content = entry.message ? entry.message.content : undefined;
|
||||
const onlyToolResults =
|
||||
Array.isArray(content) &&
|
||||
content.length > 0 &&
|
||||
content.every((b) => b && b.type === "tool_result");
|
||||
if (entry.toolUseResult !== undefined || onlyToolResults) return "tool";
|
||||
|
||||
const text =
|
||||
typeof content === "string"
|
||||
? content
|
||||
: Array.isArray(content)
|
||||
? (content.find((b) => b && b.type === "text") || {}).text || ""
|
||||
: "";
|
||||
const lead = text.replace(/^\s+/, "");
|
||||
|
||||
// Harness injections that masquerade as a user line.
|
||||
if (entry.isMeta === true) return "system";
|
||||
if (lead.startsWith("<task-notification>") || lead.startsWith("<task-notification ")) {
|
||||
return "system";
|
||||
}
|
||||
// Background-task event banner ("[SYSTEM NOTIFICATION - NOT USER INPUT] …")
|
||||
// that newer harness builds prefix ahead of the <task-notification> payload.
|
||||
if (lead.startsWith("[SYSTEM NOTIFICATION")) return "system";
|
||||
|
||||
// In a subagent transcript, a user line with no human prompt provenance is the
|
||||
// task injected by the Task/Agent tool. A real human message to the subagent
|
||||
// (rare, but allowed) carries promptSource/origin and stays "user".
|
||||
if (isSubagentFile && entry.promptSource === undefined && entry.origin === undefined) {
|
||||
return "orchestrator";
|
||||
}
|
||||
|
||||
return "user";
|
||||
}
|
||||
|
||||
/**
|
||||
* Read only the first non-empty line from a JSONL file using streaming.
|
||||
* Avoids loading the entire file into memory.
|
||||
@@ -846,7 +803,13 @@ router.get("/:id/transcripts", async (req, res) => {
|
||||
const omp = require("../lib/omp-usage");
|
||||
const list = [];
|
||||
if (omp.journalPath(req.params.id)) {
|
||||
list.push({ id: "main", name: "Main Agent", type: "main", has_transcript: true, db_agent_id: null });
|
||||
list.push({
|
||||
id: "main",
|
||||
name: "Main Agent",
|
||||
type: "main",
|
||||
has_transcript: true,
|
||||
db_agent_id: null,
|
||||
});
|
||||
}
|
||||
for (const j of omp.subagentJournals(req.params.id)) {
|
||||
list.push({
|
||||
@@ -860,6 +823,26 @@ router.get("/:id/transcripts", async (req, res) => {
|
||||
}
|
||||
return res.json({ transcripts: list });
|
||||
}
|
||||
// Cursor and Antigravity: one transcript per session, no subagent files.
|
||||
if (session.tool === "cursor" || session.tool === "antigravity") {
|
||||
const file =
|
||||
session.tool === "cursor"
|
||||
? require("../lib/cursor-usage").transcriptPath(req.params.id)
|
||||
: require("../lib/antigravity-usage").transcriptPath(req.params.id);
|
||||
const list =
|
||||
file && fs.existsSync(file)
|
||||
? [
|
||||
{
|
||||
id: "main",
|
||||
name: "Main Agent",
|
||||
type: "main",
|
||||
has_transcript: true,
|
||||
db_agent_id: null,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
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") {
|
||||
@@ -973,6 +956,8 @@ router.get("/:id/transcripts", async (req, res) => {
|
||||
name: transcriptName,
|
||||
type: isCompact ? "compaction" : "subagent",
|
||||
subagent_type: transcriptSubagentType,
|
||||
// The Agent call that launched it: the chat opens it from that call.
|
||||
tool_use_id: typeof meta?.toolUseId === "string" ? meta.toolUseId : null,
|
||||
has_transcript: true,
|
||||
db_agent_id: null, // matched later after all transcripts are collected
|
||||
_timestamp: transcriptTimestamp,
|
||||
@@ -1112,6 +1097,10 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
const isCodex = session.tool === "codex";
|
||||
// An omp session's (or subagent's) journal, recorded by omp-usage.js.
|
||||
const isOmp = session.tool === "omp";
|
||||
// A Cursor session's agent transcript, recorded by cursor-usage.js.
|
||||
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") {
|
||||
@@ -1128,6 +1117,14 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
}
|
||||
if (isOmp) {
|
||||
jsonlPath = require("../lib/omp-usage").journalPath(req.params.id, agentId || "");
|
||||
} else if (isCursor) {
|
||||
jsonlPath = isSubagentFile
|
||||
? null
|
||||
: require("../lib/cursor-usage").transcriptPath(req.params.id);
|
||||
} else if (isAgy) {
|
||||
jsonlPath = isSubagentFile
|
||||
? null
|
||||
: require("../lib/antigravity-usage").transcriptPath(req.params.id);
|
||||
} else if (isCodex) {
|
||||
jsonlPath = db
|
||||
.prepare("SELECT path FROM codex_rollouts WHERE session_id = ? AND thread_id = ?")
|
||||
@@ -1164,170 +1161,22 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
? (e) => e.type === "response_item"
|
||||
: isOmp
|
||||
? (e) => e.type === "message"
|
||||
: (e) => TRANSCRIPT_RENDER_TYPES.has(e.type);
|
||||
: isCursor
|
||||
? (e) => e.role === "user" || e.role === "assistant"
|
||||
: isAgy
|
||||
? (e) => AGY_RENDER_TYPES.has(e.type)
|
||||
: (e) => TRANSCRIPT_RENDER_TYPES.has(e.type);
|
||||
const toMessage = isCodex
|
||||
? codexMessage
|
||||
: isOmp
|
||||
? (e, n) => ompMessage(e, n, isSubagentFile)
|
||||
: (e, n) => parseMessage(e, n);
|
||||
: isCursor
|
||||
? cursorMessage
|
||||
: isAgy
|
||||
? agyMessage
|
||||
: (e, n) => parseMessage(e, n);
|
||||
|
||||
// Dedupe state for synthetic rename markers: custom-title lines can repeat
|
||||
// with the same value across a transcript, so only emit when the title
|
||||
// actually changes from the last one we surfaced.
|
||||
let lastRenameTitle = null;
|
||||
|
||||
// Helper: parse a JSONL line into a message object, or null if not a displayable message
|
||||
function parseMessage(entry, num) {
|
||||
// /rename, `claude -n`, picker Ctrl+R → custom-title metadata line. These
|
||||
// commands produce no user/assistant turn, so without this they'd be
|
||||
// invisible. Surface a compact "renamed" marker instead.
|
||||
if (entry.type === "custom-title") {
|
||||
const title = typeof entry.customTitle === "string" ? entry.customTitle.trim() : "";
|
||||
if (!title || title === lastRenameTitle) return null;
|
||||
lastRenameTitle = title;
|
||||
return {
|
||||
type: "session_event",
|
||||
event_kind: "rename",
|
||||
title,
|
||||
timestamp: entry.timestamp || null,
|
||||
content: [],
|
||||
line: num,
|
||||
};
|
||||
}
|
||||
|
||||
// Local slash-command I/O. Newer Claude Code builds write the command
|
||||
// invocation and its captured output as `system`/`local_command` lines
|
||||
// with the TUI markup in a top-level `content` string (older builds used
|
||||
// `user` messages, handled below). Surface those as user-side text so the
|
||||
// client's tuiSegments parser renders the command pill + stdout/stderr
|
||||
// (e.g. /color → "/color" pill + "Session color set to: cyan"). Skip
|
||||
// every other system subtype (turn_duration, stop_hook_summary, …) and
|
||||
// empty local_command lines (e.g. /clear writes a content-less one).
|
||||
if (entry.type === "system") {
|
||||
if (entry.subtype !== "local_command") return null;
|
||||
const sysText = typeof entry.content === "string" ? entry.content : "";
|
||||
if (!sysText.trim()) return null;
|
||||
return {
|
||||
type: "user",
|
||||
sender: "user", // local slash-command I/O is the human's own action
|
||||
timestamp: entry.timestamp || null,
|
||||
content: [{ type: "text", text: truncate(sysText, TEXT_MAX) }],
|
||||
line: num,
|
||||
};
|
||||
}
|
||||
|
||||
// Mid-turn queued message: journaled as a `queued_command` attachment
|
||||
// (never as a `user` line). Surface it at the position the model actually
|
||||
// received it. NOT everything in the queue is the human, though — the
|
||||
// harness delivers its own injections (task-notifications from background
|
||||
// agents, "[SYSTEM NOTIFICATION …]" banners) through the same queue, and
|
||||
// those attachments carry NO `origin` field, while a genuinely typed
|
||||
// message carries `origin.kind = "human"`. So: harness-marker text or a
|
||||
// non-human origin → "system"; everything else → "user". Other attachment
|
||||
// subtypes are harness noise → dropped.
|
||||
if (entry.type === "attachment") {
|
||||
const att = entry.attachment;
|
||||
if (!att || att.type !== "queued_command") return null;
|
||||
const prompt = typeof att.prompt === "string" ? att.prompt : "";
|
||||
if (!prompt.trim()) return null;
|
||||
const lead = prompt.replace(/^\s+/, "");
|
||||
// Same harness markers classifyTranscriptSender strips off user lines.
|
||||
const isHarnessText =
|
||||
lead.startsWith("<task-notification") || lead.startsWith("[SYSTEM NOTIFICATION");
|
||||
const kind = att.origin && typeof att.origin.kind === "string" ? att.origin.kind : null;
|
||||
const isSystem = isHarnessText || (kind !== null && kind !== "human");
|
||||
return {
|
||||
type: "user",
|
||||
sender: isSystem ? "system" : "user",
|
||||
timestamp: entry.timestamp || att.timestamp || null,
|
||||
content: [{ type: "text", text: truncate(prompt, TEXT_MAX) }],
|
||||
line: num,
|
||||
};
|
||||
}
|
||||
|
||||
const msg = entry.type === "assistant" ? entry.message || {} : {};
|
||||
const content = [];
|
||||
|
||||
if (entry.type === "user") {
|
||||
const msgContent = entry.message?.content;
|
||||
if (typeof msgContent === "string") {
|
||||
content.push({ type: "text", text: truncate(msgContent, TEXT_MAX) });
|
||||
} else if (Array.isArray(msgContent)) {
|
||||
for (const block of msgContent) {
|
||||
if (block.type === "text" && block.text) {
|
||||
content.push({ type: "text", text: truncate(block.text, TEXT_MAX) });
|
||||
} else if (block.type === "tool_result") {
|
||||
// A line carries one tool result; an edit's +/- counts ride on it.
|
||||
const diff = patchCounts(entry.toolUseResult);
|
||||
const answers = askAnswers(entry.toolUseResult);
|
||||
content.push({
|
||||
type: "tool_result",
|
||||
id: block.tool_use_id || null,
|
||||
output: truncate(
|
||||
typeof block.content === "string"
|
||||
? block.content
|
||||
: JSON.stringify(block.content || ""),
|
||||
10240
|
||||
),
|
||||
is_error: !!block.is_error,
|
||||
...(diff ? { diff } : {}),
|
||||
...(answers ? { answers } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (msgContent === undefined || msgContent === null) {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
const msgContent = msg.content || [];
|
||||
if (Array.isArray(msgContent)) {
|
||||
for (const block of msgContent) {
|
||||
if (block.type === "text" && block.text) {
|
||||
content.push({ type: "text", text: truncate(block.text, TEXT_MAX) });
|
||||
} else if (block.type === "thinking" && block.thinking) {
|
||||
content.push({ type: "thinking", text: truncate(block.thinking, 10240) });
|
||||
} else if (block.type === "tool_use") {
|
||||
content.push({
|
||||
type: "tool_use",
|
||||
name: block.name || "unknown",
|
||||
id: block.id || null,
|
||||
input: truncateObj(block.input, 10240),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (content.length === 0) return null;
|
||||
|
||||
const message = {
|
||||
type: entry.type,
|
||||
sender: classifyTranscriptSender(entry, isSubagentFile),
|
||||
timestamp: entry.timestamp
|
||||
? typeof entry.timestamp === "number"
|
||||
? new Date(entry.timestamp).toISOString()
|
||||
: entry.timestamp
|
||||
: null,
|
||||
content,
|
||||
line: num,
|
||||
};
|
||||
|
||||
if (entry.type === "assistant") {
|
||||
if (msg.model) message.model = msg.model;
|
||||
// "end_turn" closes the turn; "tool_use" means more work follows.
|
||||
if (typeof msg.stop_reason === "string") message.stop_reason = msg.stop_reason;
|
||||
if (msg.usage) {
|
||||
message.usage = {
|
||||
input_tokens: msg.usage.input_tokens || 0,
|
||||
output_tokens: msg.usage.output_tokens || 0,
|
||||
cache_read_input_tokens: msg.usage.cache_read_input_tokens || 0,
|
||||
cache_creation_input_tokens: msg.usage.cache_creation_input_tokens || 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
const parseMessage = createClaudeLineParser(isSubagentFile);
|
||||
|
||||
if (afterLine !== null) {
|
||||
// Incremental mode: skip lines until after afterLine, collect up to limit, then stop.
|
||||
@@ -1574,7 +1423,10 @@ function ompMessage(entry, num, isSubagent = false) {
|
||||
type: "tool_use",
|
||||
name: c.name || "unknown",
|
||||
id: c.id || null,
|
||||
input: truncateObj(c.arguments && typeof c.arguments === "object" ? c.arguments : {}, 10240),
|
||||
input: truncateObj(
|
||||
c.arguments && typeof c.arguments === "object" ? c.arguments : {},
|
||||
10240
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1601,6 +1453,131 @@ function ompMessage(entry, num, isSubagent = false) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One line of a Cursor agent transcript (`{role, message:{content}}`, see
|
||||
* cursor-usage.js) as a transcript message. Cursor writes no timestamps and no
|
||||
* model, so neither is set. A user line wraps the typed prompt in
|
||||
* `<user_query>` after a `<timestamp>` block; only the prompt is shown.
|
||||
*/
|
||||
function cursorMessage(entry, num) {
|
||||
const m = entry.message || {};
|
||||
const parts = Array.isArray(m.content)
|
||||
? m.content
|
||||
: typeof m.content === "string"
|
||||
? [{ type: "text", text: m.content }]
|
||||
: [];
|
||||
const msg = (type, sender, content) => ({ type, sender, timestamp: null, content, line: num });
|
||||
const content = [];
|
||||
if (entry.role === "user") {
|
||||
for (const c of parts) {
|
||||
if (c && c.type === "text" && typeof c.text === "string") {
|
||||
const q = /<user_query>\s*([\s\S]*?)\s*<\/user_query>/.exec(c.text);
|
||||
const text = (q ? q[1] : c.text.replace(/<timestamp>[\s\S]*?<\/timestamp>/, "")).trim();
|
||||
if (text) content.push({ type: "text", text: truncate(text, TEXT_MAX) });
|
||||
} else if (c && c.type === "tool_result") {
|
||||
const out = Array.isArray(c.content)
|
||||
? c.content.map((x) => (x && typeof x.text === "string" ? x.text : "")).join("")
|
||||
: typeof c.content === "string"
|
||||
? c.content
|
||||
: "";
|
||||
content.push({
|
||||
type: "tool_result",
|
||||
id: c.tool_use_id || null,
|
||||
output: truncate(out, 10240),
|
||||
is_error: c.is_error === true,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!content.length) return null;
|
||||
const onlyResults = content.every((c) => c.type === "tool_result");
|
||||
return msg("user", onlyResults ? "tool" : "user", content);
|
||||
}
|
||||
for (const c of parts) {
|
||||
if (!c) continue;
|
||||
if (c.type === "text" && typeof c.text === "string" && c.text.trim()) {
|
||||
content.push({ type: "text", text: truncate(c.text, TEXT_MAX) });
|
||||
} else if (c.type === "thinking" && typeof c.thinking === "string" && c.thinking.trim()) {
|
||||
content.push({ type: "thinking", text: truncate(c.thinking, 10240) });
|
||||
} else if (c.type === "tool_use") {
|
||||
content.push({
|
||||
type: "tool_use",
|
||||
name: c.name || "unknown",
|
||||
id: c.id || null,
|
||||
input: truncateObj(c.input && typeof c.input === "object" ? c.input : {}, 10240),
|
||||
});
|
||||
}
|
||||
}
|
||||
return content.length ? msg("assistant", "assistant", content) : null;
|
||||
}
|
||||
|
||||
/** The agy steps a conversation view shows (see `agyMessage`). */
|
||||
const AGY_RENDER_TYPES = new Set(["USER_INPUT", "PLANNER_RESPONSE", "GENERIC"]);
|
||||
|
||||
/**
|
||||
* One step of an agy conversation (`brain/<id>/.system_generated/logs/
|
||||
* transcript_full.jsonl`, see antigravity-usage.js) as a transcript message:
|
||||
* `USER_INPUT` the prompt (inside `<USER_REQUEST>`), `PLANNER_RESPONSE` the
|
||||
* model's text, thinking, tool calls and tokens, `GENERIC` a tool's result
|
||||
* (`status: "INVALID"` + `error` an error). agy gives a call no id: its result
|
||||
* is the step right after it (call `i` of step `s` lands at step `s + 1 + i`),
|
||||
* so both are named by that step — the same on every page of the file.
|
||||
*/
|
||||
function agyMessage(entry, num) {
|
||||
const at = typeof entry.created_at === "string" ? entry.created_at : null;
|
||||
const step = Number.isInteger(entry.step_index) ? entry.step_index : num;
|
||||
const msg = (type, sender, content) => ({ type, sender, timestamp: at, content, line: num });
|
||||
const text = typeof entry.content === "string" ? entry.content : "";
|
||||
switch (entry.type) {
|
||||
case "USER_INPUT": {
|
||||
const req = /<USER_REQUEST>\s*([\s\S]*?)\s*<\/USER_REQUEST>/.exec(text);
|
||||
const prompt = (req ? req[1] : text).trim();
|
||||
if (!prompt) return null;
|
||||
return msg("user", entry.source === "USER_EXPLICIT" ? "user" : "system", [
|
||||
{ type: "text", text: truncate(prompt, TEXT_MAX) },
|
||||
]);
|
||||
}
|
||||
case "PLANNER_RESPONSE": {
|
||||
const content = [];
|
||||
if (typeof entry.thinking === "string" && entry.thinking.trim())
|
||||
content.push({ type: "thinking", text: truncate(entry.thinking, 10240) });
|
||||
if (text.trim()) content.push({ type: "text", text: truncate(text, TEXT_MAX) });
|
||||
(Array.isArray(entry.tool_calls) ? entry.tool_calls : []).forEach((c, i) => {
|
||||
if (!c) return;
|
||||
content.push({
|
||||
type: "tool_use",
|
||||
name: c.name || "unknown",
|
||||
id: `agy-${step + 1 + i}`,
|
||||
input: truncateObj(c.args && typeof c.args === "object" ? c.args : {}, 10240),
|
||||
});
|
||||
});
|
||||
if (!content.length) return null;
|
||||
const out = msg("assistant", "assistant", content);
|
||||
if (Number.isFinite(entry.input_tokens) || Number.isFinite(entry.output_tokens))
|
||||
out.usage = {
|
||||
input_tokens: entry.input_tokens || 0,
|
||||
output_tokens: entry.output_tokens || 0,
|
||||
cache_read_input_tokens: entry.cache_read_tokens || 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
};
|
||||
return out;
|
||||
}
|
||||
case "GENERIC": {
|
||||
// Its first lines say when the call ran: not part of what it returned.
|
||||
const output = text.replace(/^Created At: [^\n]*\nCompleted At: [^\n]*\n\n?/, "");
|
||||
return msg("user", "tool", [
|
||||
{
|
||||
type: "tool_result",
|
||||
id: `agy-${step}`,
|
||||
output: truncate(output, 10240),
|
||||
is_error: entry.status === "INVALID" || entry.status === "ERROR" || !!entry.error,
|
||||
},
|
||||
]);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -1637,60 +1614,10 @@ function pageMessages(all, { limit, afterLine, beforeLine, offset }) {
|
||||
};
|
||||
}
|
||||
|
||||
/** The +/- line counts of a Write/Edit/MultiEdit result (`toolUseResult`):
|
||||
* its `structuredPatch` hunks' `+`/`-` lines, or every line of a file it
|
||||
* created. Null for any other result. */
|
||||
function patchCounts(r) {
|
||||
if (!r || typeof r !== "object") return null;
|
||||
if (Array.isArray(r.structuredPatch) && r.structuredPatch.length) {
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
for (const h of r.structuredPatch) {
|
||||
for (const l of Array.isArray(h?.lines) ? h.lines : []) {
|
||||
if (typeof l !== "string") continue;
|
||||
if (l.startsWith("+")) added++;
|
||||
else if (l.startsWith("-")) removed++;
|
||||
}
|
||||
}
|
||||
return { added, removed };
|
||||
}
|
||||
if (r.type === "create" && typeof r.content === "string") {
|
||||
return { added: r.content.split("\n").length - (r.content.endsWith("\n") ? 1 : 0), removed: 0 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** An AskUserQuestion result's answers (`toolUseResult.answers`): question
|
||||
* text → the option picked, or what the user typed. Null for any other result. */
|
||||
function askAnswers(r) {
|
||||
if (!r || typeof r !== "object" || !r.answers || typeof r.answers !== "object") return null;
|
||||
if (Array.isArray(r.answers)) return null;
|
||||
const out = {};
|
||||
for (const [q, a] of Object.entries(r.answers)) if (typeof a === "string") out[q] = a;
|
||||
return Object.keys(out).length ? out : null;
|
||||
}
|
||||
|
||||
// A conversation turn — a prompt, an answer — is shown whole: it is bounded by
|
||||
// the model's output and what the user typed. Only tool payloads and thinking
|
||||
// (the parts that can run to megabytes) keep the 10 KB cap. This one guards
|
||||
// against a pathological line, not a long answer.
|
||||
const TEXT_MAX = 1024 * 1024;
|
||||
|
||||
function truncate(str, maxLen) {
|
||||
if (!str || str.length <= maxLen) return str;
|
||||
return str.slice(0, maxLen) + "[truncated]";
|
||||
}
|
||||
|
||||
function truncateObj(obj, maxLen) {
|
||||
if (!obj) return obj;
|
||||
const json = JSON.stringify(obj);
|
||||
if (json.length <= maxLen) return obj;
|
||||
return { _truncated: truncate(json, maxLen) };
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
module.exports.sessionStats = sessionStats;
|
||||
// Exported for unit tests — sender attribution is correctness-critical.
|
||||
module.exports.classifyTranscriptSender = classifyTranscriptSender;
|
||||
module.exports.patchCounts = patchCounts;
|
||||
module.exports.askAnswers = askAnswers;
|
||||
module.exports.agentInfo = agentInfo;
|
||||
|
||||
Reference in New Issue
Block a user