diff --git a/CLAUDE.md b/CLAUDE.md index 90a661e5..c0254461 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v2.8.68 (2026-11-03) — S3: members' panes capped, Claude's commands die first on OOM, no tests from Claude, 4 AI sessions per member._ 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.68 (2026-11-03) — S3 panes capped (commands die first on OOM, no tests from Claude, 4 AI sessions per member); the pet no longer chats._ 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. @@ -48,7 +48,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which - **The dashboard's git writes are these forty-seven, and the guards are IN `git-ops.js` (and the `git-refs.js`, `git-rewrite.js`, `git-conflicts.js` and `git-pick.js` it dispatches to), 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`, `shelve`, `unshelve`, `shelf-drop`, the Branches panel's `branch-rename`, `branch-upstream`, `tag-create`, `tag-delete`, `tag-push` (`git-refs.js`), and the History tools' `conflict-save`, `rebase`, `rebase-continue`, `rebase-skip`, `rebase-abort`, `rebase-interactive`, `reset`, `undo-rewrite`, `force-push`, `remote-branch-delete`, `remote-add`, `remote-rename`, `remote-remove` (`git-rewrite.js`; `conflict-save` is `git-conflicts.js`'s), and the History tab's `pick-continue`, `pick-skip`, `pick-abort`, `undo-pick` (`git-pick.js`) — nothing else. **Branch & tag tools (`server/lib/git-refs.js`) hand git only a LOOKED-UP ref** — a `from`/`upstream`/tag found under `refs/heads`/`refs/remotes`/`refs/tags` (passed as its full refname) or a hex sha `rev-parse --verify ^{commit}` confirmed, never the request's text (`EBADREF`, `EAMBIGUOUS`); a new name passes `check-ref-format` (a tag may not start with `-`); rename/upstream/tag writes are repo-root only (`ESUBDIR`) and `EMERGING` during any operation; rename refuses the protected names like delete; a `branch-create` naming a `from` is git-refs.js's and its `switch: true` needs a clean tree (`EDIRTY`), while a plain `{branch, switch: true}` stays git-ops.js's Create & switch below; `tag-delete` needs `confirm: true`; `tag-push` pushes ONE tag `refs/tags/:refs/tags/` to `origin` (else the only remote), never `--tags`, never forced. Deleting a remote tag is not offered. **A history rewrite or remote write is allowed only because its recovery is a button too, and the server refuses what the UI only discourages:** every one runs from a repo-root lane (`ESUBDIR`), never while a merge/cherry-pick/revert/rebase is open (`EMERGING`), names the full HEAD sha its plan showed (`ESTALE`), and refuses to rewrite a commit any remote-tracking branch holds unless the body says `force: true` (`EPUSHED` — the UI shows that a force-push will be needed); each answers `before`/`after`, and `undo-rewrite` (`reset --keep `, `confirm: true`, only while HEAD is still `after`) is its Undo. `rebase` (a ref looked up by `branchRef`) and `rebase-interactive` need a clean tree; a stopped rebase is finished by `rebase-continue`/`-skip`/`-abort` (`confirm: true`) — `merge-commit`/`merge-abort` map onto it — and lets through only `ALLOWED_WHILE_REBASING` (fetch, stage, unstage, discard, ignore, resolve, conflict-save and its own steps); `operationInProgress` reports `rebase` first. `rebase-interactive` takes a todo that must list exactly `base..HEAD`'s commits by full sha (`EBADTODO`, no merge commit); git is run with `GIT_SEQUENCE_EDITOR` = `cp -- ` and `GIT_EDITOR=true`, the todo and the reword/squash messages are 0600 files AgentHub writes in the repo's own git dir (as the folder's account), and the only `exec` line is `git commit --amend -F ` — never put request text into a todo line or any shell string. `reset` is `--soft|--mixed|--keep|--hard` to an ancestor or descendant of HEAD (`ENOTRELATED`), `confirm: true`; `--hard` only on a CLEAN tree and with `confirmBranch` = the current branch (so nothing uncommitted can be lost) — the lane-destroying `reset` of `worktree.js`'s three-check guard is a different thing and stays there. `force-push` pushes HEAD only, to its upstream branch, with `--force-with-lease=:` (the sha the UI showed; a moved remote is `ESTALE`), `confirm: true`, and refuses `main`/`master`/`development`/`develop` unless the server sets `AGENTHUB_GIT_FORCE_PUSH_PROTECTED=1`. `remote-branch-delete` takes a fetched `/`, `confirm: true`, never a protected name nor the remote's default (`refs/remotes//HEAD`, `EDEFAULTBRANCH`). `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; `discard` takes the same `paths` + `hunks` selection (the Changes panel's **Discard (n)**, #87 — never every file) and undoes only the ticked blocks off the working tree; `ignore` appends an anchored `/path` line to the lane's OWN `.gitignore` for an untracked path only (`ETRACKED`), never through a symlink. **`publish` and `remote-add` are the only writes that take a destination from the request** (`{url}`), and both pass it through `validateRemoteUrl`'s allowlist; `remote-add` names a remote of letters, digits, `.`, `_`, `-` (`EBADREMOTE`), never re-points an existing one (`EREMOTEEXISTS`), and `remote-rename`/`remote-remove` (`confirm: true`) touch only a remote that exists. `publish` 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 `publish` touch a repo that already has a remote; a stored credential still goes only to its own host (the git-credentials rule below) whatever remote a user adds. **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`, `merge-abort`, `shelf-drop`, `tag-delete`, `rebase-abort`, `rebase-interactive`, `reset`, `undo-rewrite`, `force-push`, `remote-branch-delete`, `remote-remove`, `pick-skip`, `pick-abort` and `undo-pick` refuse to run without `confirm: true`; do not add another destructive action without the same gate. `branch-create` never switches unless `switch: true` (Create & switch: `git switch -c`, the working tree untouched so the changes come along; otherwise 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 unless the request says `changes: "carry"` (a plain `git switch`; a file it would overwrite is `ECARRYCONFLICT` with the list, never `--force`/`--merge`/`--discard-changes`) or `changes: "shelve"` (shelved first, the shelf re-applied at once if the switch fails), so neither can invent a merge commit or a conflict underneath a running agent (a `switch` to a branch only a remote has creates its tracking branch from a `refs/remotes` ref it LOOKED UP — `listRemoteBranches` — never one from the request); `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) and a repo inside the lane's folder (`?repo=`, accepted only when `server/lib/nested-repos.js`'s own disk walk found it — `404 ENOREPO` otherwise) are each their 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; with `shas: [...]` instead of `sha` — 1–50 non-merge commits, `git-pick.js` — they run as ONE git sequence in the order `GET /git/pick-plan` previews (a pick oldest→newest, a revert newest→oldest), a stop on a conflict is finished by `pick-continue` (`ECONFLICTS` while anything is unmerged), `pick-skip` or `pick-abort` (abort also ends a sequence whose *_HEAD is gone), none of them — nor `undo-pick` — is in `ALLOWED_WHILE_REBASING`, and the single-`sha` body stays git-ops.js's own path): 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`). **Stash is allowed ONLY as shelves, because their recovery is a button too** (the repo owner's call): `shelve`/`unshelve`/`shelf-drop` touch only stash entries whose message carries `agenthub-shelf: ` (any other stash is the user's — never listed, applied or dropped), address an entry by its commit `sha` (never `stash@{n}`, which a terminal stash shifts), list every shelf with Restore and Delete (`GET /git/shelves`), drop one ONLY after a clean `unshelve` or a `shelf-drop` with `confirm: true` (a conflicted restore keeps it and leaves the files to `resolve`), refuse a dirty tree for a restore (`EDIRTY`), run from a repo-root lane only (`ESUBDIR`) and never mid-merge/cherry-pick/revert/rebase (`EMERGING`); `shelve`, `unshelve` and a `switch` that carries or shelves changes answer `409 EAGENTRUNNING` until `confirm: true` while the lane's agent is working (`agentWorking` in `routes/lanes.js`). Do not add any stash use beyond shelves, nor a write whose recovery is not a button: every rewrite above keeps its `before` for `undo-rewrite`, and a stopped rebase its `rebase-abort`. `undo-pick` = `reset --soft ` takes back what a pick/revert just made (`confirm: true`, repo root, only while HEAD is still the `to` that pick produced — `ESTALE` —, `from` its ancestor 1–50 commits back with no merge between, none of them on a remote-tracking branch — `EPUSHED`, no override). `undo-commit` = `reset --soft HEAD~1` (`confirm: true`, repo root, never the root or a merge commit) 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`, no override); a rebase open in a terminal blocks every history action, shelves and the ref tools (`EMERGING`) but may be continued/skipped/aborted from the banner. Rewriting a pushed commit needs the request's explicit `force: true` and then a `force-push` with lease — do not relax either check. The 3-way merge editor reads `GET /git/conflict` (stages by blob id, only a path `ls-files -u` lists) and saves through `conflict-save` = `lane-file-write.js`'s guards then `git add`, `EMARKERS` before anything is written. A button that looks disabled in `LaneGitBar` is a courtesy; the refusal that matters is the server's. Reads (`/git`, `/git/changes`, `/git/log` — paged with `skip`, `all=1`, search by `q`/`author`/`path`, each commit with parents and ref badges —, `/git/commit`, `/git/patch` — a secret file's diff left out —, `/git/file-history` (`--follow`), `/git/blame-at`, `/git/tree-at` — a secret left out of the list —, `/git/compare`, `/git/pick-plan`, `/git/sequence` (`server/lib/git-history.js`, mounted by `server/routes/lane-git-history.js`, every revision resolved to a commit sha before git sees it, heavy walks timed out at 20 s; a rebase is never reported as a paused pick), `/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`, `/git/shelves`, `/git/refs`, `/git/conflict`, `/git/reflog`, `/git/remotes` — URLs with any password stripped —, `/git/rewrite-plan`) 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`), and so is commit signing (`PUT /api/me/git/signing`: `gpg.format ssh` + `user.signingkey` = one of the account's AgentHub key files + `commit.gpgsign`; a signing key the user set themselves only with `replace`). An imported key (`POST /api/me/git/ssh-key/import`) is written exactly like a generated one — the private text goes IN, only its public half comes back, a passphrase key is refused. Commit signing is PER WORKSPACE (`PUT /api/me/git/signing/lane`): one small file of ours per workspace (`lane-.gitconfig` beside the account's keys) pulled in by `includeIf "gitdir:…"` lines in the account's `~/.gitconfig` for the workspace's git dir and its sessions' `ah--*` worktrees — never written into the repository, and deleting a key stops every workspace signing with it. 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, the account's own `~/.gitconfig` or those signing files. - **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 home's red per-run switch, which starts from the account's own default (Settings → AI tools → **Session defaults**, `client/src/lib/sessionDefaults.ts`: Claude's default mode may be `bypassPermissions`, Codex's bypass may default on — set only behind a confirm, never by the first-run wizard, never for the pet chat; the server never remembers it) — and they come from the tool's catalog file (Codex: `--dangerously-bypass-approvals-and-sandbox`, which then replaces `-s`/`-a`); Claude has none (it has the `bypassPermissions` mode), and `--dangerously-bypass-hook-trust` is never emitted. Every pane gets the dashboard's hydrated `PATH` through tmux `-e`. Only `claude`/`codex` are `proxyCapable`; `resolveProxy` accepts only `native` for any other tool, else `EUNSUPPORTED`. `GET /api/run/binary?tool=` resolves through the catalog — the query string never names a command. Do not add a route that takes a command, args or env for a tool from a request. +- **A lane's `tool` is its DEFAULT, immutable; a session's tool is the session's choice — and only an AI Tools catalog id ever comes from a request.** `lanes.tool` (default `claude`) is any id `server/lib/ai-tools.js` knows; `createLane` refuses an unknown one (`EBADTOOL`) and one the user disabled (`EDISABLED` — only CREATION asks, an existing lane on a tool disabled since keeps working), and it is deliberately absent from `PATCHABLE`. A NEW session names its own with `tool` on `POST /api/lanes/:id/start` (the home's **AI** select, sent only when it differs from the lane's): `validateTool` again (`EBADTOOL`/`EDISABLED`, 400), and the id only picks a catalog file — the command, args and env still come from that file. A resume keeps its session's own tool (`sessions.tool`), never the request's. `POST /api/run` still takes the lane's tool. A lane whose tool left the catalog answers `ENOTOOL` at start. The sidebar's tool icon is per SESSION (`WorkspaceSessionList`), not per workspace row. A pane has no run form: `RunSetup` is gone from `LaneConsolePane`, and the workspace home's prompt box (`WorkspaceHome.tsx`: AI and ONE Model chip — `ModelMenu` in `RunSetup.tsx`, the model list plus Effort, a proxy's own models on a proxy — inside the box; AI Proxy, Permission and Local / New worktree in a row under it, like Claude Desktop) is the one place a session starts with a prompt (the session view's tab bar `+`, `ConsoleTabs.tsx`, starts a bare CLI or a plain shell — `shell: true`, nothing from the request but the slot; the home's AI menu has the same **Open terminal**). `claude`/`codex` keep their hand-written argv builders (`launcher` in their catalog file — a `DASHBOARD_AI_TOOLS_DIR` file may NOT claim one); every other tool is started by its detected absolute path as `[path, ...args, ...resume?, ...bypass?, ...prompt]` (`...resumeById, ` 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; 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`, else `claude` — a Grok payload, recognised by its camelCase `hookEventName` + `workspaceRoot` since Grok runs the Claude hooks, is dropped). AgentHub never reads the IDE's data. - **Codex hooks run only because the user trusted them in Codex, and AgentHub never decides that for them.** `server/lib/codex-hooks.js` writes `$CODEX_HOME/hooks.json` only on the user's click, keeps every entry that is not AgentHub's, and never writes Codex's trust (`[hooks.state]` in `config.toml` — read only, to report); nothing may pass `--dangerously-bypass-hook-trust`. The hook command is the STABLE `'/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`, else `claude`; a Grok payload is dropped), and a Codex hook's `transcript_path` is never stored or parsed as a Claude transcript — only `threadOf` reads it, and only a `rollout-*.jsonl` under `$CODEX_HOME/sessions`. - **An AI Tools install runs only what a RELEASE catalog file names.** `install`/`update` (like `launcher`) are accepted in built-in files and in the synced copy of the newest release's catalog, never in `DASHBOARD_AI_TOOLS_DIR`, and no route takes a package, URL or argv from a request. Jobs are argv-only (`ai-tool-jobs.js`), stdin closed, one at a time; npm is resolved on the login shell's OWN PATH first (`shellPath()`), because `hydrateShellPath` appends, and the pinned Node's npm would install where the user's PATH never looks; a global prefix the account cannot write (a system-wide Node, root's `/usr/local`) makes the install `--prefix ~/.local` instead, never sudo; an npm update is refused (`ENOTNPM`) for a tool whose binary is under neither `npm prefix -g` nor `~/.local/lib/node_modules` (a standalone Codex, say) — it would install a shadowed second copy. A script URL must be https on the homepage's site and is re-checked after redirects — the one way off that site is the catalog entry's own `install.redirect`, an EXACT https URL (never a site wildcard). Icons are raster only — an SVG served from the dashboard's origin runs script when opened. Do not add an install source that a browser or a custom catalog file can name. diff --git a/client/src/components/Sagi/PetChat.tsx b/client/src/components/Sagi/PetChat.tsx deleted file mode 100644 index 18b28b02..00000000 --- a/client/src/components/Sagi/PetChat.tsx +++ /dev/null @@ -1,595 +0,0 @@ -/** - * @file PetChat.tsx - * @description The pet's chat window — its whole UI: clicking the pet opens - * it. Above the conversation sits what the pet knows (`info`: live / waiting / - * errored, the sessions that need you, Mute, Clear alerts — `SagiInfo.tsx`). - * Before a chat runs, a question the pet can answer itself (`onLocal`: status, - * who are you…) is answered right here; anything else — and every message once - * a chat runs — goes to the AI: the first message starts a - * session of the user's DEFAULT AI tool (`agenthub.defaultTool`, Claude or - * Codex — the two with a readable transcript) in the "Sagi" workspace, in its - * first free tab (`startInFreeTabDetailed`), and the conversation is shown as - * chat bubbles read from the session's transcript. Follow-ups are typed into - * that session's pane through a terminal attached OFF-SCREEN (`TerminalView` - * in a fixed box left of the viewport — `submitToRun` needs a mounted, - * attached pane, exactly like the workspace's own Chat view), so nothing new - * reaches the server. A menu the CLI asks with (a permission or trust prompt) - * is read off that pane and answered with `PaneMenuCard`. The chat is kept in - * `localStorage` (`agenthub.pet.chat`) while its pane lives, so closing and - * reopening the window continues it; **New chat** STOPS that session (kills - * its pane and archives it, asking first only mid-reply), **Open in workspace** - * opens its pane there. Opening never resumes an ended session: once the pane - * is gone, the next message starts a new one. - * @author Nguyễn Ngọc Trí Vĩ - */ - -import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react"; -import { useTranslation } from "react-i18next"; -import { ArrowUp, ExternalLink, Loader2, SquarePen, Wrench, X } from "lucide-react"; -import { api } from "../../lib/api"; -import type { TranscriptMessage } from "../../lib/types"; -import { readDefaultMode, readDefaultTool } from "../../lib/sessionDefaults"; -import { - answerRunMenu, - chooseRunOption, - interruptRun, - readRunMenu, - stepRunMenu, - submitToRun, - type PaneMenu, -} from "../../lib/panePaste"; -import { isSubmitEnter } from "../../lib/keys"; -import { confirmDialog } from "../../lib/confirm"; -import { toast } from "../../lib/toast"; -import { startInFreeTabDetailed } from "../lanes/startSession"; -import { TerminalView } from "../run/TerminalView"; -import { PaneMenuCard } from "../run/PaneChat"; -import { MarkdownContent } from "../conversation/MarkdownContent"; - -/** Tools whose session the chat can read back (a transcript AgentHub parses). */ -export const PET_CHAT_TOOLS = new Set(["claude", "codex"]); -const CHAT_KEY = "agenthub.pet.chat"; -const POLL_MS = 1500; -const MENU_POLL_MS = 700; -// A follow-up waits this long for the off-screen pane to attach. -const SEND_TRIES = 20; -const SEND_RETRY_MS = 250; - -/** The session the chat is talking to. */ -export interface PetChatRef { - laneId: number; - runId: string; - tool: string; -} - -export function readPetChat(): PetChatRef | null { - try { - const v = JSON.parse(localStorage.getItem(CHAT_KEY) ?? "null") as Partial | null; - if ( - v && - typeof v.laneId === "number" && - typeof v.runId === "string" && - typeof v.tool === "string" - ) - return { laneId: v.laneId, runId: v.runId, tool: v.tool }; - } catch { - /* unreadable: no chat */ - } - return null; -} - -function writePetChat(ref: PetChatRef | null) { - try { - if (ref) localStorage.setItem(CHAT_KEY, JSON.stringify(ref)); - else localStorage.removeItem(CHAT_KEY); - } catch { - /* best-effort */ - } -} - -/** The tool a new chat starts: the user's default, when the chat can show it. */ -export function petChatTool(): string | null { - const tool = readDefaultTool() ?? "claude"; - return PET_CHAT_TOOLS.has(tool) ? tool : null; -} - -type Line = - | { key: string; role: "user" | "assistant"; text: string } - | { key: string; role: "tools"; names: string[] }; - -/** A human line's text: Claude Code's own injected lines (``, - * ``…) are not something the user said. */ -const humanText = (m: TranscriptMessage) => - m.content - .filter((c) => c.type === "text" && c.text) - .map((c) => c.text!.trim()) - .filter((t) => t && !t.startsWith("<")) - .join("\n\n"); - -/** The transcript as chat lines: the user's words, the assistant's prose, and - * each run of tool calls between them folded into one chip. */ -export function chatLines(messages: TranscriptMessage[]): Line[] { - const out: Line[] = []; - messages.forEach((m, i) => { - const sender = m.sender ?? m.type; - if (sender === "user") { - const text = humanText(m); - if (text) out.push({ key: `u${i}`, role: "user", text }); - return; - } - if (m.type !== "assistant") return; - m.content.forEach((c, j) => { - if (c.type === "text" && c.text?.trim()) { - out.push({ key: `a${i}-${j}`, role: "assistant", text: c.text.trim() }); - } else if (c.type === "tool_use" && c.name) { - const last = out[out.length - 1]; - if (last?.role === "tools") last.names.push(c.name); - else out.push({ key: `t${i}-${j}`, role: "tools", names: [c.name] }); - } - }); - }); - return out; -} - -/** Still working: the last line is the user's, or an assistant turn not ended. */ -function isBusy(messages: TranscriptMessage[]): boolean { - for (let i = messages.length - 1; i >= 0; i--) { - const m = messages[i]!; - const sender = m.sender ?? m.type; - if (m.type === "assistant") return m.stop_reason !== undefined && m.stop_reason !== "end_turn"; - if (sender === "user" && humanText(m)) return true; - } - return false; -} - -/** "Read ×2 · Bash" */ -function toolSummary(names: string[]): string { - const counts = new Map(); - names.forEach((n) => counts.set(n, (counts.get(n) ?? 0) + 1)); - return [...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(" · "); -} - -/** What the container made of a message: an answer the pet gives itself, or - * `handled` (it took the message elsewhere); null sends it to the AI. */ -export type PetLocalReply = { answer: string } | { handled: true } | null; - -interface PetChatProps { - /** The pet's shown name, for the header and greeting. */ - name: string; - /** Shown in place of the plain name (the rename button). */ - title?: ReactNode; - /** Extra header buttons, before Open in workspace (the pat heart). */ - headerActions?: ReactNode; - /** Under the header: what the pet knows (status, sessions that need you). */ - info?: ReactNode; - /** Asked first while no chat runs. */ - onLocal?: (text: string) => PetLocalReply; - onClose: () => void; - /** Opens the chat's pane in its workspace. */ - onOpenWorkspace: (laneId: number, runId: string) => void; - /** The thinking mood while a reply is being written. */ - onBusy?: (busy: boolean) => void; -} - -export function PetChat({ - name, - title, - headerActions, - info, - onLocal, - onClose, - onOpenWorkspace, - onBusy, -}: PetChatProps) { - const { t } = useTranslation("settings"); - const [chat, setChat] = useState(readPetChat); - const [sessionId, setSessionId] = useState(null); - const [messages, setMessages] = useState([]); - // Sent but not in the transcript yet. - const [pending, setPending] = useState(null); - const [starting, setStarting] = useState(false); - const [error, setError] = useState(null); - const [draft, setDraft] = useState(""); - const [menu, setMenu] = useState(null); - // The pet's own answers, given before any chat ran. - const [local, setLocal] = useState([]); - const listRef = useRef(null); - - const remember = (ref: PetChatRef | null) => { - writePetChat(ref); - setChat(ref); - setSessionId(null); - setMessages([]); - setMenu(null); - }; - - // A remembered chat whose pane is gone is over: forget it, the next message starts anew. - useEffect(() => { - if (!chat) return; - let live = true; - const check = () => - api.run - .get(chat.runId) - .then((h) => { - if (!live) return; - if (h.status !== "running") remember(null); - else if (h.sessionId) setSessionId(h.sessionId); - }) - .catch(() => live && remember(null)); - void check(); - // Codex learns its session only after a while; Claude's is known at start. - const timer = window.setInterval(() => { - if (!sessionId) void check(); - }, POLL_MS); - return () => { - live = false; - window.clearInterval(timer); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [chat?.runId, sessionId]); - - // The conversation, polled while the window is open. - useEffect(() => { - if (!sessionId) return; - let live = true; - const load = () => - api.sessions - .transcript(sessionId, { limit: 60 }) - .then((r) => { - if (!live) return; - setMessages((prev) => - prev.length === r.messages.length && - JSON.stringify(prev[prev.length - 1]) === - JSON.stringify(r.messages[r.messages.length - 1]) - ? prev - : r.messages - ); - }) - .catch(() => undefined); // not started yet: the next poll asks again - void load(); - const timer = window.setInterval(load, POLL_MS); - return () => { - live = false; - window.clearInterval(timer); - }; - }, [sessionId]); - - // A menu the CLI shows in the pane (permission, trust, a question). - useEffect(() => { - if (!chat) return; - const read = () => - setMenu((prev) => { - const next = readRunMenu(chat.runId); - return JSON.stringify(next) === JSON.stringify(prev) ? prev : next; - }); - const timer = window.setInterval(read, MENU_POLL_MS); - return () => window.clearInterval(timer); - }, [chat]); - - const lines = [...local, ...chatLines(messages)]; - // The pending line shows until the transcript has it. - useEffect(() => { - if (pending && lines.some((l) => l.role === "user" && l.text === pending.trim())) - setPending(null); - }, [lines, pending]); - const busy = starting || !!pending || isBusy(messages); - useEffect(() => onBusy?.(busy), [busy, onBusy]); - useEffect(() => () => onBusy?.(false), [onBusy]); - - // Keep the newest in sight. - useEffect(() => { - const el = listRef.current; - if (el) el.scrollTop = el.scrollHeight; - }, [lines.length, pending, busy, menu]); - - const send = useCallback( - async (text: string) => { - const prompt = text.trim(); - if (!prompt || starting) return; - setError(null); - if (!chat && onLocal) { - const reply = onLocal(prompt); - if (reply && "handled" in reply) return; - if (reply) { - const n = Date.now(); - setLocal((prev) => [ - ...prev, - { key: `lu${n}`, role: "user", text: prompt }, - { key: `la${n}`, role: "assistant", text: reply.answer }, - ]); - return; - } - } - setPending(prompt); - if (chat) { - // Typed into the attached pane; it may still be attaching. - for (let i = 0; i < SEND_TRIES; i++) { - if (submitToRun(chat.runId, prompt)) return; - await new Promise((r) => setTimeout(r, SEND_RETRY_MS)); - } - setPending(null); - setDraft(prompt); - setError(t("sagi.chat.notSent")); - return; - } - const tool = petChatTool() ?? "claude"; - setStarting(true); - try { - const { lane } = await api.lanes.ensure({ title: "Sagi" }); - const r = await startInFreeTabDetailed(lane.id, { - prompt, - ...(tool !== lane.tool ? { tool } : {}), - ...(tool === "claude" ? { permissionMode: readDefaultMode({ safe: true }) } : {}), - }); - remember({ laneId: r.laneId, runId: r.runId, tool }); - } catch (err) { - setPending(null); - setDraft(prompt); - setError( - t("sagi.startFailed", { error: err instanceof Error ? err.message : String(err) }) - ); - } finally { - setStarting(false); - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [chat, starting, t, onLocal] - ); - - // New chat: the old one's session is stopped, not left running unseen — - // asking first only while it is still writing a reply. - const newChat = async () => { - if (chat) { - if ( - busy && - !(await confirmDialog({ - title: t("sagi.chat.endTitle"), - message: t("sagi.chat.endBody"), - confirmLabel: t("sagi.chat.endConfirm"), - destructive: true, - })) - ) - return; - try { - await api.run.kill(chat.runId, { archive: true }); - } catch (err) { - toast.error(err); - return; - } - } - remember(null); - setLocal([]); - setPending(null); - setError(null); - }; - - const submit = (e?: FormEvent) => { - e?.preventDefault(); - if (!draft.trim()) return; - const text = draft; - setDraft(""); - void send(text); - }; - - const toolLabel = chat?.tool ?? petChatTool() ?? "claude"; - const empty = lines.length === 0 && !pending; - - return ( -
- {/* header */} -
- - 🐾 - -
- {title ??
{name}
} -
- - {busy ? t("sagi.chat.typing") : toolLabel} -
-
- {headerActions} - {chat && ( - onOpenWorkspace(chat.laneId, chat.runId)} - > - - - )} - void newChat()} - disabled={(!chat && !local.length) || starting} - > - - - - - -
- - {info} - - {/* conversation */} -
- {empty && ( -
- - 🐾 - -

- {t("sagi.chat.emptyTitle", { name })} -

-

{t("sagi.chat.emptyHint", { tool: toolLabel })}

-
- )} - {lines.map((l) => - l.role === "tools" ? ( -
- - - {toolSummary(l.names)} - -
- ) : l.role === "user" ? ( -
-
- {l.text} -
-
- ) : ( -
-
- -
-
- ) - )} - {pending && ( -
-
- {pending} -
-
- )} - {busy && !menu && ( -
-
- - - -
-
- )} - {starting && ( -

- - {t("sagi.chat.starting", { tool: toolLabel })} -

- )} - {chat && !sessionId && !starting && ( -

{t("sagi.chat.notStarted")}

- )} -
- - {error && ( -

- {error} -

- )} - - {/* prompt, or the question the CLI asks */} -
- {menu && chat ? ( -
- { - if (chooseRunOption(chat.runId, menu, i)) setMenu(null); - else setError(t("sagi.chat.notSent")); - }} - onAnswer={(text) => { - if (answerRunMenu(chat.runId, menu, text)) setMenu(null); - else setError(t("sagi.chat.notSent")); - }} - onStep={(dir) => { - if (!stepRunMenu(chat.runId, dir)) setError(t("sagi.chat.notSent")); - }} - onCancel={() => { - if (interruptRun(chat.runId)) setMenu(null); - else setError(t("sagi.chat.notSent")); - }} - /> -
- ) : ( -
-