diff --git a/.env.example b/.env.example index 8de1736c..f9264920 100644 --- a/.env.example +++ b/.env.example @@ -91,6 +91,11 @@ # (default: enabled). # AGENTHUB_PRICING_SYNC=0 +# The status bar's Usage panel reads Antigravity's quota by running +# `agy --print /usage --output-format json` (no model call), which may let agy +# update itself. 0 never runs it, and Antigravity shows no quota (default: on). +# AGENTHUB_AGY_USAGE=0 + # Heavy read-only aggregates (GET /api/workflows) run in a worker thread on # their own connection so they never stall other requests. 0 runs them inline # on the event loop instead (default: worker). A job past the timeout answers diff --git a/CLAUDE.md b/CLAUDE.md index 08531949..3f340a6a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v2.5.11 (2026-10-07) — git cherry-pick/revert/undo/amend + confirms, JetBrains-style side by side, Open terminal in the AI menu, Repo default, README in Vietnamese by default. Older sync history: `docs/CLAUDE-SYNC-LOG.md` (archive, not loaded)._ 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.5.12 (2026-10-08) — Quit AgentHub (#65: `agenthub stop`, stays stopped until a server starts), Add to Chat, Settings in three groups, status-area icon (`server/lib/tray.js`), JetBrains-style Changes panel (ticked `paths`/`hunks`, `ignore`, `/git/blame`, file history), plan + quota for every AI tool (`server/lib/quota.js`), Grok Build/Kimi Code in, Gemini CLI/Hermes/OpenClaw out. Older sync history: `docs/CLAUDE-SYNC-LOG.md` (archive, not loaded)._ 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. @@ -44,7 +44,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which - **The native picker opens only for a browser on this machine, and nothing from the request but a start folder reaches its argv.** `POST /api/lanes/pick-path` refuses a non-loopback socket and any request a proxy forwarded (`EREMOTE`) — behind nginx every request comes from loopback, and the dialog would open on a screen nobody watches. Do not relax that check, and do not let the request name the dialog program, a title or any other argument. - **Never build a git command as a shell string.** Lane git operations use `execFile("git", [...args])` with an explicit argument array (see `server/lib/worktree.js`'s `git()` helper), never a concatenated/interpolated string passed to a shell. - **An AI Proxy (called AI Router before v2.5.5) is a SESSION's choice, and only its name comes from the request.** A new session names a profile (`proxy` on `POST /api/lanes/:id/start` / `POST /api/run` — the pre-rename `router` is still accepted —, the **AI Proxy** select beside the model picker); `resolveProxy` (`server/lib/proxy-apply.js`) checks it (`EBADPROXY`, `EUNSUPPORTED` off Claude/Codex) and the key and endpoint come from the store. A profile's `baseUrl` is the proxy's ROOT (a trailing `/v1` is dropped on save and on read): a **claude** pane gets `--settings=/proxy-settings/.json` with `ANTHROPIC_BASE_URL` = the root (0700/0600; plus `modelPicker` rows only when the profile sets a user-chosen `behavesAs` — never guess one, it sets the context window Claude Code compacts at; the command-line layer outranks project-local and user settings, so only the PATH is in argv), a **codex** pane gets `-p ` and `$CODEX_HOME/.config.toml` with `base_url` = root + `/v1` (its base `config.toml` is never touched); both are rewritten at each spawn, `PATCH /api/ai-proxies/:name` rewrites the existing ones and `DELETE` removes them. `dashboard_runs.proxy` (renamed in place from `router`) records it, and a resume naming none goes back to it. **Never write a proxy into the user's project again** — `.claude/settings.local.json` is shared by every session in the folder and is not in a worktree; `clearLaneProxies` takes the old keys out at start and `lanes.router_profile` stays NULL. Key material never leaves the server: profiles live at `/agenthub-proxies/` (0700/0600; the old `agenthub-routers/` is moved there on first read and left as a symlink for an older build), `GET /api/ai-proxies` reports only `hasKey`, and there is deliberately no route that returns one profile in full. `/api/ai-routers` is the same router under its pre-rename path. **`POST /api/ai-proxies/test` sends a stored key ONLY to the endpoint it was stored against** — a blank `apiKey` falls back to the profile named by `name`, but only while the submitted `baseUrl` still equals the saved one, else `EBADKEY`; it probes `/v1/messages` (Claude) and `/v1/responses` (Codex) and answers `{claude, codex}`. Relax that and the route becomes a way to make the server POST a saved key to any URL a browser names. **Codex's key reaches the pane through tmux's `-e` argv (`AGENTHUB_PROXY_KEY`) and is never typed into it** — `tmux.sendCommand` takes over an idle pane with `respawn-pane` for exactly this reason; a `KEY='sk-…' codex …` line typed with `send-keys` would be rendered to every attached browser, kept in the scrollback and appended to the user's shell history. Do not reintroduce a typed command line there, and do not put a Claude key in argv either. See `server/lib/ai-proxies.js`, `server/lib/proxy-apply.js`. -- **The dashboard's git writes are these twenty-one, 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` — nothing else. **`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 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 — `GET /api/lanes/branches` is the only one that takes a path from the request, because its lane does not exist yet. +- **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 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 — `GET /api/lanes/branches` is the only one that takes a path from the request, because its lane does not exist yet. - **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]`, 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. - **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`). AgentHub never reads the IDE's data. @@ -66,14 +66,16 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which See `docs/LANES.md` for full guide: stage reporting, custom templates, lane actions, and the five node states. ## Non-negotiable engineering rules -- **When a `agenthub.service` unit of ours owns the server, nothing else may stop it.** The bootstrap that performs a self-update is spawned by the running server, so it lives in that server's systemd cgroup: SIGTERM-ing the server from there exits 0 (which `Restart=on-failure` read as "meant to stop") AND trips `KillMode=control-group`, reaping the bootstrap before it can repoint the unit or write `state.json` — a dashboard dead after every update until some later session bootstrapped again. So `scripts/systemd-service.js` splits the API: `writeUserService` repoints the unit and starts NOTHING, `restartUserService` uses `--no-block`, and `plugin-bootstrap.js` defers that restart past `writeState` and `releaseLock` (`pendingSystemdRestart`). The unit is `Restart=always`. Do not add a path that stops the dashboard from inside its own cgroup, and do not move the restart earlier. Start-at-login is the USER's switch (`/api/settings/autostart`): no bootstrap or update may enable a unit the user left disabled, and the switch only enables/disables — it never stops the server answering it. `agenthub service install` may keep using `installUserService` — it runs from a user's shell, outside the cgroup. `systemdOwned` additionally requires the unit to be ACTIVE (an installed-but-stopped unit owns nothing; treating it as owner skips the stop and then binds an occupied port). A unit naming a RETIRED plugin root is invisible until a reboot, so `fastPathOk` rejects it via `unitIsStale` (pure fs — that path may never spawn a process) and the bootstrap rewrites it; repairing the unit is NOT restarting the server (`pendingSystemdRestart = systemdOwned && needsServer`), and the detached-spawn fallback is guarded by `needsServer` so a stale unit alone never starts a second server onto a bound port. +- **When a `agenthub.service` unit of ours owns the server, nothing else may stop it.** The bootstrap that performs a self-update is spawned by the running server, so it lives in that server's systemd cgroup: SIGTERM-ing the server from there exits 0 (which `Restart=on-failure` read as "meant to stop") AND trips `KillMode=control-group`, reaping the bootstrap before it can repoint the unit or write `state.json` — a dashboard dead after every update until some later session bootstrapped again. So `scripts/systemd-service.js` splits the API: `writeUserService` repoints the unit and starts NOTHING, `restartUserService` uses `--no-block`, and `plugin-bootstrap.js` defers that restart past `writeState` and `releaseLock` (`pendingSystemdRestart`). The unit is `Restart=always` and `KillMode=process` — **an update never closes a lane pane, only Quit does**: tmux may have been forked by the dashboard into the unit's cgroup, and `control-group` killed every pane on each restart; do not drop it, nor the transient `systemd-run --user --scope` the FIRST session starts the tmux server in (`server/lib/tmux.js` — the server would otherwise share the cgroup of the dashboard or of the terminal it was started from), and do not add a `killRun`/`killSession` to any update, restart or shutdown path. Do not add a path that stops the dashboard from inside its own cgroup, and do not move the restart earlier. Start-at-login is the USER's switch (`/api/settings/autostart`): no bootstrap or update may enable a unit the user left disabled, and the switch only enables/disables — it never stops the server answering it. `agenthub service install` may keep using `installUserService` — it runs from a user's shell, outside the cgroup. `systemdOwned` additionally requires the unit to be ACTIVE (an installed-but-stopped unit owns nothing; treating it as owner skips the stop and then binds an occupied port). A unit naming a RETIRED plugin root is invisible until a reboot, so `fastPathOk` rejects it via `unitIsStale` (pure fs — that path may never spawn a process) and the bootstrap rewrites it; repairing the unit is NOT restarting the server (`pendingSystemdRestart = systemdOwned && needsServer`), and the detached-spawn fallback is guarded by `needsServer` so a stale unit alone never starts a second server onto a bound port. +- **Quit stops the dashboard through its service manager, and only a server STARTING ends it.** `POST /api/settings/quit` / `agenthub stop` (`server/lib/quit.js`) writes `/runtime/stopped`, kills every `agenthub-lane-*` pane, then stops a running unit of ours with `systemctl --user stop --no-block` / `launchctl bootout` (async — `bootout` waits for this very process) and never by SIGTERM, which `Restart=always`/`KeepAlive` would undo; with no unit running it SIGTERMs itself. While the flag exists the bootstrap starts no server (`fastPathOk`, `needsServer`; only `--force` overrides). The flag is cleared ONLY by a server that starts listening (and `agenthub start`) — never by a bootstrap — so an enabled autostart unit still brings the dashboard back at boot/login, and a SessionStart never does. +- **The status-area icon is its own process, and only the user hides or shows it.** `scripts/tray.js` (systray2) is started and kept alive by a production server (`startTrayWatch` in `server/lib/tray.js`), never in server mode; it stops and starts AgentHub only through the CLI's own `agenthub stop|start`, so a Quit from the icon is a Quit. It is on by default; `/runtime/tray-off` (Settings → System, or the icon's Hide) is the user's, and nothing else may remove it. A server never kills the icon on restart or Quit — after a Quit it is the way back — and only signals a pid whose `tray.json` heartbeat is fresh. - **On macOS the same rules bind a LaunchAgent.** `scripts/launchd-service.js` exports the systemd module's API (`service-manager.js` picks by platform — require THAT, never one of the two directly, or a Mac silently gets the systemd no-op): `KeepAlive` is `Restart=always`, `writeUserService` writes the plist and loads nothing, and the deferred hand-over is `bootout` + `bootstrap` (never `kickstart`, which keeps the old plist). Two differences are deliberate: `AbandonProcessGroup` is true so a restart does not take the lane panes' tmux server with it, and because a failed `bootstrap` leaves NOTHING running, the bootstrap falls back to its detached spawn when the restart fails and no server is live. Availability is `launchctl print gui/` — no GUI login (SSH only) means the detached spawn, as before. - **A bootstrap that is no longer the installed build does NOTHING, and `--force` does not override that.** `supersededBy` (`scripts/plugin-bootstrap.js`) reads Claude Code's `installed_plugins.json`: if a SIBLING cache directory is the recorded install, this build logs one line and returns `superseded` — no deps, no unit, no server. A Claude Code process that was not restarted after an update keeps loading the OLD cache dir, and without this its every session repointed `agenthub.service` at that old dir and restarted the dashboard onto the version the user just upgraded away from, once per session, forever. It may not hand over to the new build either — the process it would replace is the one loading it. Do not add a path that lets a retired root write the unit, stop the server, or "just" refresh deps. - **Deleting retired cache copies is the ONE thing here that touches the plugin cache, and four protections gate it.** `pruneRetiredPluginRoots` never deletes: this build; any `installPath` in `installed_plugins.json`; the dir `agenthub.service` names (`unitRoot`, foreign unit included); any dir named in the user's `settings.json` hooks (a un-restarted Claude Code is still executing from there — deleting it breaks that session's hooks). Plus the newest survivor as a rollback cushion (`KEEP_RETIRED_ROOTS`, 1 — a full copy of this repo is ~130 MB, so the cushion is one build deep, not two), and nothing at all outside `~/.claude/plugins/cache/`. It runs AFTER `writeState`, so a bad prune cannot also cost the record of a good bootstrap. Adding a fifth thing that pins a cache dir means adding a fifth protection here. - **A session reload is typed into a live pane, never spawned.** `/reload-plugins` and `/reload-skills` are scoped BY CLAUDE CODE to the session that runs them, so nothing can reload a session from the outside. `server/lib/cc-reload.js` therefore types them into the dashboard's existing tmux lane panes and spawns NO `claude` process — a one-shot `claude -p "/reload-plugins"` reloads only its own short-lived session, exits, and leaves a junk session in the dashboard's own history. Do not add a path that starts a session in order to reload one. - **A read that scans the events table never runs on the event loop, and never runs a correlated subquery per event.** Node serves every page, hook and websocket from one thread, and better-sqlite3/node:sqlite block it for the whole query — one slow aggregate freezes the dashboard for every user. Put an aggregate that takes more than a few hundred ms at real size into `JOBS` in `server/lib/read-pool.js` (its own `query_only` connection in a worker, timeout, shared in-flight runs); write "the next event of the session" as a window function (`LEAD() OVER (PARTITION BY session_id ORDER BY id)`), not `MIN(id) … id > e1.id`. And do not drop `idx_events_session(session_id, id)` as a "prefix" of another index: only it orders a session's events by id. - **A headless entry point requires `server/lib/windows-hide.js` before anything reads `child_process`.** The dashboard and its detached helpers have no console on Windows, so every git/npm call without `windowsHide` opened a console window that flashed and stole focus on each poll. `install()` (in `server/index.js`, `plugin-bootstrap.js`, `plugin-update-helper.js`, `cc-update-all-helper.js`, `restart-helper.js`) forces it for every spawn; a new detached script needs the same first line, or a `windowsHide: true` of its own (the statusline's one git call has that). -- **Server mode is a MODE of this codebase, never a fork, and local mode must not notice it.** Everything server-only checks `isServerMode()` (`AGENTHUB_MODE=server`, `server/lib/auth.js`) and is a no-op otherwise; `auth.js` opens the database only on first use, so a module that merely loads it never touches the DB in local mode. Accounts are made only from the host's shell (`agenthub server setup`, `agenthub users …`) — do not add a route that creates a user, sets a role or returns a password hash. Rows are OWNED (phase S2, `server/lib/scope.js`): `lanes.owner_id` is the creator (never from the request — `ownerFor(req)`), a session belongs to its workspace (`sessions.lane_id` or a `dashboard_runs` row), and everything else hangs off the session. A member sees only their own rows: every list/aggregate passes `req` into `sessionWhere`/`bySessionWhere`/`laneWhere`; every route naming ONE row is covered by `scopeGuard`'s `PATH_RULES` + the query/body id names in `namedIds` (404 `ENOTFOUND`) — a new id route under a new prefix needs a rule there; every WebSocket broadcast type needs a `broadcastAudience` case (an unlisted type reaches no member); a staff-wide view joins `STAFF_ONLY_PREFIXES`. Admin/analyst reads of a member's session are written to `audit_log` (`server/lib/audit.js`). A pane carries `AGENTHUB_PANE_TOKEN` (hash in `dashboard_runs.pane_token_hash`, never listed) so the CLI/MCP/statusline inside it act as the workspace owner — accepted only from loopback and only while the pane lives (`userFromPaneToken`). Every write in server mode passes the Origin check in `authGuard`; prefix checks go through `apiPath()` (decoded, case-insensitive), because Express routes `/api/Webhooks` and `%77ebhooks` too. Named locks are namespaced per member, and `named-lock.js` takes only a plain name (a lock name was a path). It is still NOT OS isolation: every pane runs as the host account until S3, so do not call it multi-tenant. A new route that writes the host's own configuration joins `ADMIN_WRITE_PREFIXES`; hooks stay loopback-only in server mode. +- **Server mode is a MODE of this codebase, never a fork, and local mode must not notice it.** Everything server-only checks `isServerMode()` (`AGENTHUB_MODE=server`, `server/lib/auth.js`) and is a no-op otherwise; `auth.js` opens the database only on first use, so a module that merely loads it never touches the DB in local mode. Accounts are made from the host's shell (`agenthub server setup`, `agenthub users …`) or by an admin on the Users page (`server/routes/users.js`), where EVERY change needs the admin's own password again (`confirmPassword` in `auth.js`, throttled — EREAUTH/ETOOMANY), never lets an admin demote or disable themselves (ESELF), is written to `audit_log`, and refuses in local mode (ELOCALMODE: no login to check) — do not add an account route without that step-up, and never one that returns a password hash. Rows are OWNED (phase S2, `server/lib/scope.js`): `lanes.owner_id` is the creator (never from the request — `ownerFor(req)`), a session belongs to its workspace (`sessions.lane_id` or a `dashboard_runs` row), and everything else hangs off the session. A member sees only their own rows: every list/aggregate passes `req` into `sessionWhere`/`bySessionWhere`/`laneWhere`; every route naming ONE row is covered by `scopeGuard`'s `PATH_RULES` + the query/body id names in `namedIds` (404 `ENOTFOUND`) — a new id route under a new prefix needs a rule there; every WebSocket broadcast type needs a `broadcastAudience` case (an unlisted type reaches no member); a staff-wide view joins `STAFF_ONLY_PREFIXES`. Admin/analyst reads of a member's session are written to `audit_log` (`server/lib/audit.js`). A pane carries `AGENTHUB_PANE_TOKEN` (hash in `dashboard_runs.pane_token_hash`, never listed) so the CLI/MCP/statusline inside it act as the workspace owner — accepted only from loopback and only while the pane lives (`userFromPaneToken`). Every write in server mode passes the Origin check in `authGuard`; prefix checks go through `apiPath()` (decoded, case-insensitive), because Express routes `/api/Webhooks` and `%77ebhooks` too. Named locks are namespaced per member, and `named-lock.js` takes only a plain name (a lock name was a path). It is still NOT OS isolation: every pane runs as the host account until S3, so do not call it multi-tenant. A new route that writes the host's own configuration joins `ADMIN_WRITE_PREFIXES`; hooks stay loopback-only in server mode. - **A pool token reaches a pane only through tmux `-e`, and only for the account `pickAccount` chose.** Never type it into a pane, never put it in the CLI's argv, never return it from a route (`poolStatus` has no token field), and never add a route that adds or reads one — the pool is filled from the host's shell (`agenthub accounts add`). An empty pool must leave every spawn exactly as before. - Preserve existing behavior unless explicitly asked to change it. - Prefer minimal, reversible diffs. diff --git a/README.en.md b/README.en.md index 3c9ae8b3..f3485b5e 100644 --- a/README.en.md +++ b/README.en.md @@ -48,6 +48,10 @@ open it in the browser and you are done. Something wrong? Send `/agenthub:agenthub-doctor` to check and fix it. To update to a new version: `/agenthub:agenthub-update`. +Not using it? Quit so nothing runs in the background: **Settings → System → Quit**, +or `agenthub stop` — opening Claude Code no longer starts it; `agenthub start` does. +AgentHub's icon beside the clock (top bar / menu bar / notification area) offers +**Open**, **Quit** and **Start AgentHub** too; hide it from its menu or in **Settings → System**. diff --git a/README.md b/README.md index 146085f1..bf88faca 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,10 @@ trên trình duyệt là xong. Gặp lỗi thì gửi `/agenthub:agenthub-doctor` để tự kiểm tra và sửa. Cập nhật bản mới: `/agenthub:agenthub-update`. +Không dùng thì tắt hẳn cho khỏi chạy ngầm: **Settings → Hệ thống → Thoát**, hoặc +`agenthub stop` — mở Claude Code sẽ không tự bật lại; bật lại bằng `agenthub start`. +Icon AgentHub cạnh đồng hồ (thanh trên cùng / menu bar / khay hệ thống) cũng có +**Open**, **Quit** và **Start AgentHub**; ẩn nó trong menu của icon hoặc ở **Settings → Hệ thống**. diff --git a/bin/agenthub.js b/bin/agenthub.js index a450a1c2..b400e510 100755 --- a/bin/agenthub.js +++ b/bin/agenthub.js @@ -40,7 +40,7 @@ const path = require("node:path"); const fs = require("node:fs"); -const { spawn } = require("node:child_process"); +const { spawn, spawnSync } = require("node:child_process"); // Resolve the repo root relative to this script's REAL location so the CLI // works both from a checkout (./bin/agenthub.js) and through the global symlink @@ -1397,14 +1397,36 @@ async function cmdReinstallHooks() { * built client) and wait until /api/health answers. No-ops with a pointer to * the live URL when a server is already up. The child is fully detached with * its output appended to data/agenthub-server.log, so closing this terminal does - * not stop the dashboard; stop it later with `kill ` (the PID is - * printed and registered in ~/.claude/.agenthub.json). + * not stop the dashboard; stop it later with `agenthub stop`. */ async function cmdStart(flags) { + // A Quit (`agenthub stop`) held the plugin bootstrap off; starting ends it. + require(path.join(REPO_ROOT, "server", "lib", "quit.js")).clearQuit(); if (await serverIsUp()) { console.log(`${c.green("●")} Dashboard already running at ${c.bold(baseUrl())}`); return; } + // A plugin install starts the way a Claude Code session does: its bootstrap + // knows the runtime dir, the UI bundle and the autostart unit. + if (/[\\/]\.claude[\\/]plugins[\\/]cache[\\/]/.test(REPO_ROOT)) { + spawnSync( + process.execPath, + [path.join(REPO_ROOT, "scripts", "plugin-bootstrap.js"), "--worker"], + { + stdio: "inherit", + } + ); + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + if (await serverIsUp()) { + console.log(`${c.green("●")} Dashboard ${c.bold("up")} at ${c.bold(baseUrl())}`); + return; + } + await new Promise((r) => setTimeout(r, 500)); + } + console.error(c.red("✖ Server did not become healthy within 30 s — run /agenthub-doctor")); + process.exit(1); + } const clientDist = path.join(REPO_ROOT, "client", "dist", "index.html"); if (!fs.existsSync(clientDist)) { console.error(c.red("✖ client/dist is missing — the production server needs a built client.")); @@ -1447,7 +1469,7 @@ async function cmdStart(flags) { console.log( `${c.green("●")} Dashboard ${c.bold("up")} at ${c.bold(baseUrl())} ${c.dim(`(pid ${child.pid})`)}` ); - console.log(c.dim(` Stop it with: kill ${child.pid}`)); + console.log(c.dim(" Stop it with: agenthub stop")); return; } tick++; @@ -1463,6 +1485,33 @@ async function cmdStart(flags) { process.exit(1); } +/** + * Quit AgentHub (issue #65): every lane pane is killed, the server stops, and + * the plugin bootstrap starts none until `agenthub start` — or a boot/login + * through an enabled autostart unit (`server/lib/quit.js`). + */ +async function cmdStop() { + if (!(await serverIsUp())) { + require(path.join(REPO_ROOT, "server", "lib", "quit.js")).markQuit(); + console.log( + `${c.dim("○")} Dashboard is not running — it stays stopped until ${c.bold("agenthub start")}` + ); + return; + } + const { killed } = await post("/api/settings/quit"); + const deadline = Date.now() + 15_000; + while ((await serverIsUp()) && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 250)); + } + if (await serverIsUp()) { + console.error(c.red(`✖ The dashboard at ${baseUrl()} is still answering after 15 s`)); + process.exit(1); + } + console.log( + `${c.green("✔")} AgentHub stopped${killed ? ` — ${killed} terminal session(s) closed` : ""}. Start it again with: ${c.bold("agenthub start")}` + ); +} + /** * Installs/removes a systemd `--user` service (Linux) or a LaunchAgent * (macOS) so the dashboard survives reboots without anything (Claude Code @@ -2731,6 +2780,11 @@ const COMMAND_GROUPS = [ [ ["status", "", "Up/down indicator for the dashboard server"], ["start", "[--port N]", "Start the server in the background and wait for healthy"], + [ + "stop", + "", + "Quit: close every workspace terminal and stop the server until `agenthub start`", + ], [ "service", "install|uninstall|status [--port N]", @@ -2948,6 +3002,11 @@ const COMMAND_GROUPS = [ "", "Run the read-only MCP server on stdio (claude mcp add agenthub -- agenthub mcp)", ], + [ + "tray", + "", + "Show AgentHub's icon in the OS status area now (the server keeps it up; Settings → System hides it)", + ], ["statusline", "", "Render the Claude Code statusline from the JSON on stdin"], [ "statusline install|uninstall", @@ -4000,6 +4059,8 @@ async function runCommand(argv) { return cmdStatus(); case "start": return cmdStart(flags); + case "stop": + return cmdStop(); case "service": return cmdService(positional[0], flags); case "server": @@ -4135,6 +4196,8 @@ async function runCommand(argv) { return; } return require(path.join(REPO_ROOT, "statusline", "statusline.js")).main(); + case "tray": + return require(path.join(REPO_ROOT, "scripts", "tray.js")).main(); case "open": return cmdOpen(); case "version": diff --git a/client/README.md b/client/README.md index 36931b90..4c321528 100644 --- a/client/README.md +++ b/client/README.md @@ -52,7 +52,7 @@ The client is a single-page application (SPA) built with modern web technologies - **React 18.3** - Component-based UI with hooks and concurrent features - **TypeScript 5.7** - Full type safety across components, utilities, and API contracts - **Vite 6.1** - Lightning-fast HMR during development, optimized production builds -- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `