From 49f87c4da385973f0ffdafd114cbe75d3eb90568 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Mon, 19 Oct 2026 17:19:41 +0700 Subject: [PATCH] =?UTF-8?q?chore:=20v2.8.12=20=E2=80=94=20clone=20queue=20?= =?UTF-8?q?and=20progress,=20session=20defaults,=20chat=20agents,=20drops?= =?UTF-8?q?=20as=20@=20mentions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Whole tree from parallel sessions, plus the version bump: - Clone: a queue with live progress (Add workspace and extra folders), clones run under nice/ionice so the machine stays responsive. - Settings → AI tools → Session defaults: a new session's default permission mode / bypass per account. - Chat lists the background subagents that have not reported back yet. - A file pasted or dropped into a claude pane goes in as an @ mention. - Changes panel: say why amend/undo is refused on a pushed commit. - openapi.yaml regenerated (Artifacts, Host, resumeById). --- CLAUDE.md | 4 +- client/src/components/ContextMenu.tsx | 4 + client/src/components/Sagi/PetChat.tsx | 2 +- .../conversation/ConversationView.tsx | 7 +- .../components/conversation/MessageList.tsx | 273 ++++++- .../__tests__/MessageList.agents.test.tsx | 118 +++ .../__tests__/MessageList.sender.test.tsx | 78 +- client/src/components/lanes/AddLaneModal.tsx | 31 +- client/src/components/lanes/CloneProgress.tsx | 93 +++ .../src/components/lanes/CloneSourcesForm.tsx | 28 +- .../src/components/lanes/LaneChangesPanel.tsx | 7 +- client/src/components/lanes/SessionReplay.tsx | 2 +- client/src/components/lanes/WorkspaceHome.tsx | 44 +- .../lanes/__tests__/AddLaneModal.test.tsx | 16 + .../lanes/__tests__/CloneProgress.test.tsx | 79 ++ .../lanes/__tests__/CloneSourcesForm.test.tsx | 25 +- .../lanes/__tests__/LaneChangesPanel.test.tsx | 14 + .../lanes/__tests__/WorkspaceHome.test.tsx | 4 +- .../onboarding/OnboardingWizard.tsx | 4 +- .../onboarding/__tests__/Onboarding.test.tsx | 12 +- client/src/components/run/LaneConsolePane.tsx | 1 + client/src/components/run/PaneChat.tsx | 7 +- client/src/components/run/PaneGrid.tsx | 6 +- client/src/components/run/RunSetup.tsx | 25 +- client/src/components/run/TerminalView.tsx | 12 +- .../run/__tests__/PaneChat.test.tsx | 6 +- .../run/__tests__/RunSetup.test.tsx | 22 + .../settings/SessionDefaultsSettings.tsx | 154 ++++ .../SessionDefaultsSettings.test.tsx | 72 ++ client/src/i18n/locales/en/lanes.json | 9 + client/src/i18n/locales/en/run.json | 3 + client/src/i18n/locales/en/sessions.json | 8 +- client/src/i18n/locales/en/settings.json | 14 + client/src/i18n/locales/vi/lanes.json | 9 + client/src/i18n/locales/vi/run.json | 3 + client/src/i18n/locales/vi/sessions.json | 8 +- client/src/i18n/locales/vi/settings.json | 14 + client/src/lib/api.ts | 31 + client/src/lib/panePaste.ts | 7 +- client/src/lib/promptAttach.ts | 18 +- client/src/lib/sessionDefaults.ts | 58 +- client/src/lib/userPrefs.ts | 3 + client/src/pages/SettingsHub.tsx | 2 + .../screens.snapshot.test.tsx.snap | 2 +- docs/API.md | 28 +- docs/LANES.md | 2 +- docs/plans/onboarding/README.md | 4 +- openapi.yaml | 678 +++++++++++++++++- package-lock.json | 4 +- package.json | 2 +- server/__tests__/api.test.js | 3 + server/__tests__/clone-queue.test.js | 132 ++++ server/__tests__/git-clone.test.js | 59 ++ server/lib/clone-queue.js | 182 +++++ server/lib/git-identity.js | 36 +- server/lib/worktree.js | 45 +- server/openapi-extra.js | 2 + server/openapi-extra/artifacts.js | 359 ++++++++++ server/openapi-extra/files.js | 28 + server/routes/lanes.js | 32 + server/routes/me.js | 3 + 61 files changed, 2824 insertions(+), 114 deletions(-) create mode 100644 client/src/components/conversation/__tests__/MessageList.agents.test.tsx create mode 100644 client/src/components/lanes/CloneProgress.tsx create mode 100644 client/src/components/lanes/__tests__/CloneProgress.test.tsx create mode 100644 client/src/components/settings/SessionDefaultsSettings.tsx create mode 100644 client/src/components/settings/__tests__/SessionDefaultsSettings.test.tsx create mode 100644 server/__tests__/clone-queue.test.js create mode 100644 server/lib/clone-queue.js create mode 100644 server/openapi-extra/artifacts.js diff --git a/CLAUDE.md b/CLAUDE.md index 17dfd4d7..2bee720f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v2.8.11 (2026-10-19) — omp and OpenCode sessions/tokens/subagents are polled from their own stores; Grok hooks are tagged `grok`; Kimi and Copilot left the catalog._ 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.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. ## Project mission - Maintain a reliable local-first dashboard for Claude Code session monitoring. @@ -47,7 +47,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which - **The dashboard's git writes are these twenty-two, and the guards are IN `git-ops.js`, not in the UI.** `POST /api/lanes/:id/git/:action` offers `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`, `discard-hunk`, `commit`, `switch`, `branch-create`, `branch-delete`, `publish`, `merge`, `resolve`, `merge-commit`, `merge-abort`, `cherry-pick`, `revert`, `undo-commit`, `amend`, `ignore` — nothing else. `commit`/`amend` with `paths` (the Changes panel's ticked files, JetBrains-style) run `git commit --only -- ` over `resolvePaths`-checked paths, never a conflicted one, and with `hunks` (ticked change blocks) commit from a TEMPORARY `GIT_INDEX_FILE` built from git's own fresh diff (`selectionPatch`, never request text), then reset only those paths' real index entries; `ignore` appends an anchored `/path` line to the lane's OWN `.gitignore` for an untracked path only (`ETRACKED`), never through a symlink. **`publish` is the ONE write that takes a destination from the request** (`{url}`), so it can only give a remote to a repo that has NONE: it refuses before the first commit (`ENOCOMMITS`) and whenever `git remote` prints anything (`EREMOTEEXISTS` — it never re-points a remote, so it cannot redirect a repo that already publishes somewhere), the name is always `origin`, the URL passes `validateRemoteUrl`'s allowlist (`https://` with NO userinfo, `ssh://`, scp-like `user@host:path` — never a local path, `file://`, `ext::`, `fd::`, `git://`, `http://` or anything starting with `-`), it runs `remote add origin -- ` then the same `push -u origin HEAD` as `push`, and a failed push removes `origin` again. That is still weaker than the AI-proxy "a key goes only to its stored endpoint" rule — a browser on the dashboard's origin can publish a remote-less repo to any allowlisted host — so do not widen the allowlist or let it touch a repo that already has a remote. **A path in the body is never a path**: `resolvePaths` accepts only what `git status` is reporting as changed right now, which is the whole authorization story for the per-file actions (no `..`, no absolute path, no untouched file). `discard`, `discard-hunk` and `merge-abort` are the irreversible actions and all refuse to run without `confirm: true`; do not add another destructive action without the same gate. `branch-create` never switches (the reason to create one from a lane is a sibling worktree, not moving a working agent's HEAD) and `branch-delete` is `-d`, never `-D`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds. `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent; `push` pushes `HEAD`, never a branch name from the request; `commit` passes its message as one argv element. **The lane is the boundary, not the repo**: a subdir lane's reads and writes stay under its own cwd, and an extra folder (`?source=`, resolved by `sourceFolder` from a `folder` row of THIS lane's `lane_sources`, never a path — `404 ENOSOURCE` otherwise) is its own boundary under every guard here; no read may return a `settings.local.json` (`ESECRET`) — do not add a git read that skips `lanePath`/`isSecret`. **A merge is a history-joining action allowed only because its recovery is a button too** (`merge-abort`) — and `cherry-pick`/`revert` ride the same rule (a non-merge commit by sha, `EMERGECOMMIT`/`EBADSHA`; same clean-tree, repo-root and conflict flow; `merge-commit`/`merge-abort` finish or abort whichever of the three `operationInProgress` finds open; a no-op is aborted again as `EEMPTY`, never left open): it starts only by name, on a clean tree, from a lane at the repo ROOT (`ESUBDIR` — a merge is repo-wide and a subdir lane could not resolve a conflict outside itself), one at a time (`pull`/`switch`/`merge`/hunk actions answer `EMERGING` meanwhile); the ref is looked up in `refs/heads`+`refs/remotes`, never passed through; a conflicted file is resolved only through `resolve` (ours/theirs, or `done` once no conflict marker is left — `stage` refuses a conflicted file that still has markers, `EMARKERS`), and `merge-commit` refuses while anything is unmerged (`ECONFLICTS`). Do not add rebase, a hard/mixed reset or stash here: a dashboard click is the wrong place to start an operation whose recovery needs a terminal, and a hard `reset` already lives behind `worktree.js`'s three-check destroy guard. The ONE reset is `undo-commit` = `reset --soft HEAD~1` (`confirm: true`, repo root, never the root or a merge commit), and it and `amend` act only on the HEAD the request names (`sha`, `ESTALE` once an agent committed past it) and refuse any HEAD a remote-tracking branch already holds (`EPUSHED`); a terminal-started rebase blocks every history action (`EMERGING`) — rewriting a pushed commit is a terminal decision; do not relax that check. A button that looks disabled in `LaneGitBar` is a courtesy; the refusal that matters is the server's. Reads (`/git`, `/git/changes`, `/git/log`, `/git/file`, `/git/raw` — an image's bytes for the viewer, extension-allowlisted and served under a `sandbox` CSP + `nosniff` so an SVG runs no script; keep both headers —, `/git/ls`, `/git/tree`, `/git/branches`) stay unguarded like `/git` always was, and resolve their repo from the LANE ROW (or its session / `lane_sources` row) — `GET /api/lanes/branches` is the only one that takes a path from the request, because its lane does not exist yet. - **A clone runs as its folder's account with THAT account's own identity, and only the public key ever leaves the server.** `POST /api/lanes/clone` takes a URL only through `validateRemoteUrl` (the `publish` allowlist) and a parent held to the user's own folder like New folder; `server/lib/git-identity.js` runs `git clone` as the folder's account (S3: the member) and writes the identity into the CLONE's config (`core.sshCommand` / `credential.helper=store --file=`), never the user's global config. A member's SSH key and HTTPS store live in their home under `~/.config/agenthub/`, written only through `write-as` (secrets on stdin, never sudo argv/env), the host's under `/agenthub-ssh/` + the shared store below; `/api/me/git` returns only `publicKey`/`hasToken`. The git bar's fetch/pull/push in a member's folder uses that same store (`memberHttpsConfig`; a typed login goes in through `write-as` stdin for the one call), and a store that cannot be read is never rewritten (`ESTOREUNREADABLE`); the private key is only `lstat`ed, never read. The commit author is the ACCOUNT's `git config --global` (`PUT /api/me/git/author`). Do not add a route that returns a private key or a token, or one that writes a git identity outside the clone's own config or the account's own `~/.gitconfig`. - **AgentHub's git credentials are its OWN store, shared by every user of the install, and git is given ONLY that store when it holds a login for the host.** `server/lib/git-credentials.js` keeps one record per `protocol + host` under `/agenthub-git-credentials/` (0700/0600) and no route ever returns a password (`hasToken` only). `gitConfigFor` runs a network action with `-c credential.helper= -c credential.helper=` whenever AgentHub holds a login for a repo remote's host or the request carries one — the machine's helpers are left out on purpose: a stale token there would win over the typed one, and git's post-success `store` would copy a use-once token into the OS user's helper. The secret travels in the helper's env or the store file, never argv. A typed credential is accepted only for one of the repo's OWN remote hosts (`EBADHOST`), so a login is never sent to a server it was not typed for, and it is saved only after git SUCCEEDED and `remember` is set. Do not add a route that returns a stored password or a path that forwards one to a host other than its own. -- **A lane's `tool` is its DEFAULT, immutable; a session's tool is the session's choice — and only an AI Tools catalog id ever comes from a request.** `lanes.tool` (default `claude`) is any id `server/lib/ai-tools.js` knows; `createLane` refuses an unknown one (`EBADTOOL`) and one the user disabled (`EDISABLED` — only CREATION asks, an existing lane on a tool disabled since keeps working), and it is deliberately absent from `PATCHABLE`. A NEW session names its own with `tool` on `POST /api/lanes/:id/start` (the home's **AI** select, sent only when it differs from the lane's): `validateTool` again (`EBADTOOL`/`EDISABLED`, 400), and the id only picks a catalog file — the command, args and env still come from that file. A resume keeps its session's own tool (`sessions.tool`), never the request's. `POST /api/run` still takes the lane's tool. A lane whose tool left the catalog answers `ENOTOOL` at start. The sidebar's tool icon is per SESSION (`WorkspaceSessionList`), not per workspace row. A pane has no run form: `RunSetup` is gone from `LaneConsolePane`, and the workspace home's prompt box (`WorkspaceHome.tsx`: AI and ONE Model chip — `ModelMenu` in `RunSetup.tsx`, the model list plus Effort, a proxy's own models on a proxy — inside the box; AI Proxy, Permission and Local / New worktree in a row under it, like Claude Desktop) is the one place a session starts with a prompt (the session view's tab bar `+`, `ConsoleTabs.tsx`, starts a bare CLI or a plain shell — `shell: true`, nothing from the request but the slot; the home's AI menu has the same **Open terminal**). `claude`/`codex` keep their hand-written argv builders (`launcher` in their catalog file — a `DASHBOARD_AI_TOOLS_DIR` file may NOT claim one); every other tool is started by its detected absolute path as `[path, ...args, ...resume?, ...bypass?, ...prompt]` (`...resumeById, ` in place of `resume` for a named session, only when its catalog file declares `resumeById`), every piece but the prompt from its catalog file, the prompt ONE argv element or, for an `after-start` tool, a tmux paste buffer (`tmux.pasteText`) after `delayMs`. Codex-only fields (`sandbox`, `approval`) are validated against the fixed allowlists in `server/lib/pty-run.js`. **Bypass args are emitted ONLY when the run request carries `bypass: true`** — the run form's red per-run switch, never remembered — and they come from the tool's catalog file (Codex: `--dangerously-bypass-approvals-and-sandbox`, which then replaces `-s`/`-a`); Claude has none (it has the `bypassPermissions` mode), and `--dangerously-bypass-hook-trust` is never emitted. Every pane gets the dashboard's hydrated `PATH` through tmux `-e`. Only `claude`/`codex` are `proxyCapable`; `resolveProxy` accepts only `native` for any other tool, else `EUNSUPPORTED`. `GET /api/run/binary?tool=` resolves through the catalog — the query string never names a command. Do not add a route that takes a command, args or env for a tool from a request. +- **A lane's `tool` is its DEFAULT, immutable; a session's tool is the session's choice — and only an AI Tools catalog id ever comes from a request.** `lanes.tool` (default `claude`) is any id `server/lib/ai-tools.js` knows; `createLane` refuses an unknown one (`EBADTOOL`) and one the user disabled (`EDISABLED` — only CREATION asks, an existing lane on a tool disabled since keeps working), and it is deliberately absent from `PATCHABLE`. A NEW session names its own with `tool` on `POST /api/lanes/:id/start` (the home's **AI** select, sent only when it differs from the lane's): `validateTool` again (`EBADTOOL`/`EDISABLED`, 400), and the id only picks a catalog file — the command, args and env still come from that file. A resume keeps its session's own tool (`sessions.tool`), never the request's. `POST /api/run` still takes the lane's tool. A lane whose tool left the catalog answers `ENOTOOL` at start. The sidebar's tool icon is per SESSION (`WorkspaceSessionList`), not per workspace row. A pane has no run form: `RunSetup` is gone from `LaneConsolePane`, and the workspace home's prompt box (`WorkspaceHome.tsx`: AI and ONE Model chip — `ModelMenu` in `RunSetup.tsx`, the model list plus Effort, a proxy's own models on a proxy — inside the box; AI Proxy, Permission and Local / New worktree in a row under it, like Claude Desktop) is the one place a session starts with a prompt (the session view's tab bar `+`, `ConsoleTabs.tsx`, starts a bare CLI or a plain shell — `shell: true`, nothing from the request but the slot; the home's AI menu has the same **Open terminal**). `claude`/`codex` keep their hand-written argv builders (`launcher` in their catalog file — a `DASHBOARD_AI_TOOLS_DIR` file may NOT claim one); every other tool is started by its detected absolute path as `[path, ...args, ...resume?, ...bypass?, ...prompt]` (`...resumeById, ` in place of `resume` for a named session, only when its catalog file declares `resumeById`), every piece but the prompt from its catalog file, the prompt ONE argv element or, for an `after-start` tool, a tmux paste buffer (`tmux.pasteText`) after `delayMs`. Codex-only fields (`sandbox`, `approval`) are validated against the fixed allowlists in `server/lib/pty-run.js`. **Bypass args are emitted ONLY when the run request carries `bypass: true`** — the home's red per-run switch, which starts from the account's own default (Settings → AI tools → **Session defaults**, `client/src/lib/sessionDefaults.ts`: Claude's default mode may be `bypassPermissions`, Codex's bypass may default on — set only behind a confirm, never by the first-run wizard, never for the pet chat; the server never remembers it) — and they come from the tool's catalog file (Codex: `--dangerously-bypass-approvals-and-sandbox`, which then replaces `-s`/`-a`); Claude has none (it has the `bypassPermissions` mode), and `--dangerously-bypass-hook-trust` is never emitted. Every pane gets the dashboard's hydrated `PATH` through tmux `-e`. Only `claude`/`codex` are `proxyCapable`; `resolveProxy` accepts only `native` for any other tool, else `EUNSUPPORTED`. `GET /api/run/binary?tool=` resolves through the catalog — the query string never names a command. Do not add a route that takes a command, args or env for a tool from a request. - **Antigravity hooks are observational only, and never answer a permission.** `server/lib/antigravity-hooks.js` writes only the named hook `agenthub` in `~/.gemini/config/hooks.json`, only on a click, and leaves every other name alone. It installs PreInvocation, PostToolUse and Stop and NEVER PreToolUse: agy reads a PreToolUse answer as a permission decision and DENIES the tool on `{}` (verified on 1.2.13), `allow` would override the user's own permission rules and `ask` would force a prompt — there is no neutral answer. The forwarder must keep printing `{}` and nothing else. The file is global (the Antigravity IDE loads it too), so the route accepts a hook only when its `transcriptPath` lies under `~/.gemini/antigravity-cli/`, and a hook's `tool` is an allowlist (`codex`, `antigravity`, `grok` — also recognised by its payload's camelCase `hookEventName` + `workspaceRoot`, since Grok runs the Claude hooks — else `claude`). AgentHub never reads the IDE's data. - **Codex hooks run only because the user trusted them in Codex, and AgentHub never decides that for them.** `server/lib/codex-hooks.js` writes `$CODEX_HOME/hooks.json` only on the user's click, keeps every entry that is not AgentHub's, and never writes Codex's trust (`[hooks.state]` in `config.toml` — read only, to report); nothing may pass `--dangerously-bypass-hook-trust`. The hook command is the STABLE `'/agenthub-hook.sh' ` because Codex keys trust on the definition: change what the script runs (`refreshScript`), never the command, the timeout or the event list without expecting every user to re-trust. A hook's `tool` is an allowlist (`codex`, `antigravity`, `grok` — also recognised by its payload's camelCase `hookEventName` + `workspaceRoot`, since Grok runs the Claude hooks — else `claude`), and a Codex hook's `transcript_path` is never stored or parsed as a Claude transcript — only `threadOf` reads it, and only a `rollout-*.jsonl` under `$CODEX_HOME/sessions`. - **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. diff --git a/client/src/components/ContextMenu.tsx b/client/src/components/ContextMenu.tsx index 205b330d..83cdf16a 100644 --- a/client/src/components/ContextMenu.tsx +++ b/client/src/components/ContextMenu.tsx @@ -165,12 +165,15 @@ export function MenuItem({ danger, disabled, testId, + title, children, }: { onSelect: () => void; danger?: boolean; disabled?: boolean; testId?: string; + /** Hover text — e.g. why a disabled item cannot run. */ + title?: string; children: ReactNode; }) { return ( @@ -179,6 +182,7 @@ export function MenuItem({ role="menuitem" data-testid={testId} disabled={disabled} + title={title} className={`${MENU_ITEM}${danger ? " !text-status-danger" : ""}`} onClick={onSelect} > diff --git a/client/src/components/Sagi/PetChat.tsx b/client/src/components/Sagi/PetChat.tsx index 7eea2a7e..751afb0e 100644 --- a/client/src/components/Sagi/PetChat.tsx +++ b/client/src/components/Sagi/PetChat.tsx @@ -318,7 +318,7 @@ export function PetChat({ const r = await startInFreeTabDetailed(lane.id, { prompt, ...(tool !== lane.tool ? { tool } : {}), - ...(tool === "claude" ? { permissionMode: readDefaultMode() } : {}), + ...(tool === "claude" ? { permissionMode: readDefaultMode({ safe: true }) } : {}), }); remember({ laneId: r.laneId, runId: r.runId, tool }); } catch (err) { diff --git a/client/src/components/conversation/ConversationView.tsx b/client/src/components/conversation/ConversationView.tsx index b23adc0a..451e17b6 100644 --- a/client/src/components/conversation/ConversationView.tsx +++ b/client/src/components/conversation/ConversationView.tsx @@ -17,7 +17,9 @@ * A caller that knows the session has ended passes `ended`: nothing more is * being written, so the catch-up poll drops from 3 s to 60 s and the * sub-agent list stops being rescanned — a `new_event` frame, a reconnect or - * the view being shown again still fetch the tail at once. + * the view being shown again still fetch the tail at once. The main + * transcript hands MessageList its `sessionId`, so the list can read its + * background agents' own transcripts for their progress. * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= @@ -622,6 +624,9 @@ function ConversationViewPane({ waiting={waiting} onOpenTerminal={onOpenTerminal} stopped={stopped} + sessionId={ + selectedTranscript && selectedTranscript !== "main" ? undefined : sessionId + } /> )} diff --git a/client/src/components/conversation/MessageList.tsx b/client/src/components/conversation/MessageList.tsx index 813802e5..35ab311d 100644 --- a/client/src/components/conversation/MessageList.tsx +++ b/client/src/components/conversation/MessageList.tsx @@ -26,7 +26,14 @@ * terminal (`waiting`) — and, like the terminal's status line, how long the * turn has run and the output tokens it has made; a call running long shows * its own time, and a line silent for minutes turns amber. A turn that ended - * closes with how long it took and its output. + * closes with how long it took and its output. A live chat also lists the + * subagents it launched in the background that have not reported back yet + * (an `Agent` call whose result is "Async agent launched…" and no + * `` naming its call since), like the terminal's footer: + * their turn returns at once, so without the list a turn that ended reads + * "Done" while they still work (issue #81) — it then reads "Replied in …". + * Given the `sessionId`, each line also shows what that agent is doing now + * and how large its context is, read off the tail of its own transcript. * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= @@ -101,6 +108,7 @@ import { CornerDownRight, } from "lucide-react"; import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types"; +import { api } from "../../lib/api"; /** Per-sender visual treatment for a transcript row. A JSONL `type:"user"` line * is not always the human (tool results, harness task-notifications, the @@ -163,6 +171,9 @@ interface MessageListProps { /** Bumped by the chat's Stop: the turn on screen is over, even when the * transcript never says so. */ stopped?: number; + /** The session whose MAIN transcript this is: its background agents' + * own transcripts are read for their progress. */ + sessionId?: string; } /** Build a map from tool_use id → tool_result for matching */ @@ -641,6 +652,7 @@ function MessageListView({ waiting = false, onOpenTerminal, stopped = 0, + sessionId, }: MessageListProps) { const { t } = useTranslation("sessions"); const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]); @@ -659,6 +671,10 @@ function MessageListView({ ); const stats = useMemo(() => (pending ? turnStats(messages) : null), [pending, messages]); const items = useMemo(() => chatItems(messages, keys), [messages, keys]); + const agents = useMemo( + () => (live ? backgroundAgents(messages, toolResultMap) : []), + [live, messages, toolResultMap] + ); if (loading) { return ( @@ -715,7 +731,14 @@ function MessageListView({ /> ); case "done": - return ; + return ( + + ); } })} {pending && ( @@ -727,6 +750,7 @@ function MessageListView({ onOpenTerminal={onOpenTerminal} /> )} + {agents.length > 0 && } ); @@ -783,6 +807,48 @@ export function workingOn( return alive ? { blocks, since: null, last } : null; } +// A launch older than this with no notification is from a run that ended +// without one (the CLI quit, a resume of an old session): not running now. +const AGENT_STALE_MS = 6 * 60 * 60_000; + +const NOTIFIED_CALL = /([^<]+)<\/tool-use-id>/g; +// The launch result names the agent's own transcript (`agent-.jsonl`). +const LAUNCHED_ID = /agentId:\s*([\w-]+)/; + +type BackgroundAgent = { block: TranscriptContent; since: string | null; agentId: string | null }; + +/** The subagents launched in the background that have not reported back: an + * Agent call whose result says it was launched async, with no + * `` naming that call since (any status — completed, + * failed, killed — means it stopped). `since` is its launch time. */ +export function backgroundAgents( + messages: TranscriptMessage[], + results: Map, + now = Date.now() +): BackgroundAgent[] { + const running = new Map(); + for (const msg of messages) { + if (msg.type === "assistant") { + for (const b of msg.content) { + if (b.type !== "tool_use" || !b.id || (b.name !== "Agent" && b.name !== "Task")) continue; + // The output is the result content as JSON when it is a block list. + const output = results.get(b.id)?.output; + if (!output?.includes("Async agent launched")) continue; + const at = msg.timestamp ?? null; + if (at && now - parseDate(at).getTime() > AGENT_STALE_MS) continue; + running.set(b.id, { block: b, since: at, agentId: output.match(LAUNCHED_ID)?.[1] ?? null }); + } + continue; + } + if (!running.size) continue; + for (const c of msg.content) { + if (c.type !== "text" || !c.text || !isTaskNotification(c.text)) continue; + for (const m of c.text.matchAll(NOTIFIED_CALL)) running.delete(m[1]!.trim()); + } + } + return [...running.values()]; +} + /** Output tokens counted once per API call: Claude Code writes one line per * content block, each repeating its call's usage — consecutive lines with * the same prompt size are one call. */ @@ -857,6 +923,23 @@ function useCountUp(value: number): number { return shown; } +/** The time now, ticking every second while `ref`'s box is on screen: a + * hidden tab or a hidden pane (another workspace, kept mounted) shows no + * clock, so the re-render is skipped until it is shown again. */ +function useClock(ref: React.RefObject): number { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const timer = window.setInterval(() => { + const el = ref.current; + if (document.visibilityState !== "visible" || (el && el.getClientRects().length === 0)) + return; + setNow(Date.now()); + }, 1000); + return () => window.clearInterval(timer); + }, [ref]); + return now; +} + /** A call's one-line title: its description (Claude Code's Bash * `description`), else the tool and its target. */ function stepTitle(block: TranscriptContent): string { @@ -886,20 +969,9 @@ function RunningStep({ onOpenTerminal?: () => void; }) { const { t } = useTranslation("sessions"); - const [now, setNow] = useState(() => Date.now()); const [showAll, setShowAll] = useState(false); const boxRef = useRef(null); - useEffect(() => { - // A hidden tab or a hidden pane (another workspace, kept mounted) shows - // no clock: skip the re-render until it is on screen again. - const timer = window.setInterval(() => { - const el = boxRef.current; - if (document.visibilityState !== "visible" || (el && el.getClientRects().length === 0)) - return; - setNow(Date.now()); - }, 1000); - return () => window.clearInterval(timer); - }, []); + const now = useClock(boxRef); const counted = useCountUp(tokens); const age = (at: string | null) => (at ? now - parseDate(at).getTime() : 0); const first = blocks[0]; @@ -976,7 +1048,159 @@ function RunningStep({ ); } -/** Under a turn that ended: how long it took and the output it made. */ +// How often a running agent's transcript tail is re-read, and how much of it +// is kept: enough for its newest step and its newest usage. +const AGENT_TAIL_MS = 4000; +const AGENT_TAIL_KEEP = 40; + +/** The newest lines of each agent's own transcript (by its `agentId`), + * re-read while `ref`'s box is on screen — only the lines after those + * already read (`after`). A failed read leaves the line without detail. */ +function useAgentTails( + sessionId: string | undefined, + ids: string[], + ref: React.RefObject +): Map { + const [tails, setTails] = useState(() => new Map()); + const lastRef = useRef(new Map()); + const key = ids.join(","); + useEffect(() => { + if (!sessionId || !key) return; + let cancelled = false; + let busy = false; + const read = async () => { + const el = ref.current; + if ( + busy || + document.visibilityState !== "visible" || + (el && el.getClientRects().length === 0) + ) + return; + busy = true; + await Promise.all( + key.split(",").map(async (id) => { + const last = lastRef.current.get(id); + try { + const r = await api.sessions.transcript(sessionId, { + agent_id: id, + ...(last ? { after: last } : { limit: 20 }), + }); + if (cancelled || !r.messages.length) return; + lastRef.current.set(id, r.last_line); + setTails((prev) => { + const next = new Map(prev); + next.set(id, [...(prev.get(id) ?? []), ...r.messages].slice(-AGENT_TAIL_KEEP)); + return next; + }); + } catch { + // Non-fatal: the line keeps its description and time. + } + }) + ); + busy = false; + }; + void read(); + const timer = window.setInterval(() => void read(), AGENT_TAIL_MS); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [sessionId, key, ref]); + return tails; +} + +/** What an agent's transcript tail says it is doing: the call it waits on + * (or none while it thinks), and its newest prompt size — its context. */ +export function agentProgress( + messages: TranscriptMessage[], + now = Date.now() +): { step: string | null; context: number } { + let context = 0; + for (let i = messages.length - 1; i >= 0 && !context; i--) { + const u = messages[i]!.usage; + if (messages[i]!.type === "assistant" && u) context = promptTokens(u); + } + const first = workingOn(messages, buildToolResultMap(messages), now)?.blocks[0]; + return { step: first ? stepTitle(first) : null, context }; +} + +/** The subagents still working in the background, one line each like the + * terminal's footer: its type, its task and how long it has run — with its + * step now and its context once its own transcript was read. */ +function BackgroundAgents({ + agents, + sessionId, +}: { + agents: BackgroundAgent[]; + sessionId?: string; +}) { + const { t } = useTranslation("sessions"); + const boxRef = useRef(null); + const now = useClock(boxRef); + const ids = useMemo(() => agents.flatMap((a) => (a.agentId ? [a.agentId] : [])), [agents]); + const tails = useAgentTails(sessionId, ids, boxRef); + return ( +
+
+ + {t("conversation.agentsRunning", { count: agents.length })} +
+
    + {agents.map(({ block, since, agentId }, i) => { + const input = (block.input ?? {}) as Record; + const type = typeof input.subagent_type === "string" ? input.subagent_type : "Agent"; + const desc = typeof input.description === "string" ? input.description : ""; + const tail = agentId ? tails.get(agentId) : undefined; + const { step, context } = tail ? agentProgress(tail, now) : { step: null, context: 0 }; + return ( +
  • +
    + + + {type} + + + {desc} + + {[ + since && formatMs(now - parseDate(since).getTime()), + context > 0 && `↓ ${t("conversation.agentContext", { tokens: fmt(context) })}`, + ] + .filter(Boolean) + .map((v, j) => ( + + {v} + + ))} +
    + {tail && ( +
    + + {step ?? t("conversation.working")} +
    + )} +
  • + ); + })} +
+
+ ); +} + +/** Under a turn that ended: how long it took and the output it made — or, + * while subagents it launched still work (`agents`), that only the reply is + * done. */ /** An option's label carries the model's " (Recommended)" marker into the * answer the CLI records; the user's pick reads without it (every picked * label of a multi-select answer too). */ @@ -1028,14 +1252,21 @@ function AskCard({ ); } -function TurnDone({ ms, output }: { ms: number; output: number }) { +function TurnDone({ ms, output, agents = 0 }: { ms: number; output: number; agents?: number }) { const { t } = useTranslation("sessions"); return (
- + {agents > 0 ? ( + + ) : ( + + )} {[ - t("conversation.turnDone", { time: formatMs(ms) }), + t(agents > 0 ? "conversation.turnReplied" : "conversation.turnDone", { + time: formatMs(ms), + }), output > 0 && t("conversation.turnTokens", { tokens: fmt(output) }), + agents > 0 && t("conversation.agentsStill", { count: agents }), ] .filter(Boolean) .join(" · ")} @@ -1045,8 +1276,8 @@ function TurnDone({ ms, output }: { ms: number; output: number }) { const meta = "text-[10px] font-mono text-fg-muted"; -// What the prompt box sends for an attached file: its saved path under the -// drop dir (`/drops/<16 hex>[-name].ext`, server/routes/run.js; the +// What the prompt box sends for an attached file: its saved path (an `@` +// mention for claude) under the drop dir (`/drops/<16 hex>[-name].ext`, server/routes/run.js; the // older `/agenthub-drops-/` still in past transcripts). const DROP_PATH = /(?<=^|\s)(\S*[\\/](?:agenthub-drops--?\d+|drops)[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+))(?=\s|$)/gi; @@ -1066,7 +1297,7 @@ export function splitDropImages(text: string): { const files: string[] = []; const rest = text.replace(DROP_PATH, (_all, full: string, name: string) => { if (IMAGE_EXT.test(name)) images.push(name); - else files.push(full); + else files.push(full.replace(/^@/, "")); // a claude prompt's `@` mention return ""; }); return { diff --git a/client/src/components/conversation/__tests__/MessageList.agents.test.tsx b/client/src/components/conversation/__tests__/MessageList.agents.test.tsx new file mode 100644 index 00000000..14d94d97 --- /dev/null +++ b/client/src/components/conversation/__tests__/MessageList.agents.test.tsx @@ -0,0 +1,118 @@ +/** + * @file MessageList.agents.test.tsx + * @description Verifies a live chat's background-agents card reads each + * running agent's own transcript (by the agentId its launch result names) + * and shows the step it is on and its context size (issue #81). + * @author Nguyễn Ngọc Trí Vĩ + */ +import { render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { MessageList, agentProgress } from "../MessageList"; +import { api } from "../../../lib/api"; +import type { TranscriptMessage } from "../../../lib/types"; + +vi.mock("../../../lib/api", async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, api: { sessions: { transcript: vi.fn() } } }; +}); + +const at = new Date().toISOString(); +const m = (p: Partial): TranscriptMessage => + ({ type: "user", timestamp: at, content: [], ...p }) as TranscriptMessage; + +// The subagent's own transcript: its task, then a search still running. +const tail: TranscriptMessage[] = [ + m({ sender: "orchestrator", content: [{ type: "text", text: "Find copyId" }] }), + m({ + type: "assistant", + sender: "assistant", + stop_reason: "tool_use", + usage: { input_tokens: 300, output_tokens: 20, cache_read_input_tokens: 54_000 }, + content: [ + { + type: "tool_use", + id: "g1", + name: "Grep", + input: { pattern: "copyId", description: "Searching for copyId" }, + }, + ], + }), +]; + +const main: TranscriptMessage[] = [ + m({ sender: "user", content: [{ type: "text", text: "go" }] }), + m({ + type: "assistant", + sender: "assistant", + stop_reason: "tool_use", + content: [ + { + type: "tool_use", + id: "t1", + name: "Agent", + input: { description: "Checking copyId coverage", subagent_type: "Explore" }, + }, + ], + }), + m({ + content: [ + { + type: "tool_result", + id: "t1", + output: JSON.stringify([ + { type: "text", text: "Async agent launched successfully.\nagentId: a4118367b503adecc" }, + ]), + }, + ], + }), + m({ + type: "assistant", + sender: "assistant", + stop_reason: "end_turn", + content: [{ type: "text", text: "waiting" }], + }), +]; + +describe("background agents' progress", () => { + const rects = Element.prototype.getClientRects; + beforeEach(() => { + // jsdom lays nothing out: make the card count as on screen. + Element.prototype.getClientRects = () => [{}] as unknown as DOMRectList; + vi.mocked(api.sessions.transcript).mockResolvedValue({ + messages: tail, + total: 2, + has_more: false, + first_line: 1, + last_line: 2, + }); + }); + afterEach(() => { + Element.prototype.getClientRects = rects; + vi.mocked(api.sessions.transcript).mockReset(); + }); + + it("reads the step an agent is on and its context", () => { + expect(agentProgress(tail)).toEqual({ step: "Searching for copyId", context: 54_300 }); + expect(agentProgress(tail.slice(0, 1))).toEqual({ step: null, context: 0 }); + }); + + it("shows them on the agent's line, from its own transcript", async () => { + render(); + await waitFor(() => + expect(screen.getByTestId("chat-background-agent-step")).toHaveTextContent( + "Searching for copyId" + ) + ); + expect(api.sessions.transcript).toHaveBeenCalledWith("s1", { + agent_id: "a4118367b503adecc", + limit: 20, + }); + expect(screen.getByTestId("chat-background-agent")).toHaveTextContent("↓ 54.3K tokens"); + }); + + it("reads nothing without the session", () => { + render(); + expect(screen.getByTestId("chat-background-agent")).toBeInTheDocument(); + expect(api.sessions.transcript).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/conversation/__tests__/MessageList.sender.test.tsx b/client/src/components/conversation/__tests__/MessageList.sender.test.tsx index 1ae20186..ede024bb 100644 --- a/client/src/components/conversation/__tests__/MessageList.sender.test.tsx +++ b/client/src/components/conversation/__tests__/MessageList.sender.test.tsx @@ -5,12 +5,13 @@ * layout), Main agent / System under their own label — instead of treating * every `type:"user"` line as the user (reported transcript mis-attribution) — * and folds a run of tool calls into one line; a typed `/command` or `@path` - * shows as a chip. + * shows as a chip. A live chat lists the background subagents still running + * and does not call their turn done (issue #81). * @author Nguyễn Ngọc Trí Vĩ */ import { describe, it, expect } from "vitest"; import { fireEvent, render, screen, within } from "@testing-library/react"; -import { MessageList, turnStats, workLabel, workingOn } from "../MessageList"; +import { MessageList, backgroundAgents, turnStats, workLabel, workingOn } from "../MessageList"; import type { TranscriptMessage } from "../../../lib/types"; function msg(partial: Partial): TranscriptMessage { @@ -343,4 +344,77 @@ describe("MessageList — sender attribution", () => { // Silent for 15 minutes with nothing running: the turn died. expect(workingOn([prompt], new Map(), now + 20 * 60_000)).toBeNull(); }); + + describe("background subagents", () => { + const at = new Date().toISOString(); + const launch = msg({ + type: "assistant", + sender: "assistant", + timestamp: at, + stop_reason: "tool_use", + content: [ + { + type: "tool_use", + id: "t1", + name: "Agent", + input: { description: "Checking copyId coverage", subagent_type: "Explore" }, + }, + ], + }); + const launched = msg({ + timestamp: at, + content: [ + { + type: "tool_result", + id: "t1", + output: JSON.stringify([{ type: "text", text: "Async agent launched successfully." }]), + }, + ], + }); + const reply = msg({ + type: "assistant", + sender: "assistant", + timestamp: at, + stop_reason: "end_turn", + content: [{ type: "text", text: "waiting for the agent" }], + }); + const notified = msg({ + sender: "system", + timestamp: at, + content: [ + { + type: "text", + text: "\na1\nt1\ncompleted\n", + }, + ], + }); + const base = [msg({ sender: "user", timestamp: at }), launch, launched, reply]; + + it("lists an agent until its notification names the call", () => { + const results = (m: TranscriptMessage[]) => + new Map( + m.flatMap((x) => x.content.filter((c) => c.type === "tool_result").map((c) => [c.id!, c])) + ); + expect(backgroundAgents(base, results(base)).map((a) => a.block.id)).toEqual(["t1"]); + const all = [...base, notified]; + expect(backgroundAgents(all, results(all))).toEqual([]); + }); + + it("does not call the turn done while the agent works", () => { + const { rerender } = render(); + expect(screen.getByTestId("chat-background-agent")).toHaveTextContent( + "ExploreChecking copyId coverage" + ); + expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("Replied in"); + expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("1 agent still running"); + rerender(); + expect(screen.queryByTestId("chat-background-agents")).toBeNull(); + expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("Done in"); + }); + + it("shows nothing once the session is no longer live", () => { + render(); + expect(screen.queryByTestId("chat-background-agents")).toBeNull(); + }); + }); }); diff --git a/client/src/components/lanes/AddLaneModal.tsx b/client/src/components/lanes/AddLaneModal.tsx index d867df69..30e3b3d4 100644 --- a/client/src/components/lanes/AddLaneModal.tsx +++ b/client/src/components/lanes/AddLaneModal.tsx @@ -9,7 +9,8 @@ * manually-typed branch name; "Clone" runs `POST /api/lanes/clone` (an * https/ssh URL cloned into a folder of the user's, with their OWN SSH key or * HTTPS login — a 409 `ECREDENTIALS` opens a login row, `ESSHAUTH` points to - * Settings → Git). There is no AI tool picker: the lane is created with the + * Settings → Git; **Partial clone** makes it blobless, and `CloneProgress` + * shows its queue place, then git's progress). There is no AI tool picker: the lane is created with the * default AI tool (Settings → AI Tools), and each new session picks its own * AI on the workspace home. Either * mode's path field can be filled by typing, by the CwdAutocomplete @@ -30,6 +31,7 @@ import { useDialog } from "../../hooks/useDialog"; import { CwdAutocomplete } from "../run/RunSetup"; import { PathPickButton } from "../PathPickButton"; import { api } from "../../lib/api"; +import { CloneProgress, newCloneToken } from "./CloneProgress"; import type { CwdSuggestion } from "../../lib/api"; import type { Lane, LaneWorktree } from "../../lib/types"; import { useAiTools } from "../../hooks/useAiTools"; @@ -116,6 +118,9 @@ export function AddLaneModal({ // server asked for one — a login for the URL's https host. const [cloneUrl, setCloneUrl] = useState(""); const [cloneName, setCloneName] = useState(""); + const [clonePartial, setClonePartial] = useState(false); + /** The running clone's progress token (CloneProgress polls it). */ + const [cloneToken, setCloneToken] = useState(null); const [login, setLogin] = useState<{ host: string; username: string; @@ -159,6 +164,7 @@ export function AddLaneModal({ setMode("repo"); setCloneUrl(""); setCloneName(""); + setClonePartial(false); setLogin(null); setSshRefused(false); setSourceRepo(""); @@ -236,13 +242,17 @@ export function AddLaneModal({ const url = cloneUrl.trim(); if (!url) return; setSshRefused(false); + const progressToken = newCloneToken(); + setCloneToken(progressToken); try { const result = await api.lanes.clone({ + progressToken, url, parent: repo, name: cloneName.trim() || undefined, title: name || undefined, tool, + ...(clonePartial ? { partial: true } : {}), ...(login?.username.trim() && login.token ? { credentials: { @@ -274,6 +284,8 @@ export function AddLaneModal({ setError(err instanceof Error ? err.message : String(err)); } setBusy(false); + } finally { + setCloneToken(null); } return; } @@ -547,6 +559,23 @@ export function AddLaneModal({ spellCheck={false} className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none" /> + + {cloneToken && ( +
+ +
+ )}
)} diff --git a/client/src/components/lanes/CloneProgress.tsx b/client/src/components/lanes/CloneProgress.tsx new file mode 100644 index 00000000..e7cc594a --- /dev/null +++ b/client/src/components/lanes/CloneProgress.tsx @@ -0,0 +1,93 @@ +/** + * @file CloneProgress — the live line under a running clone: its place in the + * server's clone queue while it waits (clones run a few at a time machine-wide, + * `server/lib/clone-queue.js`), then git's own phase, percent, size and speed + * with a bar. It polls `GET /api/lanes/clone/progress/:token` every second for + * the token the clone's POST carried (`newCloneToken`); a 404 — the POST not + * yet arrived, or an older server — just shows nothing yet. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { api, type CloneProgressView } from "../../lib/api"; + +const POLL_MS = 1000; + +/** A fresh, unguessable progress token (works on plain http, unlike randomUUID). */ +export function newCloneToken(): string { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +export function CloneProgress({ token }: { token: string }) { + const { t } = useTranslation("lanes"); + const [view, setView] = useState(null); + + useEffect(() => { + let alive = true; + setView(null); + // Never throws: progress is a courtesy, the clone's own answer decides. + const poll = () => + Promise.resolve() + .then(() => api.lanes.cloneProgress(token)) + .then((v) => { + if (alive) setView(v); + }) + .catch(() => {}); + void poll(); + const timer = setInterval(() => void poll(), POLL_MS); + return () => { + alive = false; + clearInterval(timer); + }; + }, [token]); + + if (!view || view.state === "done" || view.state === "failed") return null; + + if (view.state === "queued") { + return ( +

+ {t("clone.queued", { + position: view.position ?? 1, + waiting: view.waiting, + running: view.running, + limit: view.limit, + })} +

+ ); + } + + const phase = view.phase + ? view.phase === "other" + ? view.label || "" + : t(`clone.phase.${view.phase}`) + : t("clone.connecting"); + const percent = view.percent ?? 0; + return ( +
+

+ + {phase} + {view.phase ? ` ${percent}%` : ""} + + {view.detail && ( + {view.detail} + )} +

+ {view.phase && ( +
+
+
+ )} +
+ ); +} diff --git a/client/src/components/lanes/CloneSourcesForm.tsx b/client/src/components/lanes/CloneSourcesForm.tsx index 2ec23687..dd01ad7f 100644 --- a/client/src/components/lanes/CloneSourcesForm.tsx +++ b/client/src/components/lanes/CloneSourcesForm.tsx @@ -10,7 +10,8 @@ * sent to Settings → Git first — and an HTTPS host with no saved login offers * the login row up front (optional: a public repo needs none). The identity is * read again when the window regains focus, so a key made in another tab - * counts at once. + * counts at once. The running clone shows its queue place, then git's + * progress (`CloneProgress`); **Partial clone** sends `partial: true`. * @author Nguyễn Ngọc Trí Vĩ */ @@ -18,6 +19,7 @@ import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Check, Loader2, X } from "lucide-react"; import { api, type GitIdentity, type LaneSourcesView } from "../../lib/api"; +import { CloneProgress, newCloneToken } from "./CloneProgress"; type Row = { url: string; @@ -25,6 +27,8 @@ type Row = { note?: string; /** The note points at Settings → Git (no key, or a key the host refused). */ ssh?: boolean; + /** While busy: the progress token CloneProgress polls. */ + token?: string; }; type Login = { host: string; username: string; token: string; remember: boolean }; @@ -71,6 +75,7 @@ export function CloneSourcesForm({ const [rows, setRows] = useState([]); const [busy, setBusy] = useState(false); const [login, setLogin] = useState(null); + const [partial, setPartial] = useState(false); const [identity, setIdentity] = useState(null); // An unknown identity (an older server, a failed read) checks nothing: the @@ -116,10 +121,13 @@ export function CloneSourcesForm({ for (const row of next) { if (row.state === "failed") continue; row.state = "busy"; + row.token = newCloneToken(); show(); try { const view = await api.lanes.cloneFolder(laneId, { url: row.url, + progressToken: row.token, + ...(partial ? { partial: true } : {}), ...(login?.username.trim() && login.token ? { credentials: { @@ -151,6 +159,7 @@ export function CloneSourcesForm({ row.note = e.message || String(err); } } + row.token = undefined; show(); } if (asked) setLogin(asked); @@ -191,6 +200,18 @@ export function CloneSourcesForm({ className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary" />

{t("home.cloneHint", { path: parent })}

+ {sshHosts.length > 0 && (

+ {r.state === "busy" && r.token && ( +

+ +
+ )} {r.note && ( {r.note} diff --git a/client/src/components/lanes/LaneChangesPanel.tsx b/client/src/components/lanes/LaneChangesPanel.tsx index e81813a4..834a7368 100644 --- a/client/src/components/lanes/LaneChangesPanel.tsx +++ b/client/src/components/lanes/LaneChangesPanel.tsx @@ -1728,7 +1728,12 @@ export function LaneChangesPanel({ {commits?.[0]?.sha === commitCtx.menu.item.sha && ( { if (!text || busy || uploading || livePane || !resumable) return; diff --git a/client/src/components/lanes/WorkspaceHome.tsx b/client/src/components/lanes/WorkspaceHome.tsx index e8b3bfd7..3f74c050 100644 --- a/client/src/components/lanes/WorkspaceHome.tsx +++ b/client/src/components/lanes/WorkspaceHome.tsx @@ -11,7 +11,7 @@ * shell tab at once, like the tab bar's New terminal) and one Model chip (`ModelMenu`: the tool's own list or the * proxy's models, plus Effort); under it the quieter choices — AI Proxy * (Native or a profile), Permission (Claude's mode, Codex's sandbox/approval, - * a catalog tool's bypass) and Local / New worktree — the workspace's Recents — the sessions it + * a catalog tool's bypass — a YOLO chip for Codex) and Local / New worktree — the workspace's Recents — the sessions it * owns and has not archived (`?lane=&archived=0`, with each one's state dot, * **Show more** past the first page, and the sidebar row's Rename/Archive in * its right-click menu), then the archived ones folded with Unarchive; a live session opens its pane, @@ -20,7 +20,6 @@ * @author Nguyễn Ngọc Trí Vĩ */ -import { Checkbox } from "../Checkbox"; import { isSubmitEnter } from "../../lib/keys"; import { confirmDialog } from "../../lib/confirm"; import { useEffect, useRef, useState } from "react"; @@ -34,6 +33,7 @@ import { MoreHorizontal, ArrowUp, SquareTerminal, + Zap, } from "lucide-react"; import { api, @@ -76,7 +76,7 @@ export { } from "./startSession"; import { startInFreeTab } from "./startSession"; import { WorkspaceContext } from "./WorkspaceContext"; -import { readDefaultMode } from "../../lib/sessionDefaults"; +import { readCodexDefaults, readDefaultMode } from "../../lib/sessionDefaults"; /** Recents' page size; Show more adds another. */ const RECENT_PAGE = 30; @@ -165,12 +165,14 @@ function HomeBody({ // Claude and Codex always take a model and an effort; a catalog tool a // model only when its file names the flag, an effort never. const hasModel = claude || codex || !!entry?.modelFlag; - // The account's own default (first-run setup) — never bypass. + // The account's own defaults (first-run setup, Settings → AI tools → + // Session defaults); a pick here is for this session only. const [mode, setMode] = useState(readDefaultMode); - const [sandbox, setSandbox] = useState("workspace-write"); - const [approval, setApproval] = useState("on-request"); - // Per session, never remembered. + const [sandbox, setSandbox] = useState(() => readCodexDefaults().sandbox); + const [approval, setApproval] = useState(() => readCodexDefaults().approval); + // Off for every tool but Codex, which starts from the account's default. const [bypass, setBypass] = useState(false); + useEffect(() => setBypass(codex && readCodexDefaults().bypass), [codex]); const canBypass = !claude && !!entry?.bypass; const [effort, setEffort] = useState(""); const hasEffort = claude || codex; @@ -254,7 +256,7 @@ function HomeBody({ } }; - const text = promptWithImages(prompt, images); + const text = promptWithImages(prompt, images, claude); const start = async () => { // Enter reaches here past the disabled button: the same guards again. // A ref, not only the state: two Enters in one tick both read `starting` false. @@ -607,15 +609,25 @@ function HomeBody({
)} {canBypass && ( - - setBypass(!bypass)} + className={`flex items-center gap-1.5 rounded-md px-2 py-1 text-xs transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-status-danger/60 ${ + bypass + ? "bg-status-danger/10 font-medium text-status-danger ring-1 ring-inset ring-status-danger/30 hover:bg-status-danger/20" + : "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary" + }`} + > + - + {t(codex ? "run:fields.yolo" : "run:fields.bypassShort")} + )} {danger && ( diff --git a/client/src/components/lanes/__tests__/AddLaneModal.test.tsx b/client/src/components/lanes/__tests__/AddLaneModal.test.tsx index 35de7dfe..50ee43d9 100644 --- a/client/src/components/lanes/__tests__/AddLaneModal.test.tsx +++ b/client/src/components/lanes/__tests__/AddLaneModal.test.tsx @@ -786,9 +786,25 @@ describe("AddLaneModal — Clone", () => { ) ); expect(vi.mocked(api.lanes.clone).mock.calls[0]?.[0].credentials).toBeUndefined(); + expect(vi.mocked(api.lanes.clone).mock.calls[0]?.[0].partial).toBeUndefined(); await waitFor(() => expect(onAdded).toHaveBeenCalled()); }); + it("sends partial only when the box is ticked", async () => { + vi.mocked(api.lanes.clone).mockResolvedValue({ + lane: laneFixture({ id: 7 }), + created: true, + path: "/home/an/code/big", + }); + const user = userEvent.setup(); + await fillClone(user, "https://git.example.com/o/big.git"); + await user.click(screen.getByTestId("add-lane-clone-partial")); + await user.click(screen.getByRole("button", { name: "Add workspace" })); + await waitFor(() => + expect(api.lanes.clone).toHaveBeenCalledWith(expect.objectContaining({ partial: true })) + ); + }); + it("asks for a login on ECREDENTIALS and sends it on the retry", async () => { vi.mocked(api.lanes.clone) .mockRejectedValueOnce( diff --git a/client/src/components/lanes/__tests__/CloneProgress.test.tsx b/client/src/components/lanes/__tests__/CloneProgress.test.tsx new file mode 100644 index 00000000..b31ce320 --- /dev/null +++ b/client/src/components/lanes/__tests__/CloneProgress.test.tsx @@ -0,0 +1,79 @@ +/** + * @file Tests for CloneProgress: a queued clone shows its place in the + * server's clone queue, a running one git's phase, percent and speed with a + * bar, and nothing shows before the server knows the token. Also that + * `newCloneToken` makes a token the server accepts. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import "../../../i18n"; + +vi.mock("../../../lib/api", () => ({ + api: { lanes: { cloneProgress: vi.fn() } }, +})); + +import { api, type CloneProgressView } from "../../../lib/api"; +import { CloneProgress, newCloneToken } from "../CloneProgress"; + +const base: CloneProgressView = { + state: "running", + position: null, + running: 2, + waiting: 1, + limit: 2, + phase: null, + label: null, + percent: null, + detail: null, +}; + +describe("CloneProgress", () => { + // Braces: a function returned from beforeEach is run as its teardown. + beforeEach(() => { + vi.mocked(api.lanes.cloneProgress).mockReset(); + }); + + it("shows the clone's place in the queue while it waits", async () => { + vi.mocked(api.lanes.cloneProgress).mockResolvedValue({ + ...base, + state: "queued", + position: 1, + }); + render(); + expect(await screen.findByTestId("clone-progress")).toHaveTextContent( + "Waiting in the clone queue — #1 of 1 (2/2 clones running)" + ); + }); + + it("shows git's phase, percent and speed with a bar while it runs", async () => { + vi.mocked(api.lanes.cloneProgress).mockResolvedValue({ + ...base, + phase: "receiving", + label: "Receiving objects", + percent: 45, + detail: "450/1000, 120.50 MiB | 5.20 MiB/s", + }); + render(); + const line = await screen.findByTestId("clone-progress"); + expect(line).toHaveTextContent("Downloading 45%"); + expect(line).toHaveTextContent("120.50 MiB | 5.20 MiB/s"); + expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "45"); + }); + + it("shows nothing while the server does not know the token yet", async () => { + vi.mocked(api.lanes.cloneProgress).mockImplementation(() => + Promise.reject(Object.assign(new Error("no such clone"), { code: "ENOTFOUND" })) + ); + render(); + await new Promise((r) => setTimeout(r, 20)); + expect(screen.queryByTestId("clone-progress")).toBeNull(); + }); + + it("makes tokens the server accepts", () => { + const a = newCloneToken(); + expect(a).toMatch(/^[A-Za-z0-9_-]{16,64}$/); + expect(newCloneToken()).not.toBe(a); + }); +}); diff --git a/client/src/components/lanes/__tests__/CloneSourcesForm.test.tsx b/client/src/components/lanes/__tests__/CloneSourcesForm.test.tsx index 8636d976..aa25b5f0 100644 --- a/client/src/components/lanes/__tests__/CloneSourcesForm.test.tsx +++ b/client/src/components/lanes/__tests__/CloneSourcesForm.test.tsx @@ -3,7 +3,8 @@ * an extra folder, a host that wants a login opens the login row, and Retry * re-runs only the failed URLs with that login. An SSH link while the * account has no key is never sent — the user is pointed at Settings → Git — - * and an HTTPS host with no saved login is flagged before Clone. + * and an HTTPS host with no saved login is flagged before Clone. The + * partial-clone box adds `partial: true` to every clone. * @author Nguyễn Ngọc Trí Vĩ */ @@ -59,12 +60,29 @@ describe("CloneSourcesForm", () => { await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(2)); expect(api.lanes.cloneFolder).toHaveBeenLastCalledWith(3, { + progressToken: expect.any(String), url: "https://private.test/b.git", credentials: { username: "dev", password: "tok", remember: true }, }); expect(screen.getByTestId("workspace-clone-input")).toHaveValue(""); }); + it("asks for partial clones when the box is ticked", async () => { + const user = userEvent.setup(); + vi.mocked(api.lanes.cloneFolder).mockResolvedValue(view); + render(); + await user.type(screen.getByTestId("workspace-clone-input"), "https://h/big.git"); + await user.click(screen.getByTestId("workspace-clone-partial")); + await user.click(screen.getByTestId("workspace-clone-run")); + await waitFor(() => + expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, { + progressToken: expect.any(String), + url: "https://h/big.git", + partial: true, + }) + ); + }); + it("tells an SSH link from an HTTPS one", () => { expect(cloneTarget("git@github.com:o/r.git")).toEqual({ kind: "ssh", host: "github.com" }); expect(cloneTarget("ssh://git@h.test:2222/o/r")).toEqual({ kind: "ssh", host: "h.test" }); @@ -92,7 +110,10 @@ describe("CloneSourcesForm", () => { await user.click(screen.getByTestId("workspace-clone-run")); await waitFor(() => expect(api.lanes.cloneFolder).toHaveBeenCalledTimes(1)); - expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, { url: "https://h.test/a.git" }); + expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, { + url: "https://h.test/a.git", + progressToken: expect.any(String), + }); expect(await screen.findByText(/has no SSH key yet/)).toBeInTheDocument(); expect(screen.getByTestId("workspace-clone-input")).toHaveValue("git@github.com:o/r.git"); }); diff --git a/client/src/components/lanes/__tests__/LaneChangesPanel.test.tsx b/client/src/components/lanes/__tests__/LaneChangesPanel.test.tsx index 2ac3f855..eba4beb7 100644 --- a/client/src/components/lanes/__tests__/LaneChangesPanel.test.tsx +++ b/client/src/components/lanes/__tests__/LaneChangesPanel.test.tsx @@ -1062,6 +1062,20 @@ describe("LaneChangesPanel", () => { ); }); + it("does not offer Undo for a top commit its upstream already holds", async () => { + withParent(); + vi.mocked(api.lanes.git).mockResolvedValue( + facts({ hasRemote: true, upstream: "origin/feat/thing", ahead: 0, behind: 0 }) + ); + const user = userEvent.setup(); + render(); + await screen.findByTestId("lane-changes-overview"); + await user.click(screen.getByRole("button", { name: "History" })); + const undo = within(await commitMenu(user)).getByTestId("lane-changes-menu-undo-commit"); + await waitFor(() => expect(undo).toBeDisabled()); + expect(undo).toHaveAttribute("title", expect.stringMatching(/already on the remote/)); + }); + it("cherry-picks only from another branch's history", async () => { withParent(); vi.mocked(api.lanes.git).mockResolvedValue(facts()); diff --git a/client/src/components/lanes/__tests__/WorkspaceHome.test.tsx b/client/src/components/lanes/__tests__/WorkspaceHome.test.tsx index 2656ab61..f442530e 100644 --- a/client/src/components/lanes/__tests__/WorkspaceHome.test.tsx +++ b/client/src/components/lanes/__tests__/WorkspaceHome.test.tsx @@ -245,7 +245,9 @@ describe("WorkspaceHome", () => { // As the Workspace page: `&run=` replaces the home with the session view. function HomeUntilRun() { const [params] = useSearchParams(); - return params.get("run") ? null : undefined} />; + return params.get("run") ? null : ( + undefined} /> + ); } const user = userEvent.setup(); render( diff --git a/client/src/components/onboarding/OnboardingWizard.tsx b/client/src/components/onboarding/OnboardingWizard.tsx index 579136f9..eae74275 100644 --- a/client/src/components/onboarding/OnboardingWizard.tsx +++ b/client/src/components/onboarding/OnboardingWizard.tsx @@ -29,9 +29,9 @@ import { PetSettings } from "../settings/PetSettings"; import { Swatch, THEME_LABELS } from "../settings/AppearanceSettings"; import { markOnboarded, useOnboarding } from "../../lib/userPrefs"; import { - DEFAULT_MODES, readDefaultMode, readDefaultTool, + SAFE_MODES, writeDefaultMode, writeDefaultTool, type DefaultMode, @@ -310,7 +310,7 @@ function AgentStep() { writeDefaultMode(m); setMode(m); }} - options={DEFAULT_MODES.map((m) => ({ value: m, label: t(`agent.modes.${m}`) }))} + options={SAFE_MODES.map((m) => ({ value: m, label: t(`agent.modes.${m}`) }))} />

{t("agent.permissionHint")}

diff --git a/client/src/components/onboarding/__tests__/Onboarding.test.tsx b/client/src/components/onboarding/__tests__/Onboarding.test.tsx index 38a585c4..d8e686d3 100644 --- a/client/src/components/onboarding/__tests__/Onboarding.test.tsx +++ b/client/src/components/onboarding/__tests__/Onboarding.test.tsx @@ -2,7 +2,7 @@ * @file Onboarding.test.tsx * @description The first-run setup (docs/plans/onboarding): the account's * preferences are loaded into localStorage on sign-in and every later change - * is sent back; the starting choices never hold bypass; the wizard shows only + * is sent back; the wizard never offers bypass (a Settings default may hold it); the wizard shows only * for an account that is new to it, writes each pick like Settings does and * ends in the tour; the tour follows the real UI step by step. * @author Nguyễn Ngọc Trí Vĩ @@ -55,7 +55,7 @@ vi.mock("../../settings/PetSettings", () => ({ })); import { __resetUserPrefs, hydrate, useOnboarding } from "../../../lib/userPrefs"; -import { readDefaultMode, writeDefaultMode } from "../../../lib/sessionDefaults"; +import { readDefaultMode, SAFE_MODES, writeDefaultMode } from "../../../lib/sessionDefaults"; import { OnboardingWizard } from "../OnboardingWizard"; import { OnboardingTour, startTour } from "../OnboardingTour"; import { __resetAiTools } from "../../../hooks/useAiTools"; @@ -98,10 +98,12 @@ describe("account preferences", () => { } }); - it("never keeps bypass as the starting permission mode", () => { + it("keeps a bypass default from Settings, but the pet chat's safe read and the wizard never offer it", () => { localStorage.setItem("agenthub.permissionMode", "bypassPermissions"); - expect(readDefaultMode()).toBe("auto"); - writeDefaultMode("bypassPermissions" as never); + expect(readDefaultMode()).toBe("bypassPermissions"); + expect(readDefaultMode({ safe: true })).toBe("auto"); + expect(SAFE_MODES).not.toContain("bypassPermissions"); + writeDefaultMode("nonsense" as never); expect(localStorage.getItem("agenthub.permissionMode")).toBe("bypassPermissions"); writeDefaultMode("plan"); expect(readDefaultMode()).toBe("plan"); diff --git a/client/src/components/run/LaneConsolePane.tsx b/client/src/components/run/LaneConsolePane.tsx index bb1c308c..04f3e536 100644 --- a/client/src/components/run/LaneConsolePane.tsx +++ b/client/src/components/run/LaneConsolePane.tsx @@ -218,6 +218,7 @@ export function LaneConsolePane({ wsBaseUrl={window.location.origin.replace(/^http/, "ws")} autoFocus={autoFocus && !(chatable && chat)} covered={!!(chatable && chat)} + tool={handle.tool} // The pane died (double Ctrl+C out of `claude`, `exit`, a crash) — // the tab bar moves on (another tab, or the home's prompt box) // rather than leaving a dead terminal the user has to click out of. diff --git a/client/src/components/run/PaneChat.tsx b/client/src/components/run/PaneChat.tsx index 8a16bf3f..c6ac4fe8 100644 --- a/client/src/components/run/PaneChat.tsx +++ b/client/src/components/run/PaneChat.tsx @@ -476,7 +476,10 @@ export const PaneChat = memo(function PaneChat({ // Stable, so typing in the prompt box does not re-render the conversation // (ConversationView is memoized). - const openTerminal = useCallback(() => showTerminal(runId, sessionId), [runId, sessionId]); + const openTerminal = useCallback( + () => showTerminal(runId, sessionId, run.tool), + [runId, sessionId, run.tool] + ); const openFile = useCallback( (named: string) => { // A relative path in a reply (`server/lib/x.js`) is relative to this pane's folder. @@ -514,7 +517,7 @@ export const PaneChat = memo(function PaneChat({ const send = async () => { if (!text || uploading) return; - const out = promptForPane(prompt, images); + const out = promptForPane(prompt, images, claude); if (!(await command(out.text, out.files))) return; setFollow((n) => n + 1); setPrompt(""); diff --git a/client/src/components/run/PaneGrid.tsx b/client/src/components/run/PaneGrid.tsx index 6d7f090f..ed743cfa 100644 --- a/client/src/components/run/PaneGrid.tsx +++ b/client/src/components/run/PaneGrid.tsx @@ -244,9 +244,9 @@ const ITEM = * the draft (its files as paths) back into the input box, never sent. */ /** A session's chat switched to its terminal: the unsent prompt goes into * the CLI's input box, without Enter. */ -export function showTerminal(runId: string, sessionId: string) { +export function showTerminal(runId: string, sessionId: string, tool?: string) { const draft = readChatDraft(sessionId); - const { text, files } = promptForPane(draft.prompt, draft.images); + const { text, files } = promptForPane(draft.prompt, draft.images, tool === "claude"); if ((text || files.length) && pasteToRun(runId, text, files)) writeChatDraft(sessionId, "", []); setSessionChat(sessionId, false); } @@ -265,7 +265,7 @@ function ChatToggle({ run }: { run?: RunHandle }) { title={label} aria-label={label} onClick={() => { - if (chat) return showTerminal(run.id, sessionId); + if (chat) return showTerminal(run.id, sessionId, run.tool); const draft = readChatDraft(sessionId); const text = takeRunInput(run.id); if (text) diff --git a/client/src/components/run/RunSetup.tsx b/client/src/components/run/RunSetup.tsx index a4f91b4c..1085a337 100644 --- a/client/src/components/run/RunSetup.tsx +++ b/client/src/components/run/RunSetup.tsx @@ -67,6 +67,7 @@ import { TOKEN_TONE, promptTokens } from "../../lib/promptTokens"; import { ATTACH_MIME, addAttachments, + claudeMention, droppedAttachments, droppedSkill, droppedText, @@ -203,21 +204,29 @@ interface PromptEditorProps { export type PromptImage = { path: string; url?: string; name: string }; /** The prompt as sent: the text, then each file's saved path — which is how - * the CLI is handed an image or a file. */ -export function promptWithImages(text: string, images: PromptImage[]): string { - return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" "); + * the CLI is handed an image or a file; for `claude`, as `@` mentions. */ +export function promptWithImages(text: string, images: PromptImage[], claude = false): string { + return [text.trim(), ...images.map((i) => (claude ? claudeMention(i.path) : i.path))] + .filter(Boolean) + .join(" "); } const IMAGE_PATH = /^[^@].*\.(png|jpe?g|gif|webp)$/i; -/** The prompt as pasted into a live pane: the text with every non-image file - * in it (as `promptWithImages`), and the images' paths apart — each goes in - * a paste of its own, the one shape `claude` turns into `[Image #n]` on every - * platform (a Windows path inside a paste of text stayed a plain path). */ +/** The prompt as pasted into a live pane. `claude`: one paste, every file an + * `@` mention, and a trailing space so a mention at the end does not leave + * the `@` file suggester open to swallow the Enter after it. Any other tool: + * the text with every non-image file in it, and the images' paths apart, + * each in a paste of its own (how `codex` attaches a pasted image path). */ export function promptForPane( text: string, - images: PromptImage[] + images: PromptImage[], + claude = false ): { text: string; files: string[] } { + if (claude) { + const all = promptWithImages(text, images, true); + return { text: images.length ? `${all} ` : all, files: [] }; + } const isImage = (i: PromptImage) => IMAGE_PATH.test(i.path); return { text: promptWithImages(text, images.filter((i) => !isImage(i))), diff --git a/client/src/components/run/TerminalView.tsx b/client/src/components/run/TerminalView.tsx index 378a2e3e..155ceb7d 100644 --- a/client/src/components/run/TerminalView.tsx +++ b/client/src/components/run/TerminalView.tsx @@ -16,8 +16,8 @@ * (`lib/terminalTheme.ts`), recoloured live without re-attaching. * 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, and how `claude` attaches an image or reads - * a file. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with + * 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 * `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 @@ -63,6 +63,7 @@ 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 { attachImeInput } from "../../lib/imeInput"; import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont"; import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize"; @@ -160,6 +161,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. */ + tool?: string; } /** Memoized: the Workspace re-renders its pane tree on every lane_update, and @@ -170,6 +174,7 @@ export const TerminalView = memo(function TerminalView({ onExit, autoFocus = true, covered = false, + tool, }: TerminalViewProps) { const containerRef = useRef(null); // Read through a ref: an inline `onExit` from the parent changes identity on @@ -261,7 +266,8 @@ export const TerminalView = memo(function TerminalView({ 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. - termRef.current?.paste(saved.map((r) => r.path).join(" ") + " "); + const asText = tool === "claude" ? claudeMention : (p: string) => p; + termRef.current?.paste(saved.map((r) => asText(r.path)).join(" ") + " "); termRef.current?.focus(); setDrop(null); } catch { diff --git a/client/src/components/run/__tests__/PaneChat.test.tsx b/client/src/components/run/__tests__/PaneChat.test.tsx index abfada18..63a377c7 100644 --- a/client/src/components/run/__tests__/PaneChat.test.tsx +++ b/client/src/components/run/__tests__/PaneChat.test.tsx @@ -430,7 +430,7 @@ describe("PaneChat — the unsent prompt", () => { expect(screen.getByPlaceholderText("Write a message…")).toHaveValue("half typed"); expect(screen.getByText("a.ts")).toBeInTheDocument(); fireEvent.click(screen.getByTestId("pane-chat-send")); - expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts", []); + expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts ", []); await waitFor(() => expect(localStorage.getItem("agenthub.chat.draft.s1")).toBeNull()); }); @@ -462,7 +462,9 @@ describe("PaneChat — the unsent prompt", () => { fireEvent.click(screen.getByTestId("pane-chat-send")); expect(submitToRun).toHaveBeenCalledWith( "agenthub-lane-1", - 'read @"/home/u/My Docs/spec.md" @/home/u/b.ts', + // A trailing space: an @ mention at the end would leave claude's file + // suggester open, and it takes the Enter. + 'read @"/home/u/My Docs/spec.md" @/home/u/b.ts ', [] ); }); diff --git a/client/src/components/run/__tests__/RunSetup.test.tsx b/client/src/components/run/__tests__/RunSetup.test.tsx index 07cb8dc0..7ac6182d 100644 --- a/client/src/components/run/__tests__/RunSetup.test.tsx +++ b/client/src/components/run/__tests__/RunSetup.test.tsx @@ -606,4 +606,26 @@ describe("promptForPane — images apart, every other file in the text", () => { ], }); }); + + it("hands claude every file as an @ mention in one paste, with a trailing space", () => { + // 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: "/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 ' + + '@/data/drops/0123456789abcdef-log.txt @"/home/u/My Docs/a.PNG" ' + + '@"C:/Users/Jo Do/drops/0123456789abcdef.png" ', + files: [], + }); + expect(promptForPane("just text", [], true)).toEqual({ text: "just text", files: [] }); + expect(promptWithImages("see", [images[1]!], true)).toBe( + "see @/data/drops/0123456789abcdef-log.txt" + ); + }); }); diff --git a/client/src/components/settings/SessionDefaultsSettings.tsx b/client/src/components/settings/SessionDefaultsSettings.tsx new file mode 100644 index 00000000..9c5f372f --- /dev/null +++ b/client/src/components/settings/SessionDefaultsSettings.tsx @@ -0,0 +1,154 @@ +/** + * @file SessionDefaultsSettings — Settings → AI tools → Session defaults: the + * account's own starting choices for a new Claude Code session (permission + * mode, Bypass included) and a new Codex session (sandbox, approval, bypass), + * which the workspace home's prompt box starts from (`lib/sessionDefaults.ts`, + * synced per account). Turning bypass on as a default asks first; a pick in + * the prompt box still changes only that one session. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ShieldAlert } from "lucide-react"; +import type { CodexApproval, CodexSandbox } from "../../lib/api"; +import { useAiTools } from "../../hooks/useAiTools"; +import { confirmDialog } from "../../lib/confirm"; +import { + CODEX_APPROVALS, + CODEX_SANDBOXES, + DEFAULT_MODES, + readCodexDefaults, + readDefaultMode, + writeCodexDefaults, + writeDefaultMode, + type DefaultMode, +} from "../../lib/sessionDefaults"; +import { SettingsRow, SettingsSection, SettingsSegmented, SettingsSwitchRow } from "./SettingsRow"; + +const MODE_KEYS: Record = { + auto: "Auto", + acceptEdits: "AcceptEdits", + default: "Default", + plan: "Plan", + bypassPermissions: "Bypass", +}; +const SANDBOX_KEYS: Record = { + "read-only": "sandboxReadOnly", + "workspace-write": "sandboxWorkspaceWrite", + "danger-full-access": "sandboxFullAccess", +}; +const APPROVAL_KEYS: Record = { + "on-request": "approvalOnRequest", + never: "approvalNever", +}; + +function Danger({ children }: { children: string }) { + return ( +

+ + {children} +

+ ); +} + +export function SessionDefaultsSettings() { + const { t } = useTranslation("settings"); + const aiTools = useAiTools(); + const shown = (id: string) => aiTools.tools.some((x) => x.id === id && x.enabled); + const [mode, setMode] = useState(() => readDefaultMode() as DefaultMode); + const [codex, setCodex] = useState(readCodexDefaults); + + const askBypass = (tool: string) => + confirmDialog({ + title: + tool === "Codex" + ? t("sessionDefaults.codexConfirmTitle") + : t("sessionDefaults.confirmTitle", { tool }), + message: t("sessionDefaults.confirmBody", { tool }), + confirmLabel: t("sessionDefaults.confirmLabel"), + }); + + const pickMode = async (m: DefaultMode) => { + if (m === "bypassPermissions" && !(await askBypass("Claude Code"))) return; + writeDefaultMode(m); + setMode(m); + }; + const patchCodex = (patch: Partial) => { + writeCodexDefaults(patch); + setCodex((c) => ({ ...c, ...patch })); + }; + const pickCodexBypass = async (on: boolean) => { + if (on && !(await askBypass("Codex"))) return; + patchCodex({ bypass: on }); + }; + + if (!shown("claude") && !shown("codex")) return null; + return ( + + {shown("claude") && ( + <> + void pickMode(m)} + options={DEFAULT_MODES.map((m) => ({ + value: m, + label: t(`lanes:home.permission${MODE_KEYS[m]}`), + }))} + /> + } + /> + {mode === "bypassPermissions" && {t("run:hint.permissionWarning")}} + + )} + {shown("codex") && ( + <> + patchCodex({ sandbox })} + options={CODEX_SANDBOXES.map((v) => ({ + value: v, + label: t(`run:fields.${SANDBOX_KEYS[v]}`), + }))} + /> + } + /> + patchCodex({ approval })} + options={CODEX_APPROVALS.map((v) => ({ + value: v, + label: t(`run:fields.${APPROVAL_KEYS[v]}`), + }))} + /> + } + /> + void pickCodexBypass(on)} + /> + {codex.bypass && {t("run:hint.bypassWarning", { tool: "Codex" })}} + + )} + + ); +} diff --git a/client/src/components/settings/__tests__/SessionDefaultsSettings.test.tsx b/client/src/components/settings/__tests__/SessionDefaultsSettings.test.tsx new file mode 100644 index 00000000..f5d90e53 --- /dev/null +++ b/client/src/components/settings/__tests__/SessionDefaultsSettings.test.tsx @@ -0,0 +1,72 @@ +/** + * @file SessionDefaultsSettings.test.tsx + * @description Session defaults: Bypass becomes Claude's default permission + * mode or Codex's default bypass only after the confirm, a cancelled confirm + * keeps the old default, the plain modes save at once, and the pet chat's + * safe read never returns bypass. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SessionDefaultsSettings } from "../SessionDefaultsSettings"; +import { confirmDialog } from "../../../lib/confirm"; +import { readCodexDefaults, readDefaultMode } from "../../../lib/sessionDefaults"; + +const aiTools = vi.hoisted(() => ({ + tools: [ + { id: "claude", label: "Claude Code", installed: true, enabled: true }, + { id: "codex", label: "Codex", installed: true, enabled: true }, + ], +})); +vi.mock("../../../hooks/useAiTools", () => ({ useAiTools: () => aiTools })); +vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn() })); +const confirm = vi.mocked(confirmDialog); + +const radio = (name: RegExp) => screen.getAllByRole("radio", { name })[0]!; + +describe("SessionDefaultsSettings", () => { + beforeEach(() => { + localStorage.clear(); + confirm.mockReset(); + }); + + it("saves a plain mode without asking", () => { + render(); + fireEvent.click(radio(/plan/i)); + expect(confirm).not.toHaveBeenCalled(); + expect(readDefaultMode()).toBe("plan"); + }); + + it("makes Bypass Claude's default only once confirmed", async () => { + confirm.mockResolvedValueOnce(false); + render(); + fireEvent.click(radio(/bypass/i)); + await waitFor(() => expect(confirm).toHaveBeenCalledTimes(1)); + expect(readDefaultMode()).toBe("auto"); + + confirm.mockResolvedValueOnce(true); + fireEvent.click(radio(/bypass/i)); + await waitFor(() => expect(readDefaultMode()).toBe("bypassPermissions")); + expect(readDefaultMode({ safe: true })).toBe("auto"); + }); + + it("turns Codex's default bypass on only once confirmed, off without asking", async () => { + confirm.mockResolvedValueOnce(true); + render(); + const sw = screen.getByRole("switch"); + fireEvent.click(sw); + await waitFor(() => expect(readCodexDefaults().bypass).toBe(true)); + fireEvent.click(screen.getByRole("switch")); + await waitFor(() => expect(readCodexDefaults().bypass).toBe(false)); + expect(confirm).toHaveBeenCalledTimes(1); + }); + + it("shows nothing when neither Claude nor Codex is enabled", () => { + const saved = aiTools.tools; + aiTools.tools = []; + const { container } = render(); + expect(container.firstChild).toBeNull(); + aiTools.tools = saved; + }); +}); diff --git a/client/src/i18n/locales/en/lanes.json b/client/src/i18n/locales/en/lanes.json index 51fea2d3..a5b36457 100644 --- a/client/src/i18n/locales/en/lanes.json +++ b/client/src/i18n/locales/en/lanes.json @@ -220,6 +220,14 @@ "clone.remember": "Remember this login for my account", "clone.sshRefused": "{{host}} refused the SSH login. Add your public key to that host, or make one in Settings → Git.", "clone.sshSettings": "Open Settings → Git", + "clone.partial": "Partial clone (faster for big repos)", + "clone.partialHint": "Downloads every commit but a file's contents only when it is checked out (--filter=blob:none). Checking out an old revision later fetches from the remote.", + "clone.queued": "Waiting in the clone queue — #{{position}} of {{waiting}} ({{running}}/{{limit}} clones running)", + "clone.connecting": "Connecting…", + "clone.phase.remote": "Server preparing", + "clone.phase.receiving": "Downloading", + "clone.phase.resolving": "Unpacking", + "clone.phase.checkout": "Writing files", "clone.sshNoKey": "Not cloned: your account has no SSH key yet. Make one in Settings → Git, add its public key to {{host}}, then retry.", "moreActions": "More actions", "pipelinePicker.label": "Pipeline template", @@ -299,6 +307,7 @@ "changes.undoCommitTitle": "Undo the last commit?", "changes.undoCommitBody": "{{short}} \"{{subject}}\" is removed from the branch. Its changes stay staged, so you can edit them and commit again.", "changes.undoCommitConfirm": "Undo commit", + "changes.undoCommitPushed": "The last commit is already on the remote branch (as of the last fetch); undoing it would rewrite shared history. Revert it instead — or Fetch first if the remote no longer has it.", "changes.revertConfirm": "Add a commit on {{branch}} that undoes {{short}} \"{{subject}}\"? Files in this workspace change now.", "changes.cherryPickConfirm": "Apply {{short}} \"{{subject}}\" from {{from}} onto {{branch}} as a new commit? Files in this workspace change now.", "changes.amendTitle": "Amend the last commit?", diff --git a/client/src/i18n/locales/en/run.json b/client/src/i18n/locales/en/run.json index 24c08ec1..59f44e65 100644 --- a/client/src/i18n/locales/en/run.json +++ b/client/src/i18n/locales/en/run.json @@ -50,6 +50,9 @@ "permissionAuto": "auto (recommended - asks only for risky actions)", "permissionBypass": "bypassPermissions (DANGER - allows everything without asking)", "bypass": "Bypass permissions for this run", + "yolo": "YOLO", + "yoloHint": "YOLO mode for this session: no sandbox, no approvals (--dangerously-bypass-approvals-and-sandbox)", + "bypassShort": "Bypass", "permissionDefault": "default (interactive prompts - sessions may hang headlessly)", "permissionMode": "Permission mode", "permissionPlan": "plan (read-only planning)", diff --git a/client/src/i18n/locales/en/sessions.json b/client/src/i18n/locales/en/sessions.json index d1aa68f7..d42d7027 100644 --- a/client/src/i18n/locales/en/sessions.json +++ b/client/src/i18n/locales/en/sessions.json @@ -210,7 +210,13 @@ "moreSteps_other": "+{{count}} more", "quiet": "no new activity for {{time}}", "askDeclined": "Declined — no answer given", - "turnDone": "Done in {{time}}" + "turnDone": "Done in {{time}}", + "turnReplied": "Replied in {{time}}", + "agentsStill_one": "{{count}} agent still running", + "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" }, "overview": { "agent": "Agent", diff --git a/client/src/i18n/locales/en/settings.json b/client/src/i18n/locales/en/settings.json index 76dbc72a..708e2a30 100644 --- a/client/src/i18n/locales/en/settings.json +++ b/client/src/i18n/locales/en/settings.json @@ -1307,6 +1307,20 @@ "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.", "saveFailed": "Could not save: {{message}}" }, + "sessionDefaults": { + "title": "Session defaults", + "description": "What a new session starts with in a workspace's prompt box. Saved for your account only; a pick in the prompt box changes only that one session.", + "claudeMode": "Claude Code permission mode", + "claudeModeDesc": "Bypass permissions lets every new Claude session run any tool without asking.", + "codexSandbox": "Codex sandbox", + "codexApproval": "Codex approval", + "codexBypass": "Codex: YOLO mode", + "codexBypassDesc": "Every new Codex session starts in YOLO mode — `--dangerously-bypass-approvals-and-sandbox`: no sandbox, no approvals.", + "confirmTitle": "Make bypass the default for {{tool}}?", + "confirmBody": "Every new {{tool}} session you start will run commands and edit files without asking, until you change this back. Use it only with folders and prompts you trust.", + "confirmLabel": "Make it the default", + "codexConfirmTitle": "Make YOLO the default for Codex?" + }, "agentInstructions": { "title": "My agent instructions", "description": "Your own rules in Markdown — coding style, language, workflow. Every new session in a workspace you own starts with them, before that workspace's own instructions. Saved for your account only; sessions already running keep what they started with.", diff --git a/client/src/i18n/locales/vi/lanes.json b/client/src/i18n/locales/vi/lanes.json index 6cccad9b..c3f22283 100644 --- a/client/src/i18n/locales/vi/lanes.json +++ b/client/src/i18n/locales/vi/lanes.json @@ -220,6 +220,14 @@ "clone.remember": "Lưu đăng nhập này cho tài khoản của tôi", "clone.sshRefused": "{{host}} từ chối đăng nhập SSH. Hãy thêm public key của bạn vào host đó, hoặc tạo key trong Cài đặt → Git.", "clone.sshSettings": "Mở Cài đặt → Git", + "clone.partial": "Clone một phần (nhanh hơn với repo lớn)", + "clone.partialHint": "Tải toàn bộ commit nhưng chỉ tải nội dung file khi cần checkout (--filter=blob:none). Checkout một phiên bản cũ sau này sẽ tải thêm từ remote.", + "clone.queued": "Đang chờ trong hàng clone — thứ {{position}}/{{waiting}} ({{running}}/{{limit}} clone đang chạy)", + "clone.connecting": "Đang kết nối…", + "clone.phase.remote": "Máy chủ đang chuẩn bị", + "clone.phase.receiving": "Đang tải về", + "clone.phase.resolving": "Đang giải nén", + "clone.phase.checkout": "Đang ghi file", "clone.sshNoKey": "Chưa clone: tài khoản của bạn chưa có SSH key. Hãy tạo key trong Cài đặt → Git, thêm public key vào {{host}}, rồi thử lại.", "moreActions": "Thêm hành động", "pipelinePicker.label": "Mẫu pipeline", @@ -299,6 +307,7 @@ "changes.undoCommitTitle": "Hoàn tác commit cuối?", "changes.undoCommitBody": "{{short}} \"{{subject}}\" sẽ bị gỡ khỏi nhánh. Thay đổi của nó vẫn được giữ ở trạng thái staged để sửa và commit lại.", "changes.undoCommitConfirm": "Hoàn tác commit", + "changes.undoCommitPushed": "Commit cuối đã có trên nhánh remote (theo lần fetch gần nhất); hoàn tác sẽ viết lại lịch sử đã chia sẻ. Hãy Revert — hoặc Fetch trước nếu remote không còn commit này.", "changes.revertConfirm": "Thêm một commit trên {{branch}} để đảo ngược {{short}} \"{{subject}}\"? File trong workspace sẽ thay đổi ngay.", "changes.cherryPickConfirm": "Áp {{short}} \"{{subject}}\" từ {{from}} vào {{branch}} thành một commit mới? File trong workspace sẽ thay đổi ngay.", "changes.amendTitle": "Amend commit cuối?", diff --git a/client/src/i18n/locales/vi/run.json b/client/src/i18n/locales/vi/run.json index 46ae077e..455cdff2 100644 --- a/client/src/i18n/locales/vi/run.json +++ b/client/src/i18n/locales/vi/run.json @@ -50,6 +50,9 @@ "permissionAuto": "auto (khuyên dùng - chỉ hỏi khi hành động rủi ro)", "permissionBypass": "bypassPermissions (NGUY HIỂM - cho phép tất cả mà không hỏi)", "bypass": "Bỏ qua xin quyền cho lần chạy này", + "yolo": "YOLO", + "yoloHint": "Chế độ YOLO cho phiên này: không sandbox, không hỏi phê duyệt (--dangerously-bypass-approvals-and-sandbox)", + "bypassShort": "Bỏ qua quyền", "permissionDefault": "default (prompt tương tác - headless sẽ treo)", "permissionMode": "Chế độ quyền", "permissionPlan": "plan (chỉ đọc, lập kế hoạch)", diff --git a/client/src/i18n/locales/vi/sessions.json b/client/src/i18n/locales/vi/sessions.json index 62e1078c..945696e5 100644 --- a/client/src/i18n/locales/vi/sessions.json +++ b/client/src/i18n/locales/vi/sessions.json @@ -210,7 +210,13 @@ "moreSteps_other": "+{{count}} việc nữa", "quiet": "không có hoạt động mới trong {{time}}", "askDeclined": "Đã từ chối — không trả lời", - "turnDone": "Xong trong {{time}}" + "turnDone": "Xong trong {{time}}", + "turnReplied": "Đã trả lời trong {{time}}", + "agentsStill_one": "còn {{count}} agent đang chạy", + "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" }, "overview": { "agent": "Agent", diff --git a/client/src/i18n/locales/vi/settings.json b/client/src/i18n/locales/vi/settings.json index 16c0b2f2..d26b490d 100644 --- a/client/src/i18n/locales/vi/settings.json +++ b/client/src/i18n/locales/vi/settings.json @@ -1307,6 +1307,20 @@ "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.", "saveFailed": "Không lưu được: {{message}}" }, + "sessionDefaults": { + "title": "Mặc định cho phiên mới", + "description": "Phiên mới bắt đầu với các lựa chọn này trong ô prompt của workspace. Chỉ lưu cho tài khoản của bạn; chọn khác trong ô prompt chỉ đổi cho phiên đó.", + "claudeMode": "Chế độ quyền của Claude Code", + "claudeModeDesc": "Bỏ qua quyền cho mọi phiên Claude mới chạy mọi tool mà không hỏi.", + "codexSandbox": "Sandbox của Codex", + "codexApproval": "Phê duyệt của Codex", + "codexBypass": "Codex: chế độ YOLO", + "codexBypassDesc": "Mọi phiên Codex mới chạy ở chế độ YOLO — `--dangerously-bypass-approvals-and-sandbox`: không sandbox, không hỏi phê duyệt.", + "confirmTitle": "Đặt bỏ qua quyền làm mặc định cho {{tool}}?", + "confirmBody": "Mọi phiên {{tool}} mới bạn mở sẽ chạy lệnh và sửa file mà không hỏi, cho tới khi bạn đổi lại. Chỉ dùng với thư mục và prompt bạn tin tưởng.", + "confirmLabel": "Đặt làm mặc định", + "codexConfirmTitle": "Đặt YOLO làm mặc định cho Codex?" + }, "agentInstructions": { "title": "Hướng dẫn agent của tôi", "description": "Quy tắc riêng của bạn bằng Markdown — style code, ngôn ngữ, quy trình. Mọi session mới trong workspace của bạn đều nhận chúng, trước hướng dẫn riêng của workspace. Chỉ lưu cho tài khoản của bạn; session đang chạy giữ nội dung lúc khởi động.", diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index dc1730dc..5ccb78c6 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -3576,11 +3576,22 @@ export const api = { title?: string; tool?: LaneTool; credentials?: { username: string; password: string; remember?: boolean }; + /** Blobless clone (`--filter=blob:none`): file contents fetched on checkout. */ + partial?: boolean; + /** This clone's name for {@link lanes.cloneProgress}. */ + progressToken?: string; }) => request<{ lane: Lane; created: boolean; path: string }>("/lanes/clone", { method: "POST", body: JSON.stringify(body), }), + /** + * GET /api/lanes/clone/progress/:token — a clone's place in the + * machine-wide queue, then git's own progress; 404 until the POST + * carrying `progressToken` arrives, and a minute after it ends. + */ + cloneProgress: (token: string) => + request(`/lanes/clone/progress/${encodeURIComponent(token)}`), /** * GET /api/lanes/pipelines — every pipeline template the server can render * a lane against, built-in plus any `DASHBOARD_PIPELINES_DIR` override. @@ -3977,6 +3988,9 @@ export const api = { url: string; name?: string; credentials?: { username: string; password: string; remember?: boolean }; + /** Blobless clone, like {@link lanes.clone}'s `partial`. */ + partial?: boolean; + progressToken?: string; } ) => request(`/lanes/${id}/sources/clone`, { @@ -5290,6 +5304,23 @@ export interface LaneSource { } /** Response shape of GET /api/lanes/:id/sources (and of every sources write). */ +/** `GET /api/lanes/clone/progress/:token` — see server/lib/clone-queue.js. */ +export interface CloneProgressView { + state: "queued" | "running" | "done" | "failed"; + /** 1-based place among the waiting clones while `queued`. */ + position: number | null; + /** Clones running / waiting machine-wide, and how many may run at once. */ + running: number; + waiting: number; + limit: number; + phase: "remote" | "receiving" | "resolving" | "checkout" | "other" | null; + /** git's own words for the phase (shown for `other`). */ + label: string | null; + percent: number | null; + /** e.g. "450/1000, 120.50 MiB | 5.20 MiB/s". */ + detail: string | null; +} + export interface LaneSourcesView { instructions: string; /** The workspace's tool can take folders and instructions (claude, codex). */ diff --git a/client/src/lib/panePaste.ts b/client/src/lib/panePaste.ts index 3148520a..bc3b536d 100644 --- a/client/src/lib/panePaste.ts +++ b/client/src/lib/panePaste.ts @@ -14,9 +14,10 @@ * terminal moves the unsent prompt across (`takeRunInput` reads the TUI's * input box off the screen and empties it; the chat's draft is pasted back * without Enter), so the half-typed text is never left behind on the other side. - * Attached images' paths go after the text, each in a paste of its own: `claude` - * turns a paste that is only an image's path into `[Image #n]`, but left a - * Windows path (`C:\…\drops\…png`) inside a paste of text as a plain path. + * Attached images' paths go after the text, each in a paste of its own — how + * `codex` attaches one; a `claude` pane gets every file as an `@` mention in + * the text instead (`promptForPane`), since a pasted path stayed a plain + * `C:\…\drops\…png` there on Windows. * The Chat view also answers a menu the CLI asks with (`parsePaneMenu` reads * it off the screen; `chooseRunOption`/`answerRunMenu` press its digit, the * arrows and Enter, or type into its "Type something" row). diff --git a/client/src/lib/promptAttach.ts b/client/src/lib/promptAttach.ts index 511fae60..cdd8aa85 100644 --- a/client/src/lib/promptAttach.ts +++ b/client/src/lib/promptAttach.ts @@ -4,8 +4,9 @@ * file or folder dragged from the Files panel (`ATTACH_MIME`, read back by * `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). An attachment is a path - * sent with the prompt, so nothing here touches the server. + * 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. * @author Nguyễn Ngọc Trí Vĩ */ @@ -101,6 +102,19 @@ export function droppedText( } } +/** An attached file as Claude Code's `@` mention, which `claude` reads at + * submit — an image as an image — whether it came on the command line or in + * a paste. A bare pasted PATH becomes `[Image #n]` only when the TUI catches + * the paste on its own and finishes before Enter: on Windows (psmux) it + * stayed a plain `C:\…\drops\…png` in the prompt, and on the command line + * a path is never an image at all. A Windows path's backslashes become `/` + * (Windows takes either); a path with a space is quoted. */ +export function claudeMention(path: string): string { + if (path.startsWith("@")) return path; + const p = /^[A-Za-z]:\\/.test(path) ? path.replace(/\\/g, "/") : path; + return /\s/.test(p) ? `@"${p}"` : `@${p}`; +} + 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.-]+)?$/; diff --git a/client/src/lib/sessionDefaults.ts b/client/src/lib/sessionDefaults.ts index 31f2b117..ad08e8cc 100644 --- a/client/src/lib/sessionDefaults.ts +++ b/client/src/lib/sessionDefaults.ts @@ -1,22 +1,37 @@ /** * @file sessionDefaults.ts * @description The account's own starting choices for new work, picked in the - * first-run setup (docs/plans/onboarding) and synced per account like every - * other preference (`lib/userPrefs.ts`): the AI a new workspace starts on, and - * the permission mode a new Claude session starts in. Bypass is NEVER one of - * them — it stays the per-session switch (CLAUDE.md), so `bypassPermissions` - * is refused on read and on write. + * first-run setup (docs/plans/onboarding) or Settings → AI tools → Session + * defaults, and synced per account like every other preference + * (`lib/userPrefs.ts`): the AI a new workspace starts on, the permission mode + * a new Claude session starts in, and a new Codex session's sandbox, approval + * and bypass. Bypass (Claude's `bypassPermissions`, Codex's bypass switch) may + * be a default only because the user turned it on in Settings behind a + * confirm — the first-run setup never offers it, and the pet chat never + * starts in it (`readDefaultMode({ safe: true })`). * @author Nguyễn Ngọc Trí Vĩ */ -import type { PermissionMode } from "./api"; +import type { CodexApproval, CodexSandbox, PermissionMode } from "./api"; const TOOL_KEY = "agenthub.defaultTool"; const MODE_KEY = "agenthub.permissionMode"; +const CODEX_SANDBOX_KEY = "agenthub.codex.sandbox"; +const CODEX_APPROVAL_KEY = "agenthub.codex.approval"; +const CODEX_BYPASS_KEY = "agenthub.codex.bypass"; /** The permission modes a default may be. */ -export const DEFAULT_MODES = ["auto", "acceptEdits", "default", "plan"] as const; +export const DEFAULT_MODES = ["auto", "acceptEdits", "default", "plan", "bypassPermissions"] as const; export type DefaultMode = (typeof DEFAULT_MODES)[number]; +/** The ones the first-run setup offers: never bypass. */ +export const SAFE_MODES = DEFAULT_MODES.filter((m) => m !== "bypassPermissions"); + +export const CODEX_SANDBOXES: readonly CodexSandbox[] = [ + "read-only", + "workspace-write", + "danger-full-access", +]; +export const CODEX_APPROVALS: readonly CodexApproval[] = ["on-request", "never"]; function read(key: string): string | null { try { @@ -37,11 +52,36 @@ function write(key: string, value: string): void { export const readDefaultTool = (): string | null => read(TOOL_KEY); export const writeDefaultTool = (id: string): void => write(TOOL_KEY, id); -/** The permission mode a new Claude session starts in ("auto" when none was picked). */ -export function readDefaultMode(): PermissionMode { +/** The permission mode a new Claude session starts in ("auto" when none was + * picked); `safe` turns a bypass default into "auto". */ +export function readDefaultMode({ safe = false }: { safe?: boolean } = {}): PermissionMode { const v = read(MODE_KEY); + if (safe && v === "bypassPermissions") return "auto"; return (DEFAULT_MODES as readonly string[]).includes(v ?? "") ? (v as DefaultMode) : "auto"; } export function writeDefaultMode(mode: DefaultMode): void { if ((DEFAULT_MODES as readonly string[]).includes(mode)) write(MODE_KEY, mode); } + +export interface CodexDefaults { + sandbox: CodexSandbox; + approval: CodexApproval; + bypass: boolean; +} + +/** A new Codex session's sandbox, approval and bypass (Codex's own defaults when none was picked). */ +export function readCodexDefaults(): CodexDefaults { + const sandbox = read(CODEX_SANDBOX_KEY) as CodexSandbox | null; + const approval = read(CODEX_APPROVAL_KEY) as CodexApproval | null; + return { + sandbox: sandbox && CODEX_SANDBOXES.includes(sandbox) ? sandbox : "workspace-write", + approval: approval && CODEX_APPROVALS.includes(approval) ? approval : "on-request", + bypass: read(CODEX_BYPASS_KEY) === "true", + }; +} +export function writeCodexDefaults(patch: Partial): void { + if (patch.sandbox && CODEX_SANDBOXES.includes(patch.sandbox)) write(CODEX_SANDBOX_KEY, patch.sandbox); + if (patch.approval && CODEX_APPROVALS.includes(patch.approval)) + write(CODEX_APPROVAL_KEY, patch.approval); + if (patch.bypass !== undefined) write(CODEX_BYPASS_KEY, String(patch.bypass === true)); +} diff --git a/client/src/lib/userPrefs.ts b/client/src/lib/userPrefs.ts index c4b12ef5..f9545b41 100644 --- a/client/src/lib/userPrefs.ts +++ b/client/src/lib/userPrefs.ts @@ -38,6 +38,9 @@ export const PREF_KEYS = [ "agent-monitor-notifications", "agenthub.defaultTool", "agenthub.permissionMode", + "agenthub.codex.sandbox", + "agenthub.codex.approval", + "agenthub.codex.bypass", "agenthub.tour", "agenthub.workspace.allTabs", ] as const; diff --git a/client/src/pages/SettingsHub.tsx b/client/src/pages/SettingsHub.tsx index 9e3d59e3..5056938d 100644 --- a/client/src/pages/SettingsHub.tsx +++ b/client/src/pages/SettingsHub.tsx @@ -67,6 +67,7 @@ import { SnapshotSettings } from "../components/settings/SnapshotSettings"; import { ReportSettings } from "../components/settings/ReportSettings"; import { AiToolsSettings } from "../components/settings/AiToolsSettings"; import { AgentInstructionsSettings } from "../components/settings/AgentInstructionsSettings"; +import { SessionDefaultsSettings } from "../components/settings/SessionDefaultsSettings"; import { ArtifactsHubSettings } from "../components/settings/ArtifactsHubSettings"; import { PricingSettings } from "../components/settings/PricingSettings"; import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings"; @@ -292,6 +293,7 @@ export function SettingsHub({ section }: { section?: string } = {}) { render: () => (
+ {/* The Hub's install switch: server mode, an admin's (it hides itself). */} diff --git a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap index c06513e1..9f26ebe5 100644 --- a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap +++ b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap @@ -7239,7 +7239,7 @@ exports[`screen snapshots > Settings — about 1`] = ` - UI build v2.8.11 (rebuild client to match) + UI build v2.8.12 (rebuild client to match)

diff --git a/docs/API.md b/docs/API.md index 6b4f021b..204fa6b5 100644 --- a/docs/API.md +++ b/docs/API.md @@ -110,7 +110,7 @@ An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude | `PUT /api/me/avatar` (raw image bytes) | the account's picture (PNG, JPEG, GIF or WebP by magic bytes — never SVG), stored as `/avatars/u.`; the `/api/me/account` shape back with `user.avatarAt` set. `400 EBADIMAGE`, `413 ETOOBIG` over 1 MB, `403 ELOCALMODE`. Login cookie only, audited | | `DELETE /api/me/avatar` | removes it (`avatarAt` back to `null`); deleting the user removes it too | -The keys are the browser's own localStorage names: `theme`, `agenthub.theme.dark`, `agenthub.theme.light`, `i18nextLng`, `agenthub.terminal.font`, `agenthub.terminal.fontSize`, `agenthub.ui.fontSize`, `agenthub.pet`, `agenthub.pet.size`, `agenthub.pet.names`, `agent-dashboard-sagi-enabled`, `agent-dashboard-sagi-muted`, `agent-monitor-notifications`, `agenthub.defaultTool`, `agenthub.permissionMode` (never `bypassPermissions` — bypass stays the per-session switch), `agenthub.tour`, and `agenthub.chatUi` (`"1"` = Claude/Codex sessions open as a chat; read and written on the server only, by `client/src/lib/chatUi.ts`). The request never names an account, and writes are same-origin. An account's save writes only their own row, and under S3 a `theme` of `dark` or `light` also becomes the `theme` in their own `~/.claude/settings.json`, written as them. +The keys are the browser's own localStorage names: `theme`, `agenthub.theme.dark`, `agenthub.theme.light`, `i18nextLng`, `agenthub.terminal.font`, `agenthub.terminal.fontSize`, `agenthub.ui.fontSize`, `agenthub.pet`, `agenthub.pet.size`, `agenthub.pet.names`, `agent-dashboard-sagi-enabled`, `agent-dashboard-sagi-muted`, `agent-monitor-notifications`, `agenthub.defaultTool`, `agenthub.permissionMode` (`bypassPermissions` allowed — set only from Settings → AI tools → Session defaults, behind a confirm), `agenthub.codex.sandbox`, `agenthub.codex.approval`, `agenthub.codex.bypass` (`"true"`/`"false"`: a new Codex session's defaults), `agenthub.tour`, and `agenthub.chatUi` (`"1"` = Claude/Codex sessions open as a chat; read and written on the server only, by `client/src/lib/chatUi.ts`). The request never names an account, and writes are same-origin. An account's save writes only their own row, and under S3 a `theme` of `dark` or `light` also becomes the `theme` in their own `~/.claude/settings.json`, written as them. `GET /api/settings/info` includes `server.version` (the running dashboard release). Pair with `agenthub version` or Settings → System to confirm client and server builds match after deploy. @@ -351,11 +351,31 @@ check of that route applies, and opens a workspace on the clone. #### Clone a repository into a new workspace ``` -POST /api/lanes/clone Body: { url, parent, name?, title?, tool?, credentials?: { username, password, remember? } } +POST /api/lanes/clone Body: { url, parent, name?, title?, tool?, credentials?: { username, password, remember? }, partial?, progressToken? } → 201 { lane, created: true, path } +GET /api/lanes/clone/progress/:token + → 200 { state: queued|running|done|failed, position, running, waiting, limit, + phase: remote|receiving|resolving|checkout|other|null, label, percent, detail } ``` -Add workspace → **Clone**. `url` passes the same allowlist as the git bar's +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 +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`): +every commit and tree, but a file's contents only when a checkout needs them — +far less to download and unpack for a big repo; checking out an old revision +later fetches from the remote. `progressToken` (16–64 of `[A-Za-z0-9_-]`, unused; +else `400 EBADFIELD`) is the browser's own name for the clone: while the POST is +held open, `GET /api/lanes/clone/progress/:token` answers its 1-based +`position` among the waiting clones, then git's `--progress` line parsed into +`phase`, `percent` and `detail` (`"450/1000, 120.50 MiB | 5.20 MiB/s"`) — the +form polls it every second (`CloneProgress.tsx`). Only the user who started +the clone sees it (404 otherwise), and it is dropped a minute after the clone +ends. `url` passes the same allowlist as the git bar's `publish` (`https://` with no userinfo, `ssh://`, scp-like `user@host:path` — else `400 EBADURL`); `name` (default: the URL's last segment without `.git`) is one plain folder name that must not exist (`400 EBADNAME`, `409 EEXISTS`); @@ -1600,7 +1620,7 @@ What a workspace's home page edits (`server/lib/lane-sources.js`, `server/routes - **Instructions** — the `instructions` key is required (`400 EBADFIELD` without it), a string or `null`; blank clears; over 16 000 characters is `413 ETOOBIG`. The lane is re-broadcast (`lane_update`). The text is stored in `lanes.instructions` but is NOT part of the lane payload (`GET /api/lanes`, `lane_update`) — read it here. - **Folder** — must be absolute, exist and be a directory (`400 EBADPATH`), must not be or contain the OS home (`400 EBADHOME`), and must not be the workspace's own folder or one it already has (`400 EDUP`). Stored by real path. Removing it only drops the row. -- **Clone** — clones a related repo BESIDE the workspace folder (into its parent; no path is taken from the request) and adds it as a Folder. The clone is `POST /api/lanes/clone`'s own: the same URL allowlist (`400 EBADURL`), one plain `name` (default: the repo's), `409 EEXISTS` for a taken name, run as the folder's account with that account's git identity, `409 ECREDENTIALS` (with `auth: {protocol, host}`) / `ESSHAUTH` / `ENOREPO`; a tool that takes no folders is `400 EUNSUPPORTED`. Answers the view plus `path`. Removing the folder later never deletes the clone. The workspace home's **Add folder** form takes several URLs, one per line, and clones them in turn. Before cloning it checks each URL against `GET /api/me/git`: an SSH link (`ssh://…`, `user@host:path`) while the account has no SSH key is not sent at all and points to Settings → Git (make a key, add its public key to the host), and an HTTPS host with no saved login is flagged with an optional login row — a public repo needs none. The server's own `ECREDENTIALS`/`ESSHAUTH` answers still decide. +- **Clone** — clones a related repo BESIDE the workspace folder (into its parent; no path is taken from the request) and adds it as a Folder. The clone is `POST /api/lanes/clone`'s own: the same URL allowlist (`400 EBADURL`), one plain `name` (default: the repo's), the same `partial` and `progressToken`, `409 EEXISTS` for a taken name, run as the folder's account with that account's git identity, `409 ECREDENTIALS` (with `auth: {protocol, host}`) / `ESSHAUTH` / `ENOREPO`; a tool that takes no folders is `400 EUNSUPPORTED`. Answers the view plus `path`. Removing the folder later never deletes the clone. The workspace home's **Add folder** form takes several URLs, one per line, and clones them in turn. Before cloning it checks each URL against `GET /api/me/git`: an SSH link (`ssh://…`, `user@host:path`) while the account has no SSH key is not sent at all and points to Settings → Git (make a key, add its public key to the host), and an HTTPS host with no saved login is flagged with an optional login row — a public repo needs none. The server's own `ECREDENTIALS`/`ESSHAUTH` answers still decide. - **File** — up to 10 MB (`413 ETOOBIG`), not empty (`400 EEMPTY`). AgentHub stores it under `/workspace-context//` with a name it derives from `X-File-Name` (directory parts and path characters dropped, `name (2).ext` on a clash), so a request never names a path. It is a COPY taken at upload: later edits to the original are not seen — remove and upload it again, or add its directory as a Folder (read live from disk). Removing it deletes that copy. At most 50 sources per workspace (`400 ETOOMANY`). - **Memory** — read-only: the folder's `CLAUDE.md`, `.claude/CLAUDE.md`, `CLAUDE.local.md`, `AGENTS.md`, and for a Claude workspace `/projects//memory/*.md` — `{files: [{label, path, size, mtime, truncated, content}]}`, each at most 256 KB of content. - Every write is same-origin only (`403 EBADORIGIN`); an unknown workspace is `404 ENOLANE`, an unknown source `404 ENOSOURCE`. diff --git a/docs/LANES.md b/docs/LANES.md index 5e2eb3e5..f784b128 100644 --- a/docs/LANES.md +++ b/docs/LANES.md @@ -396,7 +396,7 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page - **Row menu** — right-click a row, or its `⋯` button: Rename, the Model (AI Proxy) select, New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace"). - **Stop archives, Sleep keeps** — every UI path that ends a live pane goes through `endRuns` (`client/src/lib/stopSession.ts`). **Stop** — a tab's **×**, a pane header's **×**, **Close tab** in a right-click menu — kills the pane AND archives the session it held (`DELETE /api/run/:id?archive=1`, which sets `sessions.archived_at` server-side and broadcasts `session_updated`), so a session the user closed leaves the workspace's list (the home's archived fold brings it back with **Unarchive**). It only hides: a session worktree is NOT removed here, unlike the row's own Archive button. **Sleep** — the moon left of each × (AI panes only), and **Sleep** in the tab, pane and session right-click menus — kills the pane but keeps the session listed, so it can be opened and resumed later by sending it a message. Both ask first for an AI pane (it may be mid-turn); a shell closes at once. A pane that dies on its own (exit, crash, reboot) is not archived, and the CLI's `DELETE` without `archive` keeps its old behaviour. - **Right-click menus** — every item that has actions opens them on a right-click too, in one shell (`client/src/components/ContextMenu.tsx`: placed at the pointer, kept inside the viewport, closed by Escape, a click outside or an item). The buttons stay; the menu repeats what they do and nothing more. A session row — in the sidebar, the home's Recents and the archived fold (`lanes/SessionMenu.tsx`) — offers Open, Open in split (live), Rename and Archive (sidebar), Unarchive (archived), Session details, Copy session ID, Sleep and Close tab (live); the sidebar's **New session** row Open, Open in split, Sleep and Close tab. A console tab offers Open, Open in split, Session details, Copy session ID, Sleep and Close tab; a pane header (split view) Show here, Split right/down with a new terminal, Close pane, Sleep and Stop session. Sleep is offered only for an AI pane (a shell holds no session). In the Changes panel a changed file (overview row or review header) offers Open, Stage/Unstage, Discard — two clicks, as on its trash button — or the conflict's Keep mine / Take theirs / Mark resolved, and Copy relative path; a Files tree entry Open and Copy relative path; a History commit View commit, Copy commit SHA and Copy commit message. The home's title opens the row menu, and a Context file or extra folder offers Copy path and Remove. Outside the workspace the same shell serves the Sessions table, the Dashboard's agent cards and recent events, the Activity feed and Workflow runs (`components/SessionRowMenu.tsx`: Open session details, Open in its workspace when it has one, Copy session ID, Copy folder path), an attached prompt image (View / Remove), the managed workspace card (Reset), every Settings list (Git credentials, AI Proxy, Pricing, alert rules, webhooks, remote sources, AI tools) and every Claude Code / Codex config list (skills, agents, commands, output styles and memory files via `FileItemMenu` in `components/config/MdItemList.tsx`; plugins, MCP servers, marketplaces, auth profiles), an Artifacts card (its buttons' actions, plus Copy link for a page) and a Users row (Edit and Open home in Files for an admin, Copy username / email). In the File Explorer (`FileExplorer.tsx`), besides an item and the empty area: a sidebar workspace (Open, Copy path) or home (Open, Open Trash), a path-bar crumb (Open, Download as zip, Paste into this folder, Copy path), a Trash row (Restore, Copy path, Delete forever), the Trash's empty area (Refresh, Empty Trash) and an admin's per-user Trash folder (Open, Empty Trash — asked first, off for an empty one). A delete or remove there takes the button's own confirm — a modal, or two clicks. Text areas (the terminal, a conversation, a diff) keep the browser's own menu. The shell stops the menu's clicks from reaching the card it was opened from. -- **Chat view of a live pane** — each Claude or Codex SESSION shows either a chat or its terminal, its own choice: the chat/terminal button left of the pane's split buttons (`ChatToggle` in `client/src/components/run/PaneGrid.tsx`) switches it, and the choice is kept in this browser's localStorage by session id (`agenthub.chatUi.sessions`, the newest 200; `useSessionChat` in `client/src/lib/chatUi.ts`). **Settings → Appearance → Chat UI** (`ChatUiSettings.tsx`, saved per ACCOUNT on the server: `GET/PUT /api/me/prefs`, key `agenthub.chatUi`) is only the default — written onto a session the first time it is shown, so changing it later leaves every session already seen as it is. The chat needs the session id, so a pane shows it only once its session is known (`client/src/components/run/LaneConsolePane.tsx`). Chat (`client/src/components/run/PaneChat.tsx`) shows the session's conversation the way Claude Desktop does — the shared `ConversationView` in its `fill` layout: one centred column, your messages as bubbles, the reply as plain prose, every run of tool calls folded into one line named by what it did, in Claude Desktop's fixed order ("Ran 3 commands, edited a file, used a tool ›", `workLabel`) — a thinking block that carries text is the model's narration (Claude Code ≥ 2.1.280 stores real thinking empty) and reads as prose between those lines, as Claude Desktop shows it — that opens onto a framed list of its steps — each titled by its own description, the call in a capped scroll box, its output one click further — and a round ↓ back to the end — above a prompt box that grows with its text up to 40% of the screen, then scrolls. The conversation stays at its end while you are there: a new line, the running step, an image loading or the pane shown again after being hidden keeps the newest in sight (a `ResizeObserver` in `ConversationView`), until you scroll up; sending a message takes it back to the end. It lies OVER the terminal, which stays mounted and attached (the console never unmounts a terminal); a message is sent through that terminal as a bracketed paste and Enter (`submitToRun` in `client/src/lib/panePaste.ts`), exactly as if typed, so no new route or server write exists for it. On a Windows dashboard (psmux) the attach (`server/lib/pty-attach.js`) holds any input that follows a paste until 700 ms after it (`PASTE_HOLD_MS`): psmux delays a paste ~350 ms and wrote an Enter sent meanwhile INSIDE it (psmux#766), so the prompt sat in Claude's composer as `[Pasted text #1]` instead of being sent. While the session's main agent is `working` (its `UserPromptSubmit` hook seen, no `Stop` yet — `GET /api/agents?session_id=&status=working`, re-read on `agent_updated`), an empty box shows **Stop** in Send's place, like Claude Desktop: it presses Esc in the pane (`interruptRun` in the same file), the key `claude` and `codex` stop a turn with; typing a message brings Send back (Claude Code queues it). Stop also ends the conversation's live line (`stopped` on `ConversationView`/`MessageList`) until the transcript gains a newer line: an Esc before the model wrote anything puts the prompt back in Claude Code's input box and writes no `[Request interrupted by user]` line, so the transcript still ends on the prompt and would read as **Thinking…** for 15 minutes (issue #74). An Esc pressed in the terminal itself is not seen by the chat. A Codex pane without AgentHub's hooks never shows it. An image attached in the box reaches the transcript as its saved path; the user's turn shows the image itself (`GET /api/run/drop/:name`) — any other attached file as a chip (name · EXT) that opens its preview like an output — and opens it full size over the whole page (`ImageLightbox`, rendered into `document.body`, so nothing stacked over a pane covers it). The box carries Claude Desktop's controls: a `+` menu (`ChatAddMenu.tsx`, also in the workspace home's prompt box) — Upload or add files; what already exists, attached like an upload (a chip, its path sent with the prompt): **Project files** (a search of the box's folder, folders too — `GET /api/run/files?dirs=1` — sent as `@path`), **Context files** (the workspace's own, `GET /api/lanes/:id/sources`, sent as `@`) and **Recent uploads** (files uploaded through a prompt box in the last day, kept in this browser's localStorage `agenthub.prompt.recentUploads` — the server prunes an upload after a day); a file or folder dragged onto anywhere in a prompt box (not only its text area) attaches the same way — from the Changes panel's Files tree, its search, a Changes-list row, or a workspace's Context files and Folder list; a row from an extra folder's section (`data-drag-root`) and a context file or folder carry their absolute path, since they lie outside the pane's folder (`client/src/lib/promptAttach.ts`); Skills (yours and the project's), Plugins → a plugin → the skills and commands it ships, each put at the front of the message as `/` / `/:`, and Manage links to the Claude config page — and, for a Claude pane, the Model/Effort chip, a change typed into the pane as `/model ` / `/effort `, and the permission mode beside `+` in the box. A menu opened in a split pane (a `Select`, the Model chip) flips up or down by the room inside ITS pane (`data-menu-bounds` on the pane, `menuRoom` in `client/src/components/Select.tsx`), never the window's — a menu running past the pane's edge is painted over by the pane below. A running session changes mode only by Shift+Tab, so the server steps it and reads each step back off the pane (`POST /api/run/:id/permission-mode`, `server/lib/pane-mode.js`): it lands on exactly the mode picked, presses nothing while a menu hides the mode line, and returns to where it began when the mode is not in that session's cycle (Bypass only when the session was started with it). The mode belongs to that session only. A menu the CLI asks with — AskUserQuestion (one question or several, multiSelect too), a permission or trust prompt, Codex's approval, a first-start login menu — is answered from the chat: it is read off the terminal underneath every 700 ms while the pane is on screen (`parsePaneMenu`/`readRunMenu` in `client/src/lib/panePaste.ts`: two numbered rows or more from 1, the `❯`/`›` cursor on one of them, no `─` rule right above the first — a reply's numbered list has no cursor, a list typed into Claude's input box sits under its rule) and shown in the prompt box's place as a card of buttons (`PaneMenuCard` in `PaneChat.tsx`, with each option's description and a multiSelect box's state; the box is hidden with CSS, never unmounted, so its draft survives the question); a pick presses its digit in the pane (which picks it, or ticks its box), Claude's unnumbered **Submit** row is reached with the arrows and Enter, **Cancel (Esc)** presses Esc, and the **Type something** row opens a field inside the card whose text, on Enter, is typed there as the answer (`answerRunMenu`: the arrows to that row, Ctrl+U over any text already in it, the text as a bracketed paste, Enter — none in a multiSelect, where Submit is still to pick). That row presses no key: a second press of its digit would type the digit into the CLI's own field. Once text is typed in that row its label is the text, so it is found as the last numbered row above **Chat about this**. Like Send, it only presses keys in the pane, so no new route exists for it. A menu with no numbers (the first-start theme picker) still needs the terminal. A live pane's chat ends, until its turn is over, with what the model is doing now and — like the terminal's status line — how long the turn has run since your prompt and the output tokens it has made so far, e.g. `Thinking… (2m 8s · ↑ 3.5K tokens)` (`workingOn` and `turnStats` in `MessageList.tsx`; Claude Code repeats a call's usage on each of its lines, so consecutive lines with the same prompt size count once), the words lit by a shimmer that sweeps a few times and rests (`.text-shimmer`, stopped under reduced motion) and the token count rolling up rather than jumping. It names every call of the turn still waiting for its result — the oldest first and **+N more** for the rest, a click listing them all, since Claude Code writes parallel calls on separate lines and their results arrive in any order — by its description (Claude Code's Bash `description`) or tool and target, with that call's own time once it has run 30 s; else **Thinking…**. A line with no new transcript line for 3 minutes turns amber (the step may be stuck). While the CLI asks you something — the main agent awaiting input on a `Notification` hook (a permission prompt; `awaiting_reason = 'notification'`, read with the Stop button's agent poll) — it reads **Waiting for your approval: ** in amber with an **Open terminal** button (`showTerminal` in `PaneGrid.tsx`, the chat/terminal toggle's own switch: the unsent prompt goes into the CLI's input box); the menu card above answers it from the chat. AskUserQuestion fires no `Notification`, so a menu read off the pane counts as waiting too — for this line and the hidden tab's **● Waiting**. Before the session has started — no session row yet, because Claude Code runs no hook until its folder is trusted — the chat says so instead of the empty-transcript text, with the same **Open terminal** button (`notStarted` in `ConversationView.tsx`), and Send and every control typed into the pane (`/model`, `/effort`) ask first (`confirmDialog`, **Send anyway**): an Enter there would answer the trust prompt, whose preselected choice is **No, exit** (issue #73). It asks rather than refuses because the row appears only with the session's first hook, and a hard lock would strand the chat if none came right after the answer. A turn that ended (`end_turn`) closes with **✓ Done in 2m 8s · ↑ 3.5K tokens** — no cost: pricing one turn would need a second cost formula. The prompt box shows how full the context is (`ContextMeter` in `PaneChat.tsx`): the newest reply's prompt size (input + cache) against the model's window — 200K for Claude's 4.5 line and older, 1M for every model since (`contextWindow`, from LiteLLM's list; none for another vendor) — amber from 75 %, red from 90 %, the numbers on hover. While the browser tab is hidden its title is marked **● Waiting** while a chat pane's session waits on you and **✓ Done** once a turn ended (`client/src/lib/tabBadge.ts`); showing the tab drops the Done mark. Thinking is shown (after your prompt, after a tool result, or after narration whose `stop_reason` is `tool_use`). The transcript route passes each assistant line's `stop_reason`; `end_turn` ends the turn, so does an interrupt, and a turn with no new line for 15 minutes and no call running is treated as dead. The chat re-reads the transcript every 3 s while the page is visible and the pane is on screen (`useOnScreen` in `client/src/hooks/useOnScreen.ts`: a pane of another workspace or tab stays mounted but `display:none`, and polls nothing until it is shown again, then catches up once), and on a `new_event` of its session at most every 1.5 s (`EVENT_FETCH_MS`). The tail read (`?after=`) skips the lines the chat already has before parsing them. While the chat covers the terminal, the terminal is moved out of the viewport with a transform (`covered` on `TerminalView`), which stops xterm drawing without changing its size — the menu reading below still works. Each message's hover line shows ↓ the whole prompt it was answered from (input + cache read + cache write; the split on hover) and ↑ its output. An AskUserQuestion is not folded into the collapsed work line: it is its own card (`AskCard`) showing each question with the option the user picked or what they typed — the `answers` the transcript route reads off Claude Code's `toolUseResult` —, "declined" when they refused it. Each assistant turn that changed files ends, like Claude Desktop's, with an **Edited N files** card (`EditedFiles` in `client/src/components/conversation/MessageList.tsx`): one row per file with the lines it added and removed over the turn — the `diff` the transcript route reads off Claude Code's `structuredPatch` — and, in a live pane's chat, a click on a row opens that file in the workspace's Changes panel when it lies in the pane's folder, else its preview (beside the chat, or over it when the pane is narrow). It has no Undo: reverting a file is a write the dashboard does not take from a click. The conversation column and the prompt box are `max(48rem, 70%)` of the pane wide — never narrower than Claude's fixed 48rem, wider on a large screen. Every chat pane, split or not, has Claude Desktop's activity panel (`client/src/components/run/ChatOutline.tsx`, `GET /api/sessions/:id/outline`, re-read every 5 s while the tab is visible and the pane is on screen; the server keeps the last answer per session until its events change). It starts open in a pane that spans the grid's full width or height (`spanning` in `ConsoleArea`, `client/src/pages/Workspace.tsx`, from `layoutRects`) once the session has an output, and otherwise — no output yet, or a quarter-sized pane (e.g. four panes at once) — collapsed into a file button with the number of outputs at the chat's top right. The user's own open or close is kept per session for the browser tab (`sessionStorage` `agenthub.chat.panel.`), so switching the pane to its terminal and back keeps it; a split or a close that changes the pane's shape drops it and brings the default back. Whether it sits beside the conversation or floats over it is decided by the PANE's width, not the window's (a CSS container query on `PaneChat`'s root, 40rem): beside it, a splitter on its left edge (`Divider` from `PaneGrid.tsx`, arrow keys too) drags the panel — and a file preview in its place — between 20% and 70% of the pane, remembered per browser (`localStorage` `agenthub.chat.panelWidth`, one fraction for every pane). Open, it shows **Progress** — the session's `TaskCreate`/`TaskUpdate` (or `TodoWrite`) plan, only when there is one: how many steps are done of how many (`3/7`) over a bar, done items struck through and the step in progress highlighted; **Outputs** — files it wrote or edited (`Write`/`Edit`/`MultiEdit`/`NotebookEdit`), newest first, each with its extension tag (a file made by a Bash command is not listed); and **Used in this session** — **Uploads** (files attached through the box), **Files read**, **Memory** (Claude Code memory files), **Skills** and **Connectors** (MCP servers). A file path in a reply or your message is a link — absolute, or relative with a folder and an extension (`server/lib/x.js`, resolved against the pane's folder by `resolveIn` in `client/src/lib/resolvePath.ts`), a `:line` suffix included, bare, in inline code or as a markdown link's target (`[x](src/x.ts#L12)`; `MarkdownContent.tsx`) — and opens like an Edited-files row: in the Changes panel when it lies in the workspace's folder, else in the preview. A click on an output opens its preview in the panel's place (`FilePreview` in `client/src/components/run/ChatFilePreview.tsx`: name · EXT, Copy path, Close back to the panel); Uploads, Files read and Memory open their list with a preview beside it (`FileListModal`), and Skills opens the same dialog over the session's skills, each previewed from its SKILL.md (`SkillListModal`: a plugin's `plugin:name` from `GET /api/cc-config/plugins`, else the user's or the project's skill; a built-in skill has no file and shows by name); Connectors opens the Claude config page. Every row but Connectors also drags onto the prompt box (`dragAttachments`/`dragSkill` in `client/src/lib/promptAttach.ts`): an output, or a whole Uploads/Files read/Memory group, attaches its files like the `+` menu does (an upload by its bare path, anything else as `@`); a skill becomes the message's leading `/` — the Skills row only when the session used one skill (a prompt takes one command), else drag it from its list. A row dragged out of one of those list dialogs closes the dialog, so the box is reachable. A preview reads only through routes that already guard it — a file under the pane's folder through `GET /api/lanes/:id/git/file` (an image through `/git/raw`), a memory file under `~/.claude` through `GET /api/cc-config/file`, an attached image or text file (`.txt`, `.md`, `.csv`, `.log`, `.json`… — a long paste is one) through `GET /api/run/drop/:name` — and shows just the path for anything else (an attached spreadsheet, a file outside the workspace, one since deleted). A Windows path (`C:\…\.claude\…`) is matched like a POSIX one. The ended-session replay and the Analyst page's conversation use the same layout. The chat's unsent prompt and its attachments are kept per session in this browser (`agenthub.chat.draft.`, `client/src/lib/chatDraft.ts`), so switching the pane to its terminal and back, or reloading the page, leaves them as typed — an attached image keeps its path, not its preview; a send clears it. Switching carries the unsent prompt across: to the terminal, the draft (its files as paths) is pasted into the CLI's input box without Enter and the draft is cleared; to the chat, the text in the CLI's input box is read off the screen (`parsePromptInput` in `client/src/lib/panePaste.ts`: Claude's `❯` box between its `─` rules, Codex's last `›` row up to its footer; a soft-wrapped row is joined back) into the draft and deleted there with Delete/Backspace — never Ctrl+C or Esc, which would stop a working turn. A box holding `[Pasted text #N]` or `[Image #N]` is left as it is, since the screen does not show what it stands for. A row of a turn's **Edited N files** card drags into the prompt box as an `@path` attachment (its header drags every file in the card); dropped elsewhere it is the plain path. +- **Chat view of a live pane** — each Claude or Codex SESSION shows either a chat or its terminal, its own choice: the chat/terminal button left of the pane's split buttons (`ChatToggle` in `client/src/components/run/PaneGrid.tsx`) switches it, and the choice is kept in this browser's localStorage by session id (`agenthub.chatUi.sessions`, the newest 200; `useSessionChat` in `client/src/lib/chatUi.ts`). **Settings → Appearance → Chat UI** (`ChatUiSettings.tsx`, saved per ACCOUNT on the server: `GET/PUT /api/me/prefs`, key `agenthub.chatUi`) is only the default — written onto a session the first time it is shown, so changing it later leaves every session already seen as it is. The chat needs the session id, so a pane shows it only once its session is known (`client/src/components/run/LaneConsolePane.tsx`). Chat (`client/src/components/run/PaneChat.tsx`) shows the session's conversation the way Claude Desktop does — the shared `ConversationView` in its `fill` layout: one centred column, your messages as bubbles, the reply as plain prose, every run of tool calls folded into one line named by what it did, in Claude Desktop's fixed order ("Ran 3 commands, edited a file, used a tool ›", `workLabel`) — a thinking block that carries text is the model's narration (Claude Code ≥ 2.1.280 stores real thinking empty) and reads as prose between those lines, as Claude Desktop shows it — that opens onto a framed list of its steps — each titled by its own description, the call in a capped scroll box, its output one click further — and a round ↓ back to the end — above a prompt box that grows with its text up to 40% of the screen, then scrolls. The conversation stays at its end while you are there: a new line, the running step, an image loading or the pane shown again after being hidden keeps the newest in sight (a `ResizeObserver` in `ConversationView`), until you scroll up; sending a message takes it back to the end. It lies OVER the terminal, which stays mounted and attached (the console never unmounts a terminal); a message is sent through that terminal as a bracketed paste and Enter (`submitToRun` in `client/src/lib/panePaste.ts`), exactly as if typed, so no new route or server write exists for it. On a Windows dashboard (psmux) the attach (`server/lib/pty-attach.js`) holds any input that follows a paste until 700 ms after it (`PASTE_HOLD_MS`): psmux delays a paste ~350 ms and wrote an Enter sent meanwhile INSIDE it (psmux#766), so the prompt sat in Claude's composer as `[Pasted text #1]` instead of being sent. While the session's main agent is `working` (its `UserPromptSubmit` hook seen, no `Stop` yet — `GET /api/agents?session_id=&status=working`, re-read on `agent_updated`), an empty box shows **Stop** in Send's place, like Claude Desktop: it presses Esc in the pane (`interruptRun` in the same file), the key `claude` and `codex` stop a turn with; typing a message brings Send back (Claude Code queues it). Stop also ends the conversation's live line (`stopped` on `ConversationView`/`MessageList`) until the transcript gains a newer line: an Esc before the model wrote anything puts the prompt back in Claude Code's input box and writes no `[Request interrupted by user]` line, so the transcript still ends on the prompt and would read as **Thinking…** for 15 minutes (issue #74). An Esc pressed in the terminal itself is not seen by the chat. A Codex pane without AgentHub's hooks never shows it. An image attached in the box reaches the transcript as its saved path — in a Claude pane as an `@` mention (`claudeMention` in `client/src/lib/promptAttach.ts`, which Claude reads at submit, an image as an image; a trailing space keeps its `@` suggester from taking the Enter), as is a file dropped or pasted on a Claude terminal and every file a new or resumed Claude session is started with: a bare pasted path became `[Image #n]` only when the TUI caught the paste in time, and stayed a plain `C:\…\drops\…png` on a Windows dashboard (psmux), while a path in the command-line prompt is never an image; the user's turn shows the image itself (`GET /api/run/drop/:name`) — any other attached file as a chip (name · EXT) that opens its preview like an output — and opens it full size over the whole page (`ImageLightbox`, rendered into `document.body`, so nothing stacked over a pane covers it). The box carries Claude Desktop's controls: a `+` menu (`ChatAddMenu.tsx`, also in the workspace home's prompt box) — Upload or add files; what already exists, attached like an upload (a chip, its path sent with the prompt): **Project files** (a search of the box's folder, folders too — `GET /api/run/files?dirs=1` — sent as `@path`), **Context files** (the workspace's own, `GET /api/lanes/:id/sources`, sent as `@`) and **Recent uploads** (files uploaded through a prompt box in the last day, kept in this browser's localStorage `agenthub.prompt.recentUploads` — the server prunes an upload after a day); a file or folder dragged onto anywhere in a prompt box (not only its text area) attaches the same way — from the Changes panel's Files tree, its search, a Changes-list row, or a workspace's Context files and Folder list; a row from an extra folder's section (`data-drag-root`) and a context file or folder carry their absolute path, since they lie outside the pane's folder (`client/src/lib/promptAttach.ts`); Skills (yours and the project's), Plugins → a plugin → the skills and commands it ships, each put at the front of the message as `/` / `/:`, and Manage links to the Claude config page — and, for a Claude pane, the Model/Effort chip, a change typed into the pane as `/model ` / `/effort `, and the permission mode beside `+` in the box. A menu opened in a split pane (a `Select`, the Model chip) flips up or down by the room inside ITS pane (`data-menu-bounds` on the pane, `menuRoom` in `client/src/components/Select.tsx`), never the window's — a menu running past the pane's edge is painted over by the pane below. A running session changes mode only by Shift+Tab, so the server steps it and reads each step back off the pane (`POST /api/run/:id/permission-mode`, `server/lib/pane-mode.js`): it lands on exactly the mode picked, presses nothing while a menu hides the mode line, and returns to where it began when the mode is not in that session's cycle (Bypass only when the session was started with it). The mode belongs to that session only. A menu the CLI asks with — AskUserQuestion (one question or several, multiSelect too), a permission or trust prompt, Codex's approval, a first-start login menu — is answered from the chat: it is read off the terminal underneath every 700 ms while the pane is on screen (`parsePaneMenu`/`readRunMenu` in `client/src/lib/panePaste.ts`: two numbered rows or more from 1, the `❯`/`›` cursor on one of them, no `─` rule right above the first — a reply's numbered list has no cursor, a list typed into Claude's input box sits under its rule) and shown in the prompt box's place as a card of buttons (`PaneMenuCard` in `PaneChat.tsx`, with each option's description and a multiSelect box's state; the box is hidden with CSS, never unmounted, so its draft survives the question); a pick presses its digit in the pane (which picks it, or ticks its box), Claude's unnumbered **Submit** row is reached with the arrows and Enter, **Cancel (Esc)** presses Esc, and the **Type something** row opens a field inside the card whose text, on Enter, is typed there as the answer (`answerRunMenu`: the arrows to that row, Ctrl+U over any text already in it, the text as a bracketed paste, Enter — none in a multiSelect, where Submit is still to pick). That row presses no key: a second press of its digit would type the digit into the CLI's own field. Once text is typed in that row its label is the text, so it is found as the last numbered row above **Chat about this**. Like Send, it only presses keys in the pane, so no new route exists for it. A menu with no numbers (the first-start theme picker) still needs the terminal. A live pane's chat ends, until its turn is over, with what the model is doing now and — like the terminal's status line — how long the turn has run since your prompt and the output tokens it has made so far, e.g. `Thinking… (2m 8s · ↑ 3.5K tokens)` (`workingOn` and `turnStats` in `MessageList.tsx`; Claude Code repeats a call's usage on each of its lines, so consecutive lines with the same prompt size count once), the words lit by a shimmer that sweeps a few times and rests (`.text-shimmer`, stopped under reduced motion) and the token count rolling up rather than jumping. It names every call of the turn still waiting for its result — the oldest first and **+N more** for the rest, a click listing them all, since Claude Code writes parallel calls on separate lines and their results arrive in any order — by its description (Claude Code's Bash `description`) or tool and target, with that call's own time once it has run 30 s; else **Thinking…**. A line with no new transcript line for 3 minutes turns amber (the step may be stuck). While the CLI asks you something — the main agent awaiting input on a `Notification` hook (a permission prompt; `awaiting_reason = 'notification'`, read with the Stop button's agent poll) — it reads **Waiting for your approval: ** in amber with an **Open terminal** button (`showTerminal` in `PaneGrid.tsx`, the chat/terminal toggle's own switch: the unsent prompt goes into the CLI's input box); the menu card above answers it from the chat. AskUserQuestion fires no `Notification`, so a menu read off the pane counts as waiting too — for this line and the hidden tab's **● Waiting**. Before the session has started — no session row yet, because Claude Code runs no hook until its folder is trusted — the chat says so instead of the empty-transcript text, with the same **Open terminal** button (`notStarted` in `ConversationView.tsx`), and Send and every control typed into the pane (`/model`, `/effort`) ask first (`confirmDialog`, **Send anyway**): an Enter there would answer the trust prompt, whose preselected choice is **No, exit** (issue #73). It asks rather than refuses because the row appears only with the session's first hook, and a hard lock would strand the chat if none came right after the answer. A turn that ended (`end_turn`) closes with **✓ Done in 2m 8s · ↑ 3.5K tokens** — no cost: pricing one turn would need a second cost formula. A subagent launched in the background returns at once (its result is "Async agent launched…"), so the turn ends while it still works: until a `` names its call, a live chat lists it under the conversation — its type, its description, how long it has run and, once its own transcript was read, the step it is on and its context size (↓, its newest prompt), like the terminal's footer (`backgroundAgents`/`BackgroundAgents`/`agentProgress` in `MessageList.tsx`; the tail is re-read every 4 s through the existing `GET /api/sessions/:id/transcript?agent_id=`, only the lines after those already read, and only while the card is on screen) — and the last turn reads **Replied in … · N agents still running** with a spinner instead of Done (issue #81). A launch older than 6 hours with no notification is taken as ended (a CLI that quit, a resumed old session). The prompt box shows how full the context is (`ContextMeter` in `PaneChat.tsx`): the newest reply's prompt size (input + cache) against the model's window — 200K for Claude's 4.5 line and older, 1M for every model since (`contextWindow`, from LiteLLM's list; none for another vendor) — amber from 75 %, red from 90 %, the numbers on hover. While the browser tab is hidden its title is marked **● Waiting** while a chat pane's session waits on you and **✓ Done** once a turn ended (`client/src/lib/tabBadge.ts`); showing the tab drops the Done mark. Thinking is shown (after your prompt, after a tool result, or after narration whose `stop_reason` is `tool_use`). The transcript route passes each assistant line's `stop_reason`; `end_turn` ends the turn, so does an interrupt, and a turn with no new line for 15 minutes and no call running is treated as dead. The chat re-reads the transcript every 3 s while the page is visible and the pane is on screen (`useOnScreen` in `client/src/hooks/useOnScreen.ts`: a pane of another workspace or tab stays mounted but `display:none`, and polls nothing until it is shown again, then catches up once), and on a `new_event` of its session at most every 1.5 s (`EVENT_FETCH_MS`). The tail read (`?after=`) skips the lines the chat already has before parsing them. While the chat covers the terminal, the terminal is moved out of the viewport with a transform (`covered` on `TerminalView`), which stops xterm drawing without changing its size — the menu reading below still works. Each message's hover line shows ↓ the whole prompt it was answered from (input + cache read + cache write; the split on hover) and ↑ its output. An AskUserQuestion is not folded into the collapsed work line: it is its own card (`AskCard`) showing each question with the option the user picked or what they typed — the `answers` the transcript route reads off Claude Code's `toolUseResult` —, "declined" when they refused it. Each assistant turn that changed files ends, like Claude Desktop's, with an **Edited N files** card (`EditedFiles` in `client/src/components/conversation/MessageList.tsx`): one row per file with the lines it added and removed over the turn — the `diff` the transcript route reads off Claude Code's `structuredPatch` — and, in a live pane's chat, a click on a row opens that file in the workspace's Changes panel when it lies in the pane's folder, else its preview (beside the chat, or over it when the pane is narrow). It has no Undo: reverting a file is a write the dashboard does not take from a click. The conversation column and the prompt box are `max(48rem, 70%)` of the pane wide — never narrower than Claude's fixed 48rem, wider on a large screen. Every chat pane, split or not, has Claude Desktop's activity panel (`client/src/components/run/ChatOutline.tsx`, `GET /api/sessions/:id/outline`, re-read every 5 s while the tab is visible and the pane is on screen; the server keeps the last answer per session until its events change). It starts open in a pane that spans the grid's full width or height (`spanning` in `ConsoleArea`, `client/src/pages/Workspace.tsx`, from `layoutRects`) once the session has an output, and otherwise — no output yet, or a quarter-sized pane (e.g. four panes at once) — collapsed into a file button with the number of outputs at the chat's top right. The user's own open or close is kept per session for the browser tab (`sessionStorage` `agenthub.chat.panel.`), so switching the pane to its terminal and back keeps it; a split or a close that changes the pane's shape drops it and brings the default back. Whether it sits beside the conversation or floats over it is decided by the PANE's width, not the window's (a CSS container query on `PaneChat`'s root, 40rem): beside it, a splitter on its left edge (`Divider` from `PaneGrid.tsx`, arrow keys too) drags the panel — and a file preview in its place — between 20% and 70% of the pane, remembered per browser (`localStorage` `agenthub.chat.panelWidth`, one fraction for every pane). Open, it shows **Progress** — the session's `TaskCreate`/`TaskUpdate` (or `TodoWrite`) plan, only when there is one: how many steps are done of how many (`3/7`) over a bar, done items struck through and the step in progress highlighted; **Outputs** — files it wrote or edited (`Write`/`Edit`/`MultiEdit`/`NotebookEdit`), newest first, each with its extension tag (a file made by a Bash command is not listed); and **Used in this session** — **Uploads** (files attached through the box), **Files read**, **Memory** (Claude Code memory files), **Skills** and **Connectors** (MCP servers). A file path in a reply or your message is a link — absolute, or relative with a folder and an extension (`server/lib/x.js`, resolved against the pane's folder by `resolveIn` in `client/src/lib/resolvePath.ts`), a `:line` suffix included, bare, in inline code or as a markdown link's target (`[x](src/x.ts#L12)`; `MarkdownContent.tsx`) — and opens like an Edited-files row: in the Changes panel when it lies in the workspace's folder, else in the preview. A click on an output opens its preview in the panel's place (`FilePreview` in `client/src/components/run/ChatFilePreview.tsx`: name · EXT, Copy path, Close back to the panel); Uploads, Files read and Memory open their list with a preview beside it (`FileListModal`), and Skills opens the same dialog over the session's skills, each previewed from its SKILL.md (`SkillListModal`: a plugin's `plugin:name` from `GET /api/cc-config/plugins`, else the user's or the project's skill; a built-in skill has no file and shows by name); Connectors opens the Claude config page. Every row but Connectors also drags onto the prompt box (`dragAttachments`/`dragSkill` in `client/src/lib/promptAttach.ts`): an output, or a whole Uploads/Files read/Memory group, attaches its files like the `+` menu does (an upload by its bare path, anything else as `@`); a skill becomes the message's leading `/` — the Skills row only when the session used one skill (a prompt takes one command), else drag it from its list. A row dragged out of one of those list dialogs closes the dialog, so the box is reachable. A preview reads only through routes that already guard it — a file under the pane's folder through `GET /api/lanes/:id/git/file` (an image through `/git/raw`), a memory file under `~/.claude` through `GET /api/cc-config/file`, an attached image or text file (`.txt`, `.md`, `.csv`, `.log`, `.json`… — a long paste is one) through `GET /api/run/drop/:name` — and shows just the path for anything else (an attached spreadsheet, a file outside the workspace, one since deleted). A Windows path (`C:\…\.claude\…`) is matched like a POSIX one. The ended-session replay and the Analyst page's conversation use the same layout. The chat's unsent prompt and its attachments are kept per session in this browser (`agenthub.chat.draft.`, `client/src/lib/chatDraft.ts`), so switching the pane to its terminal and back, or reloading the page, leaves them as typed — an attached image keeps its path, not its preview; a send clears it. Switching carries the unsent prompt across: to the terminal, the draft (its files as paths) is pasted into the CLI's input box without Enter and the draft is cleared; to the chat, the text in the CLI's input box is read off the screen (`parsePromptInput` in `client/src/lib/panePaste.ts`: Claude's `❯` box between its `─` rules, Codex's last `›` row up to its footer; a soft-wrapped row is joined back) into the draft and deleted there with Delete/Backspace — never Ctrl+C or Esc, which would stop a working turn. A box holding `[Pasted text #N]` or `[Image #N]` is left as it is, since the screen does not show what it stands for. A row of a turn's **Edited N files** card drags into the prompt box as an `@path` attachment (its header drags every file in the card); dropped elsewhere it is the plain path. - **The workspace home** — `/workspace?lane=` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box — the one place a session starts WITH a prompt (a pane has no run form; the session view's tab bar `+` only starts a bare CLI or a shell) — whose fields run in order **AI** (the session's own tool: every installed, enabled one, the workspace's by default), **AI Proxy** (Native or a saved profile; Native alone, disabled, for a tool that cannot use one), **Model** (the tool's own list, or on a router that router's models), **Permission** (Claude's permission mode; Codex's sandbox and approval; a catalog tool's per-session bypass switch) and **Effort** (Claude and Codex), whose half-typed text is kept per workspace in `agenthub.workspace.draft.` (localStorage, so it survives leaving the page or switching workspace; cleared once sent — attached images are not kept), and whose **Start** opens a NEW session in the first slot with no live run (0–9, `POST /api/lanes/:id/start {slot}`; all ten busy is refused) and then shows it in a tab of its own, and **Recents** — the workspace's own sessions (`?lane=`) with their cost and time, a live one opening its pane and any other its conversation (`&session=`), exactly like the sidebar. The transcript page (`/sessions/`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`, the one place that resumes) and beside it **Fork**, which resumes the history into a NEW session id (`{forkSession: true}` → `--fork-session`, the original untouched, offered even while the original has a live pane): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt, after the workspace owner's own **My agent instructions** from Settings → AI tools — one markdown text per account (typed, or read from a local `.md`/`.txt` with **Choose file**, saved only on Save), shared by every workspace they own), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir` plus the instructions as a `CLAUDE.md` in an `--add-dir` folder of their own / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. Each extra folder is also its own repo in the **Changes** panel, stacked under the workspace folder's like VS Code's Source Control: once the workspace has one, every repo is a section with a foldable header, each a whole panel — Changes, Files, Review, History, commit and push — on its own folder (every git route with `?source=`, resolved from the `lane_sources` row; `ENOSOURCE` otherwise, never the workspace folder); the open sections share the height, and a folded one stays mounted. A chat's request to open a file unfolds the workspace folder's section. Right-clicking a section header offers **Close repository** (extra folders only — the workspace folder never closes) and **Close other repositories**; closing REMOVES the folder from the workspace (`DELETE /api/lanes/:id/sources/:sourceId`, the folder stays on disk), asking first only if that section holds an unsaved edit. The panel's bottom fold, **Workspace context** (folded by default, kept in `agenthub.workspace.contextOpen`), is the home's context column made compact (`client/src/components/lanes/WorkspaceContext.tsx`, the same component): Instructions, Memory, Context files and Folders are edited from the session view without going back to the home, and a folder added or removed there joins or leaves the stack above at once. A plain-text paste over 5,000 characters into any prompt box (the home, a chat, a finished session's reply) is attached as `pasted-text.txt` like a pasted file (`PASTE_AS_FILE_CHARS` in `client/src/components/run/RunSetup.tsx`): the prompt carries its path, never the text (#68). The home HIDES the session view with CSS — the console never unmounts behind it. - **A session in its own worktree** — Claude Desktop's worktree toggle: the row under the home's prompt box has a **Local / New worktree** select (Claude workspaces in a git repo). Claude Code itself makes a worktree only when started with `--worktree` or when asked mid-session — and a worktree it enters mid-session is invisible to AgentHub, which reads a session's folder once, at its start, and the new session's pane runs `claude --worktree=`, so CLAUDE CODE makes `/.claude/worktrees/` on branch `worktree-`, copies `.worktreeinclude`, runs your `WorktreeCreate` hook, and on exit removes a worktree with no changes (or asks). AgentHub adds `/.claude/worktrees/` to the repo's `info/exclude` (Claude does not, and the main repo would list it). The session stays this workspace's own — listed with a branch icon and the worktree's name — and the session view's Changes panel works in ITS worktree (every git route with `?session=`, resolved from the session row: owned by the workspace, registered by `git worktree list`; `ENOWORKTREE` otherwise, never the main folder). Resume and Fork start in that worktree. **Archive** of a worktree session with no live pane asks first and removes the worktree like Claude Desktop — `git worktree remove` without force (git refuses unsaved work, and then nothing is archived) and `git branch -d` (unmerged work keeps its branch); a lock held by a live process refuses, a stale one is cleared. The workspace's own folder is never removed. - **Resume interrupted sessions** — when a session's pane dies under it (the machine shut down, tmux was killed, the CLI crashed), its run is recorded `interrupted` instead of `abandoned`, and every page of the dashboard opens the **Resume interrupted sessions?** dialog (`client/src/components/lanes/InterruptedSessionsPrompt.tsx`, from `GET /api/run/interrupted`). Each session is listed with its workspace, tool, name or first prompt and when it stopped, all ticked: **Resume** starts each ticked one in its workspace's first free tab (`POST /api/lanes/:id/start {slot: "auto", resumeSessionId}`, with the run's model, effort and permission mode — never `bypassPermissions`) and opens the first; **Don't resume** (also ×, Escape, the backdrop) resumes none. Either answer dismisses the whole list (`POST /api/run/interrupted/dismiss`), so it is asked once; a resume that failed stays listed. What counts as interrupted: a pane the dashboard did not close (Stop, ×, Quit are `killed`), not a Terminal tab, with a known session — and for Claude, not one the user ended themselves (`SessionEnd` reason `prompt_input_exit`/`logout`). Codex and other tools report no such reason, so their every vanished pane counts; a Codex pane without its opt-in hooks has no known session and is never listed. A session resumed by any other path drops off the list. A run younger than 20 s is never judged dead (a tmux list is a cached answer, and another dashboard on the same database may not have listed a brand-new pane yet). In server mode each user is offered only their own workspaces' sessions. diff --git a/docs/plans/onboarding/README.md b/docs/plans/onboarding/README.md index 25e0c3a8..7ed09205 100644 --- a/docs/plans/onboarding/README.md +++ b/docs/plans/onboarding/README.md @@ -2,7 +2,7 @@ Asked 2026-10-08 (modelled on Orca's 3-step welcome). Decisions: -- **Bypass is never remembered** (CLAUDE.md rule). The wizard remembers a default *permission mode* (Auto / Accept edits / Ask / Plan); bypass stays the red per-session switch. +- **The wizard never offers bypass.** It remembers a default *permission mode* (Auto / Accept edits / Ask / Plan). Since 2026-10-19 Settings → AI tools → **Session defaults** may make Bypass the default (Claude's `bypassPermissions`, Codex's bypass switch), behind a confirm; the pet chat still never starts in it. - **Per account, on the server.** The choices follow the account to any browser. Local mode is one account, `local`. - **The tutorial is a step-by-step spotlight tour.** @@ -24,7 +24,7 @@ Asked 2026-10-08 (modelled on Orca's 3-step welcome). Decisions: - **On save:** wrapping `localStorage.setItem` for `PREF_KEYS` pushes each change (debounced). - [x] New preference keys: - `agenthub.defaultTool`, read by AddLaneModal before the install default; - - `agenthub.permissionMode`, read by WorkspaceHome. `bypassPermissions` is never accepted here. + - `agenthub.permissionMode`, read by WorkspaceHome. `bypassPermissions` is accepted only as set from Settings → Session defaults. - [x] `components/onboarding/OnboardingWizard.tsx`, mounted in Layout. It shows when the account is not onboarded AND has no workspace (an account that already has one is marked onboarded silently). Steps: 1. Default agent (detected tools as a grid, plus "show more") and the default permission mode. 2. Appearance: System, Dark and Light as preview cards, a theme grid for dark and for light below them, then language and terminal font. diff --git a/openapi.yaml b/openapi.yaml index 6c68899f..baee8841 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -3,7 +3,7 @@ openapi: 3.0.3 info: title: Agent Dashboard for Claude Code API - version: 2.8.11 + version: 2.8.12 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ĩ @@ -68,6 +68,10 @@ tags: description: 'The File Explorer on a workspace or a user''s home: list, make, rename, move, copy, delete into a restorable trash, streamed upload, zip download. Writes run as the folder''s account; a taken name is settled by `conflict`.' - name: Git identity description: A user's own SSH key and HTTPS logins and commit author, and cloning a repository into a new workspace with them. + - name: Artifacts + description: 'What agents produced, shared as a SNAPSHOT: a claude.ai page link, a pinned or uploaded file, a skill or a plugin. A viewer reads only the snapshot copy (sandbox CSP, nosniff); an install writes into the caller''s own account only. Every write is same-origin.' + - name: Host + description: 'Administration → Server: the machine''s hardware, load, disks and each user''s share of CPU and memory. Read-only, staff only in server mode.' components: parameters: SessionIdPath: @@ -3859,7 +3863,7 @@ components: example: claude-opus-4-8 resumeSessionId: type: string - description: Optional Claude session id to resume via `--resume`. Must match /^[A-Za-z0-9-]{8,}$/ and requires conversation mode, else EBADSESSION / EBADMODE. + description: Optional Claude session id to resume via `--resume`. Must match /^[A-Za-z0-9_-]{8,}$/ and requires conversation mode, else EBADSESSION / EBADMODE. example: a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d effort: type: string @@ -4218,6 +4222,9 @@ components: resume: type: boolean description: Offers Resume last session + resumeById: + type: boolean + description: Can resume a named session (Claude, Codex, a catalog `resumeById` such as omp) bypass: type: boolean description: Offers the per-run Bypass permissions switch @@ -4928,6 +4935,71 @@ components: - replace default: fail description: 'A taken name: `fail` answers 409 EEXISTS with the names, `skip` leaves it, `keep` uses a free `name (1).ext`, `replace` sends the old one to the trash first.' + Artifact: + type: object + properties: + id: + type: string + kind: + type: string + enum: + - page + - file + - skill + - plugin + title: + type: string + description: + type: string + nullable: true + ownerId: + type: integer + nullable: true + owner: + type: string + nullable: true + description: Display name or username. + mine: + type: boolean + canChange: + type: boolean + laneId: + type: integer + nullable: true + sessionId: + type: string + nullable: true + source: + type: string + nullable: true + description: 'Plugin: `name@marketplace`.' + market: + type: string + nullable: true + description: 'Plugin: owner/repo, git URL or marketplace.json URL.' + url: + type: string + nullable: true + description: 'Page: a https://claude.ai/… link.' + files: + type: array + items: + type: object + description: The files the snapshot recorded. + size: + type: integer + nullable: true + shared: + type: boolean + sharedAt: + type: string + nullable: true + installs: + type: integer + createdAt: + type: string + updatedAt: + type: string paths: /api/health: get: @@ -16290,9 +16362,34 @@ paths: type: string remember: type: boolean + partial: + type: boolean + description: 'Blobless clone (`--filter=blob:none`): file contents fetched on checkout.' + progressToken: + type: string + pattern: ^[A-Za-z0-9_-]{16,64}$ + description: The caller's name for this clone, to poll `/api/lanes/clone/progress/{token}`. responses: '201': description: '{lane, created: true, path}' + /api/lanes/clone/progress/{token}: + get: + tags: + - Git identity + summary: A clone's place in the queue, then git's progress + description: Clones run a few at a time machine-wide (`AGENTHUB_CLONE_CONCURRENCY`). Only the user who started the clone sees it; kept 60 s after it ends. + operationId: cloneProgress + parameters: + - name: token + in: path + required: true + schema: + type: string + responses: + '200': + description: '{state: queued|running|done|failed, position, running, waiting, limit, phase: remote|receiving|resolving|checkout|other|null, label, percent, detail}' + '404': + description: ENOTFOUND /api/me/git: get: tags: @@ -16491,6 +16588,583 @@ paths: description: The identity. '400': description: EBADAUTHOR + /api/artifacts: + get: + tags: + - Artifacts + summary: List artifacts + operationId: listArtifacts + description: A developer sees their own rows plus shared ones; staff see every row in `hub` scope. + parameters: + - name: scope + in: query + schema: + type: string + enum: + - mine + - hub + - name: kind + in: query + schema: + type: string + enum: + - page + - file + - skill + - plugin + - name: q + in: query + schema: + type: string + - name: laneId + in: query + required: false + schema: + type: integer + - name: sessionId + in: query + schema: + type: string + responses: + '200': + description: '`{items}`.' + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/Artifact' + /api/artifacts/settings: + get: + tags: + - Artifacts + summary: Hub switches and what the caller may do + operationId: getArtifactSettings + responses: + '200': + description: '`{serverMode, canShare, installsEnabled, canManage}`.' + content: + application/json: + schema: + type: object + properties: + serverMode: + type: boolean + canShare: + type: boolean + installsEnabled: + type: boolean + canManage: + type: boolean + put: + tags: + - Artifacts + summary: Turn installs on or off (admin; any caller in local mode) + operationId: setArtifactSettings + description: Audited in server mode (Settings → AI tools → Artifacts Hub). + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - installsEnabled + properties: + installsEnabled: + type: boolean + responses: + '200': + description: '`{installsEnabled}`.' + '400': + description: EBADFIELD + '403': + description: EADMIN / EBADORIGIN + /api/artifacts/candidates: + get: + tags: + - Artifacts + summary: Documents a workspace's sessions wrote (to pin) + operationId: listArtifactCandidates + parameters: + - name: laneId + in: query + required: false + schema: + type: integer + responses: + '200': + description: '`{items: [{path, sessionId, at, exists, pinned}]}`.' + '404': + description: 'Error `{error:{code,message}}`: 400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.' + /api/artifacts/skills: + get: + tags: + - Artifacts + summary: Skills the caller can share (user + workspace) + operationId: listArtifactSkills + parameters: + - name: laneId + in: query + required: false + schema: + type: integer + responses: + '200': + description: '`{items: [{name, from: "user"|"workspace"}]}`.' + /api/artifacts/page: + post: + tags: + - Artifacts + summary: Add a claude.ai page link + operationId: addArtifactPage + description: Only `https://claude.ai/…` is accepted; one row per owner and link. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - url + properties: + url: + type: string + title: + type: string + description: + type: string + responses: + '201': + description: Created. + content: + application/json: + schema: + type: object + properties: + item: + $ref: '#/components/schemas/Artifact' + '400': + description: 'Error `{error:{code,message}}`: 400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.' + /api/artifacts/file: + post: + tags: + - Artifacts + summary: Pin a workspace file (snapshotted) + operationId: pinArtifactFile + description: Read under the download route's guards (`lanePath`, `resolveInLane`, `ESECRET`, ≤ 25 MB) and copied once. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - laneId + - path + properties: + laneId: + type: integer + path: + type: string + title: + type: string + sessionId: + type: string + responses: + '201': + description: Created. + content: + application/json: + schema: + type: object + properties: + item: + $ref: '#/components/schemas/Artifact' + '400': + description: 'Error `{error:{code,message}}`: 400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.' + /api/artifacts/file/upload: + post: + tags: + - Artifacts + summary: Keep one uploaded file (≤ 25 MB) + operationId: uploadArtifactFile + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - files + properties: + files: + type: string + format: binary + title: + type: string + description: + type: string + responses: + '201': + description: Created. + content: + application/json: + schema: + type: object + properties: + item: + $ref: '#/components/schemas/Artifact' + '400': + description: 'Error `{error:{code,message}}`: 400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.' + /api/artifacts/skill: + post: + tags: + - Artifacts + summary: Share an existing skill + operationId: addArtifactSkill + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + type: string + laneId: + type: integer + title: + type: string + description: + type: string + responses: + '201': + description: Created. + content: + application/json: + schema: + type: object + properties: + item: + $ref: '#/components/schemas/Artifact' + '400': + description: 'Error `{error:{code,message}}`: 400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.' + /api/artifacts/skill/upload: + post: + tags: + - Artifacts + summary: 'Skills from files: a file, a folder, a .zip/.skill or several skills' + operationId: uploadArtifactSkills + description: '`paths` is a JSON array of each file''s relative path. Zip sizes are read before inflating; nothing is stored if any skill fails.' + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - files + properties: + files: + type: array + items: + type: string + format: binary + paths: + type: string + name: + type: string + title: + type: string + description: + type: string + responses: + '201': + description: Created. + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/Artifact' + '400': + description: 'Error `{error:{code,message}}`: 400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.' + /api/artifacts/skill/new: + post: + tags: + - Artifacts + summary: Write a skill in the dialog + operationId: writeArtifactSkill + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + - description + - content + properties: + name: + type: string + description: + type: string + content: + type: string + title: + type: string + responses: + '201': + description: Created. + content: + application/json: + schema: + type: object + properties: + item: + $ref: '#/components/schemas/Artifact' + items: + type: array + items: + $ref: '#/components/schemas/Artifact' + '400': + description: 'Error `{error:{code,message}}`: 400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.' + /api/artifacts/plugin/inspect: + post: + tags: + - Artifacts + summary: Read a marketplace's plugin list + operationId: inspectArtifactMarket + description: Clones a user-named https link only to read `marketplace.json`; no credential is ever sent. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - market + properties: + market: + type: string + responses: + '200': + description: '`{market, name, description, plugins: [{name, key, description, version, category}]}`.' + '502': + description: 'Error `{error:{code,message}}`: 400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.' + /api/artifacts/plugin: + post: + tags: + - Artifacts + summary: Share a plugin from a marketplace + operationId: addArtifactPlugin + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - key + - market + properties: + key: + type: string + description: '`name@marketplace`.' + market: + type: string + title: + type: string + description: + type: string + responses: + '201': + description: Created. + content: + application/json: + schema: + type: object + properties: + item: + $ref: '#/components/schemas/Artifact' + '400': + description: 'Error `{error:{code,message}}`: 400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.' + /api/artifacts/{id}: + get: + tags: + - Artifacts + summary: One artifact + operationId: getArtifact + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: '`{item}`.' + content: + application/json: + schema: + type: object + properties: + item: + $ref: '#/components/schemas/Artifact' + '404': + description: 'Error `{error:{code,message}}`: 400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.' + patch: + tags: + - Artifacts + summary: Rename, describe, share or unshare (owner or admin) + operationId: updateArtifact + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [] + properties: + title: + type: string + description: + type: string + shared: + type: boolean + source: + type: string + description: Plugin only. + market: + type: string + description: Plugin only. + responses: + '200': + description: '`{item}`.' + content: + application/json: + schema: + type: object + properties: + item: + $ref: '#/components/schemas/Artifact' + '403': + description: 'Error `{error:{code,message}}`: 400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.' + '404': + description: 'Error `{error:{code,message}}`: 400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.' + delete: + tags: + - Artifacts + summary: Delete an artifact and its snapshot (owner or admin) + operationId: deleteArtifact + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: '`{ok: true}`.' + '403': + description: 'Error `{error:{code,message}}`: 400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.' + '404': + description: 'Error `{error:{code,message}}`: 400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.' + /api/artifacts/{id}/raw: + get: + tags: + - Artifacts + summary: One recorded file of a snapshot + operationId: getArtifactRaw + description: Always served under a `sandbox` CSP with `nosniff`; an unlisted type, or `download=1`, is an attachment. Only a file the snapshot recorded can be named. + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: path + in: query + required: true + schema: + type: string + - name: download + in: query + schema: + type: string + enum: + - '1' + responses: + '200': + description: The file bytes. + '400': + description: 'Error `{error:{code,message}}`: 400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.' + '404': + description: 'Error `{error:{code,message}}`: 400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.' + /api/artifacts/{id}/install: + post: + tags: + - Artifacts + summary: Install a shared skill or plugin into the caller's own account + operationId: installArtifact + description: A skill goes to the caller's `~/.claude/skills/` (never over an existing one without `replace`; the old one is moved aside). A plugin runs `claude plugin install --scope user` as the caller. + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [] + properties: + replace: + type: boolean + responses: + '200': + description: '`{installed, replaced?}`.' + '403': + description: 'Error `{error:{code,message}}`: 400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.' + '409': + description: 'Error `{error:{code,message}}`: 400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.' + '502': + description: 'Error `{error:{code,message}}`: 400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.' + /api/host: + get: + tags: + - Host + summary: The machine and each user's live share of it + operationId: getHost + description: 'Cached 3 s. `usage` is null off Linux. Server mode: admin/analyst only (403 EADMIN for a developer).' + responses: + '200': + description: '`{at, hostname, os, uptime, cpu, memory, disks, homesMount, gpus, usage}` — see docs/API.md.' + content: + application/json: + schema: + type: object + '500': + description: EHOST /api/settings/import: post: tags: diff --git a/package-lock.json b/package-lock.json index ff03a11c..d8444451 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agenthub", - "version": "2.8.11", + "version": "2.8.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agenthub", - "version": "2.8.11", + "version": "2.8.12", "hasInstallScript": true, "license": "UNLICENSED", "dependencies": { diff --git a/package.json b/package.json index a12577c5..8d29545e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agenthub", - "version": "2.8.11", + "version": "2.8.12", "bin": { "agenthub": "bin/agenthub.js" }, diff --git a/server/__tests__/api.test.js b/server/__tests__/api.test.js index d2a4ee21..187031d4 100644 --- a/server/__tests__/api.test.js +++ b/server/__tests__/api.test.js @@ -100,6 +100,9 @@ const EXPECTED_API_PATHS = [ "/api/webhooks/{id}", "/api/webhooks/{id}/test", "/api/webhooks/{id}/deliveries", + "/api/artifacts", + "/api/artifacts/{id}/install", + "/api/host", "/api/openapi.json", "/api/docs", "/api/redoc", diff --git a/server/__tests__/clone-queue.test.js b/server/__tests__/clone-queue.test.js new file mode 100644 index 00000000..d82981ee --- /dev/null +++ b/server/__tests__/clone-queue.test.js @@ -0,0 +1,132 @@ +/** + * @file Tests for `server/lib/clone-queue.js`: no more clones run at once + * than the limit, the rest start in arrival order as slots free, a failing + * clone frees its slot too, the per-clone thread share, and a tracked + * clone's progress: its queue position, git's --progress lines parsed, and + * shown only to its owner. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const { describe, it, afterEach } = require("node:test"); +const assert = require("node:assert/strict"); +const os = require("node:os"); + +const queue = require("../lib/clone-queue"); + +const saved = process.env.AGENTHUB_CLONE_CONCURRENCY; + +function gate() { + let open; + const p = new Promise((r) => (open = r)); + return { p, open }; +} + +describe("clone-queue", () => { + afterEach(() => { + if (saved === undefined) delete process.env.AGENTHUB_CLONE_CONCURRENCY; + else process.env.AGENTHUB_CLONE_CONCURRENCY = saved; + }); + + it("runs at most the limit at once, the rest in arrival order", async () => { + process.env.AGENTHUB_CLONE_CONCURRENCY = "2"; + const started = []; + const gates = [gate(), gate(), gate(), gate()]; + const runs = gates.map((g, i) => + queue.withCloneSlot(async () => { + started.push(i); + await g.p; + return i; + }) + ); + await new Promise((r) => setImmediate(r)); + assert.deepEqual(started, [0, 1]); + assert.deepEqual(queue.cloneQueueState(), { running: 2, waiting: 2, limit: 2 }); + gates[1].open(); + await runs[1]; + await new Promise((r) => setImmediate(r)); + assert.deepEqual(started, [0, 1, 2]); + gates[0].open(); + gates[2].open(); + gates[3].open(); + assert.deepEqual(await Promise.all(runs), [0, 1, 2, 3]); + assert.deepEqual(queue.cloneQueueState(), { running: 0, waiting: 0, limit: 2 }); + }); + + it("frees the slot when a clone fails", async () => { + process.env.AGENTHUB_CLONE_CONCURRENCY = "1"; + await assert.rejects( + queue.withCloneSlot(async () => { + throw new Error("boom"); + }), + /boom/ + ); + assert.equal(await queue.withCloneSlot(async () => "ok"), "ok"); + assert.equal(queue.cloneQueueState().running, 0); + }); + + it("splits the cores between the running clones", () => { + const cores = os.availableParallelism?.() ?? os.cpus().length; + process.env.AGENTHUB_CLONE_CONCURRENCY = "1"; + assert.equal(queue.threads(), Math.max(1, cores)); + process.env.AGENTHUB_CLONE_CONCURRENCY = String(cores * 4); + assert.equal(queue.threads(), 1); + delete process.env.AGENTHUB_CLONE_CONCURRENCY; + assert.equal(queue.concurrency(), Math.max(1, Math.floor(cores / 2))); + }); + + it("reports a tracked clone's queue position, then git's progress, to its owner only", async () => { + process.env.AGENTHUB_CLONE_CONCURRENCY = "1"; + const first = gate(); + const a = queue.track("tok-aaaaaaaaaaaaaaaa", 7); + const b = queue.track("tok-bbbbbbbbbbbbbbbb", 7); + const ra = queue.withCloneSlot(() => first.p, a); + let release; + const rb = queue.withCloneSlot(() => new Promise((r) => (release = r)), b); + await new Promise((r) => setImmediate(r)); + assert.equal(queue.progress("tok-aaaaaaaaaaaaaaaa", 7).state, "running"); + const waitingView = queue.progress("tok-bbbbbbbbbbbbbbbb", 7); + assert.equal(waitingView.state, "queued"); + assert.equal(waitingView.position, 1); + assert.equal(waitingView.running, 1); + // Another user — or nobody — never sees it. + assert.equal(queue.progress("tok-bbbbbbbbbbbbbbbb", 8), null); + assert.equal(queue.progress("tok-cccccccccccccccc", 7), null); + + queue.report( + a, + "Cloning into 'r'...\nremote: Counting objects: 100% (5/5), done.\n" + + "Receiving objects: 12% (120/1000), 3.00 MiB | 1.50 MiB/s\rReceiving objects: 45% (450/1000), 120.50 MiB | 5.20 MiB/s" + ); + const v = queue.progress("tok-aaaaaaaaaaaaaaaa", 7); + assert.equal(v.phase, "receiving"); + assert.equal(v.percent, 45); + assert.equal(v.detail, "450/1000, 120.50 MiB | 5.20 MiB/s"); + queue.report(a, "Resolving deltas: 100% (300/300), done.\n"); + assert.equal(queue.progress("tok-aaaaaaaaaaaaaaaa", 7).phase, "resolving"); + assert.equal(queue.progress("tok-aaaaaaaaaaaaaaaa", 7).detail, "300/300"); + + first.open(); + await ra; + queue.finish(a, true); + assert.equal(queue.progress("tok-aaaaaaaaaaaaaaaa", 7).state, "done"); + await new Promise((r) => setImmediate(r)); + assert.equal(queue.progress("tok-bbbbbbbbbbbbbbbb", 7).state, "running"); + release(); + await rb; + queue.finish(b, false); + assert.equal(queue.progress("tok-bbbbbbbbbbbbbbbb", 7).state, "failed"); + }); + + it("refuses a malformed or reused token, and keeps progress out of an error", () => { + assert.throws(() => queue.track("short", null), { code: "EBADFIELD" }); + assert.throws(() => queue.track("../../etc/passwd-xxxxxxxx", null), { code: "EBADFIELD" }); + queue.track("tok-dddddddddddddddd", null); + assert.throws(() => queue.track("tok-dddddddddddddddd", null), { code: "EBADFIELD" }); + assert.equal( + queue.stripProgress( + "Cloning into 'r'...\nReceiving objects: 45% (450/1000)\rfatal: early EOF\n" + ), + "Cloning into 'r'...\nfatal: early EOF" + ); + }); +}); diff --git a/server/__tests__/git-clone.test.js b/server/__tests__/git-clone.test.js index cbd1e6f4..460d7fe3 100644 --- a/server/__tests__/git-clone.test.js +++ b/server/__tests__/git-clone.test.js @@ -64,6 +64,8 @@ before(async () => { "init", ]); execFileSync("git", ["clone", "-q", "--bare", src, bare]); + // A forge serves a partial clone only when it allows filters. + execFileSync("git", ["-C", bare, "config", "uploadpack.allowFilter", "true"]); remote = http.createServer((_req, res) => { res.writeHead(401, { "WWW-Authenticate": 'Basic realm="t"' }); @@ -183,6 +185,61 @@ describe("POST /api/lanes/clone", () => { assert.equal(r.body.error.code, "ENOREPO"); }); + it("makes a blobless clone only when asked, and refuses a non-boolean", async () => { + const r = await request("POST", "/api/lanes/clone", { + url: "https://forge.test/o/r.git", + parent: WORK, + name: "r-partial", + partial: true, + }); + assert.equal(r.status, 201, JSON.stringify(r.body)); + const dir = path.join(WORK, "r-partial"); + assert.equal(fs.readFileSync(path.join(dir, "README.md"), "utf8"), "hi\n"); + const cfg = (k) => + execFileSync("git", ["-C", dir, "config", "--get", k], { encoding: "utf8" }).trim(); + assert.equal(cfg("remote.origin.partialclonefilter"), "blob:none"); + // The full clone above is not a promisor remote. + assert.throws(() => + execFileSync("git", ["-C", path.join(WORK, "r"), "config", "--get", "remote.origin.promisor"]) + ); + const bad = await request("POST", "/api/lanes/clone", { + url: "https://forge.test/o/r.git", + parent: WORK, + name: "r-bad", + partial: "yes", + }); + assert.equal(bad.status, 400); + assert.equal(bad.body.error.code, "EBADFIELD"); + }); + + it("reports a clone's progress by its token, and refuses a bad token", async () => { + const token = "progress-test-0000001"; + const r = await request("POST", "/api/lanes/clone", { + url: "https://forge.test/o/r.git", + parent: WORK, + name: "r-progress", + progressToken: token, + }); + assert.equal(r.status, 201, JSON.stringify(r.body)); + const p = await request("GET", `/api/lanes/clone/progress/${token}`); + assert.equal(p.status, 200); + assert.equal(p.body.state, "done"); + assert.equal(p.body.position, null); + assert.equal( + (await request("GET", "/api/lanes/clone/progress/nope-nope-nope-nope")).status, + 404 + ); + const bad = await request("POST", "/api/lanes/clone", { + url: "https://forge.test/o/r.git", + parent: WORK, + name: "r-badtoken", + progressToken: "x", + }); + assert.equal(bad.status, 400); + assert.equal(bad.body.error.code, "EBADFIELD"); + assert.ok(!fs.existsSync(path.join(WORK, "r-badtoken"))); + }); + it("refuses a cross-origin request", async () => { const r = await request( "POST", @@ -649,6 +706,8 @@ describe("git-identity, member account (S3, stubbed)", () => { /^core\.sshCommand=ssh -o IdentityFile='.*\/id_ed25519' -o IdentityFile='.*\/id_rsa' -o IdentitiesOnly=yes$/ ); assert.deepEqual(args.slice(-3), ["--", "git@gh.test:o/r.git", "r"]); + // index-pack gets a share of the cores, never persisted into the clone. + assert.match(args.slice(0, i).join(" "), /-c pack\.threads=\d+/); }); it("gives a member's fetch their own store, and puts a typed login in it only for the call", () => { diff --git a/server/lib/clone-queue.js b/server/lib/clone-queue.js new file mode 100644 index 00000000..d1143733 --- /dev/null +++ b/server/lib/clone-queue.js @@ -0,0 +1,182 @@ +/** + * @file The machine-wide limit on `git clone`s running at once, and each + * clone's progress. A clone's index-pack resolves deltas on every core it + * finds, so a few big (~1 GB) clones started together pinned all of them and + * stalled the dashboard and every pane beside it. Clones past the limit wait + * here, in arrival order, and each one gets its share of the cores + * (`threads`) — the clone's own timeout starts only once it runs. + * `AGENTHUB_CLONE_CONCURRENCY` overrides the default (half the cores, at + * least 1). A clone the browser named with a token (`track`) reports its + * place in the queue, then git's own `--progress` lines (`report`), to + * `GET /api/lanes/clone/progress/:token` — only to the user who started it. + * @author Nguyễn Ngọc Trí Vĩ + */ + +"use strict"; + +const os = require("node:os"); + +/** A finished clone's progress stays readable this long, then is dropped. */ +const KEEP_DONE_MS = 60_000; +const TOKEN_RE = /^[A-Za-z0-9_-]{16,64}$/; + +function cores() { + return Math.max(1, (os.availableParallelism?.() ?? os.cpus().length) || 1); +} + +/** How many clones may run at once. */ +function concurrency() { + const set = Number.parseInt(process.env.AGENTHUB_CLONE_CONCURRENCY, 10); + if (Number.isInteger(set) && set > 0) return set; + return Math.max(1, Math.floor(cores() / 2)); +} + +/** index-pack threads for one clone: the cores split between the running ones. */ +function threads() { + return Math.max(1, Math.floor(cores() / concurrency())); +} + +let running = 0; +/** `{start, job}` in arrival order; `job` is null for an untracked clone. */ +const waiting = []; +/** token → job: `{owner, state, phase, label, percent, detail, finishedAt}`. */ +const jobs = new Map(); + +function next() { + while (running < concurrency() && waiting.length) { + running++; + const { start, job } = waiting.shift(); + if (job) job.state = "running"; + start(); + } +} + +/** Runs `fn` once a clone slot is free; resolves or rejects with it. */ +async function withCloneSlot(fn, job = null) { + await new Promise((start) => { + waiting.push({ start, job }); + next(); + }); + try { + return await fn(); + } finally { + running--; + next(); + } +} + +/** `{running, waiting, limit}` — for a status read or a test. */ +function cloneQueueState() { + return { running, waiting: waiting.length, limit: concurrency() }; +} + +function sweep(now = Date.now()) { + for (const [token, job] of jobs) { + if (job.finishedAt && now - job.finishedAt > KEEP_DONE_MS) jobs.delete(token); + } +} + +/** + * A progress record for the clone the browser named `token`, owned by + * `owner` (`scope.ownerFor`). Throws `EBADFIELD` for a malformed token or one + * still in use. + */ +function track(token, owner) { + sweep(); + if (typeof token !== "string" || !TOKEN_RE.test(token) || jobs.has(token)) { + throw Object.assign(new Error("progressToken: 16–64 of [A-Za-z0-9_-], unused"), { + code: "EBADFIELD", + }); + } + const job = { + owner, + state: "queued", + phase: null, + label: null, + percent: null, + detail: null, + finishedAt: null, + }; + jobs.set(token, job); + return job; +} + +/** Marks a tracked clone over: `done` or `failed`. */ +function finish(job, ok) { + if (!job) return; + job.state = ok ? "done" : "failed"; + job.finishedAt = Date.now(); +} + +/** git's progress words → a phase the UI translates; anything else is `other`. */ +const PHASES = [ + [/^remote: /, "remote"], + [/^Receiving objects/, "receiving"], + [/^Resolving deltas/, "resolving"], + [/^Updating files/, "checkout"], +]; +const LINE_RE = /^((?:remote: )?[A-Za-z][A-Za-z ]*?):\s+(\d{1,3})% \(([^)]*)\)(.*)$/; + +/** + * Feeds a chunk of `git clone --progress` stderr into `job`: the LAST + * complete progress line in it wins (git redraws a line with `\r`). + */ +function report(job, chunk) { + if (!job) return; + const lines = String(chunk).split(/[\r\n]+/); + for (let i = lines.length - 1; i >= 0; i--) { + const m = LINE_RE.exec(lines[i].trim()); + if (!m) continue; + job.label = m[1].replace(/^remote: /, ""); + job.phase = (PHASES.find(([re]) => re.test(m[1])) || [null, "other"])[1]; + job.percent = Math.min(100, Number(m[2])); + // "1234/5678), 120.50 MiB | 5.20 MiB/s, done." → counts + size + speed + job.detail = + `${m[3]}${m[4]}` + .replace(/,\s*done\.?$/, "") + .replace(/^,\s*/, "") + .trim() || null; + return; + } +} + +/** + * What `GET /api/lanes/clone/progress/:token` answers, or null when there is + * no such clone or it is not `owner`'s. A queued clone carries its 1-based + * `position` among the waiting ones. + */ +function progress(token, owner) { + sweep(); + const job = jobs.get(token); + if (!job || job.owner !== owner) return null; + const position = waiting.findIndex((w) => w.job === job); + return { + state: job.state, + position: position >= 0 ? position + 1 : null, + ...cloneQueueState(), + phase: job.phase, + label: job.label, + percent: job.percent, + detail: job.detail, + }; +} + +/** A progress-free tail of git's stderr for an error message. */ +function stripProgress(stderr) { + return String(stderr || "") + .split(/[\r\n]+/) + .filter((l) => l.trim() && !LINE_RE.test(l.trim())) + .join("\n"); +} + +module.exports = { + withCloneSlot, + cloneQueueState, + concurrency, + threads, + track, + finish, + report, + progress, + stripProgress, +}; diff --git a/server/lib/git-identity.js b/server/lib/git-identity.js index a768886a..dbc71713 100644 --- a/server/lib/git-identity.js +++ b/server/lib/git-identity.js @@ -44,8 +44,8 @@ const gitCredentials = require("./git-credentials"); const runAs = require("./run-as"); const HOST_RE = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:\d{1,5})?$/; -/** A clone may take a while on a big repo; past this it is killed. */ -const CLONE_TIMEOUT_MS = 10 * 60_000; +/** A clone may take a while on a big repo (~1 GB); past this it is killed. */ +const CLONE_TIMEOUT_MS = 30 * 60_000; const HELPER = path.join(__dirname, "git-credential-helper.js"); const err = (code, message) => Object.assign(new Error(message), { code }); @@ -841,15 +841,33 @@ function sshFailure(stderr) { * Clone `url` into `/` as the account owning `parent`, with * that account's own identity. `once` is a login typed for this clone * (`{username, password, remember}`) — https only, for this URL's host only. + * `partial` makes it a blobless clone (`--filter=blob:none`): every commit + * and tree, but a file's contents only when a checkout first needs them — + * far less to download and unpack for a big repo, at the cost of a fetch + * from the remote when an old revision is checked out later. * Resolves `{dir, account}`; rejects with `ECREDENTIALS` (`{protocol, host}` * attached) when git wanted a login it did not get, `ESSHAUTH` when the key * was refused, else the git error. */ -async function cloneRepo({ url, parent, name, once = null }) { +function cloneRepo({ job = null, ...opts }) { + // Waits for a free clone slot (clone-queue.js) before anything — a typed + // login sits in a member's store only while its own clone runs, and the + // timeout counts from there. `job` (clone-queue `track`) gets the queue + // position and git's progress. + const queue = require("./clone-queue"); + return queue.withCloneSlot( + () => cloneNow(opts, queue.threads(), (chunk) => queue.report(job, chunk)), + job + ); +} + +async function cloneNow({ url, parent, name, once = null, partial = false }, threads, onProgress) { const { git } = require("./worktree"); const account = accountForDir(parent); const remote = remoteOf(url); - const pre = []; + // 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}`]; const persist = []; const env = {}; let restore = null; @@ -911,9 +929,13 @@ async function cloneRepo({ url, parent, name, once = null }) { let ok = false; try { - await git(parent, [...pre, "clone", ...persist, "--", url, name], { + const filter = partial ? ["--filter=blob:none"] : []; + // --progress: git prints it to a pipe too, for the UI's progress line. + await git(parent, [...pre, "clone", ...persist, "--progress", ...filter, "--", url, name], { timeout: CLONE_TIMEOUT_MS, env, + background: true, + onStderr: onProgress, }); ok = true; } catch (e) { @@ -932,9 +954,7 @@ async function cloneRepo({ url, parent, name, once = null }) { } throw err( "ECLONE", - String(stderr || e.message) - .trim() - .slice(-800) + (require("./clone-queue").stripProgress(stderr) || e.message).trim().slice(-800) ); } finally { // A login typed without "remember" — or one that did not work — leaves diff --git a/server/lib/worktree.js b/server/lib/worktree.js index b2336780..60c5eb88 100644 --- a/server/lib/worktree.js +++ b/server/lib/worktree.js @@ -19,6 +19,26 @@ const PROTECTED_BRANCHES = new Set(["main", "master"]); /** Most status lines `statusCounts` reports by name; the counts stay exact. */ const STATUS_FILE_CAP = 20; +/** + * `nice`/`ionice` before a heavy git (a clone) so the dashboard and the panes + * beside it keep their share of the machine; children (index-pack, sudo's + * git) inherit both. Empty where neither exists (Windows). + */ +let lowPriorityPrefix = null; +function lowPriority() { + if (lowPriorityPrefix) return lowPriorityPrefix; + const find = (name) => + ["/usr/bin", "/bin"].map((d) => path.join(d, name)).find((f) => fs.existsSync(f)); + const nice = process.platform === "win32" ? null : find("nice"); + const ionice = process.platform === "linux" ? find("ionice") : null; + lowPriorityPrefix = [ + ...(nice ? [nice, "-n", "10"] : []), + // best-effort class, lowest level: never the idle class, which can wait forever + ...(ionice ? [ionice, "-c", "2", "-n", "7"] : []), + ]; + return lowPriorityPrefix; +} + /** * Promisified git wrapper. On failure, throws an Error with err.git = { args, code, stderr }. * Treats zero exit code as success even if stderr has hints. @@ -42,6 +62,10 @@ async function git( encoding = "utf8", // stdin (a patch for `apply -`): never a file the dashboard writes. input = null, + // true: run under nice/ionice (a clone — see lowPriority above). + background = false, + // Called with each stderr chunk as it arrives (a clone's --progress). + onStderr = null, } = {} ) { // Build a clean environment: copy process.env but scrub git hook variables @@ -88,6 +112,12 @@ async function git( if (literal) pass.GIT_LITERAL_PATHSPECS = "1"; [cmd, argv] = require("./run-as").sudoArgv(member, "git", args, pass); } + const gitCmd = cmd; + if (background && lowPriority().length) { + const [first, ...rest] = lowPriority(); + argv = [...rest, cmd, ...argv]; + cmd = first; + } try { const run = @@ -100,7 +130,7 @@ async function git( ); child.stdin.end(input); }); - const result = await run(cmd, argv, { + const pending = run(cmd, argv, { cwd, env, // A read with its own size cap passes a bigger buffer and treats an @@ -113,12 +143,23 @@ async function git( timeout, encoding, }); + // promisify(execFile) hands back its child; the stdin runner has none. + if (onStderr && pending.child?.stderr) { + pending.child.stderr.on("data", (d) => { + try { + onStderr(String(d)); + } catch { + /* a progress reader never fails the git call */ + } + }); + } + const result = await pending; return { stdout: result.stdout, stderr: result.stderr }; } catch (err) { // ENOENT from execFile is ambiguous: a missing binary OR a missing cwd. // With the folder present it is git itself that is absent — say so, with // the fix, rather than `git status failed` (like ETMUXMISSING for tmux). - if (err.code === "ENOENT" && cmd === "git" && fs.existsSync(cwd)) { + if (err.code === "ENOENT" && gitCmd === "git" && fs.existsSync(cwd)) { const error = new Error( "git is not installed (or not on the dashboard's PATH), and every git action needs it. " + "Install it (sudo apt install git / brew install git / https://git-scm.com/download/win), " + diff --git a/server/openapi-extra.js b/server/openapi-extra.js index e54afc34..22772ce6 100644 --- a/server/openapi-extra.js +++ b/server/openapi-extra.js @@ -19,6 +19,7 @@ const aiTools = require("./openapi-extra/ai-tools"); const misc = require("./openapi-extra/misc"); const auth = require("./openapi-extra/auth"); const files = require("./openapi-extra/files"); +const artifacts = require("./openapi-extra/artifacts"); // Enriched overrides of already-documented endpoints — same operationId, tags, // and request/response `$ref` schemas as the base, with added examples and // richer descriptions. Listed last so they win the merge. @@ -56,6 +57,7 @@ module.exports = combine( misc, auth, files, + artifacts, overrideSessionsAgents, overrideCore, overridePricingAlerts, diff --git a/server/openapi-extra/artifacts.js b/server/openapi-extra/artifacts.js new file mode 100644 index 00000000..69b0eddb --- /dev/null +++ b/server/openapi-extra/artifacts.js @@ -0,0 +1,359 @@ +/** + * @file OpenAPI fragment for the Artifacts hub (`/api/artifacts/*`, server/routes/artifacts.js) + * and the Administration page's Server tab (`GET /api/host`, server/routes/host.js). + * Hand-written from the routes; docs/API.md § Artifacts hub is the long form. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const tags = [ + { + name: "Artifacts", + description: + "What agents produced, shared as a SNAPSHOT: a claude.ai page link, a pinned or uploaded file, a skill or a plugin. A viewer reads only the snapshot copy (sandbox CSP, nosniff); an install writes into the caller's own account only. Every write is same-origin.", + }, + { + name: "Host", + description: + "Administration → Server: the machine's hardware, load, disks and each user's share of CPU and memory. Read-only, staff only in server mode.", + }, +]; + +const errorCodes = + "400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN."; + +const schemas = { + Artifact: { + type: "object", + properties: { + id: { type: "string" }, + kind: { type: "string", enum: ["page", "file", "skill", "plugin"] }, + title: { type: "string" }, + description: { type: "string", nullable: true }, + ownerId: { type: "integer", nullable: true }, + owner: { type: "string", nullable: true, description: "Display name or username." }, + mine: { type: "boolean" }, + canChange: { type: "boolean" }, + laneId: { type: "integer", nullable: true }, + sessionId: { type: "string", nullable: true }, + source: { type: "string", nullable: true, description: "Plugin: `name@marketplace`." }, + market: { type: "string", nullable: true, description: "Plugin: owner/repo, git URL or marketplace.json URL." }, + url: { type: "string", nullable: true, description: "Page: a https://claude.ai/… link." }, + files: { type: "array", items: { type: "object" }, description: "The files the snapshot recorded." }, + size: { type: "integer", nullable: true }, + shared: { type: "boolean" }, + sharedAt: { type: "string", nullable: true }, + installs: { type: "integer" }, + createdAt: { type: "string" }, + updatedAt: { type: "string" }, + }, + }, +}; + +const json = (properties, required = []) => ({ + required: true, + content: { "application/json": { schema: { type: "object", required, properties } } }, +}); +const multipart = (properties, required = []) => ({ + required: true, + content: { "multipart/form-data": { schema: { type: "object", required, properties } } }, +}); +const item = { + type: "object", + properties: { item: { $ref: "#/components/schemas/Artifact" } }, +}; +const items = { + type: "object", + properties: { items: { type: "array", items: { $ref: "#/components/schemas/Artifact" } } }, +}; +const idParam = { name: "id", in: "path", required: true, schema: { type: "string" } }; +const laneQuery = { name: "laneId", in: "query", required: false, schema: { type: "integer" } }; +const errs = { description: `Error \`{error:{code,message}}\`: ${errorCodes}` }; +const created = (schema, description = "Created.") => ({ + 201: { description, content: { "application/json": { schema } } }, + 400: errs, +}); + +const paths = { + "/api/artifacts": { + get: { + tags: ["Artifacts"], + summary: "List artifacts", + operationId: "listArtifacts", + description: + "A developer sees their own rows plus shared ones; staff see every row in `hub` scope.", + parameters: [ + { name: "scope", in: "query", schema: { type: "string", enum: ["mine", "hub"] } }, + { name: "kind", in: "query", schema: { type: "string", enum: ["page", "file", "skill", "plugin"] } }, + { name: "q", in: "query", schema: { type: "string" } }, + laneQuery, + { name: "sessionId", in: "query", schema: { type: "string" } }, + ], + responses: { 200: { description: "`{items}`.", content: { "application/json": { schema: items } } } }, + }, + }, + "/api/artifacts/settings": { + get: { + tags: ["Artifacts"], + summary: "Hub switches and what the caller may do", + operationId: "getArtifactSettings", + responses: { + 200: { + description: "`{serverMode, canShare, installsEnabled, canManage}`.", + content: { + "application/json": { + schema: { + type: "object", + properties: { + serverMode: { type: "boolean" }, + canShare: { type: "boolean" }, + installsEnabled: { type: "boolean" }, + canManage: { type: "boolean" }, + }, + }, + }, + }, + }, + }, + }, + put: { + tags: ["Artifacts"], + summary: "Turn installs on or off (admin; any caller in local mode)", + operationId: "setArtifactSettings", + description: "Audited in server mode (Settings → AI tools → Artifacts Hub).", + requestBody: json({ installsEnabled: { type: "boolean" } }, ["installsEnabled"]), + responses: { + 200: { description: "`{installsEnabled}`." }, + 400: { description: "EBADFIELD" }, + 403: { description: "EADMIN / EBADORIGIN" }, + }, + }, + }, + "/api/artifacts/candidates": { + get: { + tags: ["Artifacts"], + summary: "Documents a workspace's sessions wrote (to pin)", + operationId: "listArtifactCandidates", + parameters: [laneQuery], + responses: { + 200: { description: "`{items: [{path, sessionId, at, exists, pinned}]}`." }, + 404: errs, + }, + }, + }, + "/api/artifacts/skills": { + get: { + tags: ["Artifacts"], + summary: "Skills the caller can share (user + workspace)", + operationId: "listArtifactSkills", + parameters: [laneQuery], + responses: { 200: { description: '`{items: [{name, from: "user"|"workspace"}]}`.' } }, + }, + }, + "/api/artifacts/page": { + post: { + tags: ["Artifacts"], + summary: "Add a claude.ai page link", + operationId: "addArtifactPage", + description: "Only `https://claude.ai/…` is accepted; one row per owner and link.", + requestBody: json( + { url: { type: "string" }, title: { type: "string" }, description: { type: "string" } }, + ["url"] + ), + responses: created(item), + }, + }, + "/api/artifacts/file": { + post: { + tags: ["Artifacts"], + summary: "Pin a workspace file (snapshotted)", + operationId: "pinArtifactFile", + description: + "Read under the download route's guards (`lanePath`, `resolveInLane`, `ESECRET`, ≤ 25 MB) and copied once.", + requestBody: json( + { + laneId: { type: "integer" }, + path: { type: "string" }, + title: { type: "string" }, + sessionId: { type: "string" }, + }, + ["laneId", "path"] + ), + responses: created(item), + }, + }, + "/api/artifacts/file/upload": { + post: { + tags: ["Artifacts"], + summary: "Keep one uploaded file (≤ 25 MB)", + operationId: "uploadArtifactFile", + requestBody: multipart({ + files: { type: "string", format: "binary" }, + title: { type: "string" }, + description: { type: "string" }, + }, ["files"]), + responses: created(item), + }, + }, + "/api/artifacts/skill": { + post: { + tags: ["Artifacts"], + summary: "Share an existing skill", + operationId: "addArtifactSkill", + requestBody: json( + { + name: { type: "string" }, + laneId: { type: "integer" }, + title: { type: "string" }, + description: { type: "string" }, + }, + ["name"] + ), + responses: created(item), + }, + }, + "/api/artifacts/skill/upload": { + post: { + tags: ["Artifacts"], + summary: "Skills from files: a file, a folder, a .zip/.skill or several skills", + operationId: "uploadArtifactSkills", + description: + "`paths` is a JSON array of each file's relative path. Zip sizes are read before inflating; nothing is stored if any skill fails.", + requestBody: multipart({ + files: { type: "array", items: { type: "string", format: "binary" } }, + paths: { type: "string" }, + name: { type: "string" }, + title: { type: "string" }, + description: { type: "string" }, + }, ["files"]), + responses: created(items), + }, + }, + "/api/artifacts/skill/new": { + post: { + tags: ["Artifacts"], + summary: "Write a skill in the dialog", + operationId: "writeArtifactSkill", + requestBody: json( + { + name: { type: "string" }, + description: { type: "string" }, + content: { type: "string" }, + title: { type: "string" }, + }, + ["name", "description", "content"] + ), + responses: created({ type: "object", properties: { item: item.properties.item, items: items.properties.items } }), + }, + }, + "/api/artifacts/plugin/inspect": { + post: { + tags: ["Artifacts"], + summary: "Read a marketplace's plugin list", + operationId: "inspectArtifactMarket", + description: "Clones a user-named https link only to read `marketplace.json`; no credential is ever sent.", + requestBody: json({ market: { type: "string" } }, ["market"]), + responses: { + 200: { description: "`{market, name, description, plugins: [{name, key, description, version, category}]}`." }, + 502: errs, + }, + }, + }, + "/api/artifacts/plugin": { + post: { + tags: ["Artifacts"], + summary: "Share a plugin from a marketplace", + operationId: "addArtifactPlugin", + requestBody: json( + { + key: { type: "string", description: "`name@marketplace`." }, + market: { type: "string" }, + title: { type: "string" }, + description: { type: "string" }, + }, + ["key", "market"] + ), + responses: created(item), + }, + }, + "/api/artifacts/{id}": { + get: { + tags: ["Artifacts"], + summary: "One artifact", + operationId: "getArtifact", + parameters: [idParam], + responses: { 200: { description: "`{item}`.", content: { "application/json": { schema: item } } }, 404: errs }, + }, + patch: { + tags: ["Artifacts"], + summary: "Rename, describe, share or unshare (owner or admin)", + operationId: "updateArtifact", + parameters: [idParam], + requestBody: json({ + title: { type: "string" }, + description: { type: "string" }, + shared: { type: "boolean" }, + source: { type: "string", description: "Plugin only." }, + market: { type: "string", description: "Plugin only." }, + }), + responses: { 200: { description: "`{item}`.", content: { "application/json": { schema: item } } }, 403: errs, 404: errs }, + }, + delete: { + tags: ["Artifacts"], + summary: "Delete an artifact and its snapshot (owner or admin)", + operationId: "deleteArtifact", + parameters: [idParam], + responses: { 200: { description: "`{ok: true}`." }, 403: errs, 404: errs }, + }, + }, + "/api/artifacts/{id}/raw": { + get: { + tags: ["Artifacts"], + summary: "One recorded file of a snapshot", + operationId: "getArtifactRaw", + description: + "Always served under a `sandbox` CSP with `nosniff`; an unlisted type, or `download=1`, is an attachment. Only a file the snapshot recorded can be named.", + parameters: [ + idParam, + { name: "path", in: "query", required: true, schema: { type: "string" } }, + { name: "download", in: "query", schema: { type: "string", enum: ["1"] } }, + ], + responses: { 200: { description: "The file bytes." }, 400: errs, 404: errs }, + }, + }, + "/api/artifacts/{id}/install": { + post: { + tags: ["Artifacts"], + summary: "Install a shared skill or plugin into the caller's own account", + operationId: "installArtifact", + description: + "A skill goes to the caller's `~/.claude/skills/` (never over an existing one without `replace`; the old one is moved aside). A plugin runs `claude plugin install --scope user` as the caller.", + parameters: [idParam], + requestBody: json({ replace: { type: "boolean" } }), + responses: { + 200: { description: "`{installed, replaced?}`." }, + 403: errs, + 409: errs, + 502: errs, + }, + }, + }, + "/api/host": { + get: { + tags: ["Host"], + summary: "The machine and each user's live share of it", + operationId: "getHost", + description: + "Cached 3 s. `usage` is null off Linux. Server mode: admin/analyst only (403 EADMIN for a developer).", + responses: { + 200: { + description: + "`{at, hostname, os, uptime, cpu, memory, disks, homesMount, gpus, usage}` — see docs/API.md.", + content: { "application/json": { schema: { type: "object" } } }, + }, + 500: { description: "EHOST" }, + }, + }, + }, +}; + +module.exports = { tags, schemas, paths }; diff --git a/server/openapi-extra/files.js b/server/openapi-extra/files.js index 6ed28002..c950b986 100644 --- a/server/openapi-extra/files.js +++ b/server/openapi-extra/files.js @@ -253,12 +253,40 @@ const paths = { remember: { type: "boolean" }, }, }, + partial: { + type: "boolean", + description: + "Blobless clone (`--filter=blob:none`): file contents fetched on checkout.", + }, + progressToken: { + type: "string", + pattern: "^[A-Za-z0-9_-]{16,64}$", + description: + "The caller's name for this clone, to poll `/api/lanes/clone/progress/{token}`.", + }, }, ["url", "parent"] ), responses: { 201: { description: "{lane, created: true, path}" } }, }, }, + "/api/lanes/clone/progress/{token}": { + get: { + tags: ["Git identity"], + summary: "A clone's place in the queue, then git's progress", + description: + "Clones run a few at a time machine-wide (`AGENTHUB_CLONE_CONCURRENCY`). Only the user who started the clone sees it; kept 60 s after it ends.", + operationId: "cloneProgress", + parameters: [{ name: "token", in: "path", required: true, schema: { type: "string" } }], + responses: { + 200: { + description: + "{state: queued|running|done|failed, position, running, waiting, limit, phase: remote|receiving|resolving|checkout|other|null, label, percent, detail}", + }, + 404: { description: "ENOTFOUND" }, + }, + }, + }, "/api/me/git": { get: { tags: ["Git identity"], diff --git a/server/routes/lanes.js b/server/routes/lanes.js index 9ed3a256..e7c94900 100644 --- a/server/routes/lanes.js +++ b/server/routes/lanes.js @@ -777,6 +777,10 @@ async function cloneInto(req, res, body, before) { res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } }); return null; } + if (body.partial !== undefined && typeof body.partial !== "boolean") { + res.status(400).json({ error: { code: "EBADFIELD", message: "partial: boolean" } }); + return null; + } const once = body.credentials; if (once != null && (typeof once !== "object" || Array.isArray(once))) { res.status(400).json({ error: { code: "EBADCRED", message: "credentials: object" } }); @@ -813,17 +817,32 @@ async function cloneInto(req, res, body, before) { return null; } const ownerId = scope.ownerFor(req); + // The browser's own name for this clone, so it can poll its progress. + const queue = require("../lib/clone-queue"); + let job = null; + if (body.progressToken !== undefined) { + try { + job = queue.track(body.progressToken, ownerId); + } catch (err) { + res.status(400).json({ error: { code: err.code, message: err.message } }); + return null; + } + } try { await before(target, ownerId); await identity.cloneRepo({ + job, url, parent: dir, name, once: once ? { username: once.username, password: once.password, remember: once.remember === true } : null, + partial: body.partial === true, }); + queue.finish(job, true); } catch (err) { + queue.finish(job, false); const status = ["EBADCRED", "EBADHOST", "EBADTOOL", "EDISABLED"].includes(err.code) ? 400 : ["ECREDENTIALS", "ESSHAUTH", "ENOREPO", "EOWNERFOLDER", "EMEMBERGONE"].includes(err.code) @@ -843,6 +862,19 @@ async function cloneInto(req, res, body, before) { return { target, name, ownerId }; } +/** + * A clone's place in the machine-wide queue, then git's own progress + * (`server/lib/clone-queue.js`), by the `progressToken` its POST carried — + * only to the user who started it (404 otherwise, and once it is long over). + */ +router.get("/clone/progress/:token", (req, res) => { + const view = require("../lib/clone-queue").progress(req.params.token, scope.ownerFor(req)); + if (!view) { + return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such clone" } }); + } + res.json(view); +}); + router.post("/clone", sameOriginGuard, async (req, res) => { const body = req.body || {}; // Before the clone: a workspace this user may not own there, or a tool diff --git a/server/routes/me.js b/server/routes/me.js index c385dff9..7262435e 100644 --- a/server/routes/me.js +++ b/server/routes/me.js @@ -57,6 +57,9 @@ const PREF_KEYS = [ "agent-monitor-notifications", "agenthub.defaultTool", "agenthub.permissionMode", + "agenthub.codex.sandbox", + "agenthub.codex.approval", + "agenthub.codex.bypass", "agenthub.tour", "agenthub.chatUi", "agenthub.workspace.allTabs",