Compare commits
25 Commits
a58e95a957
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 49f87c4da3 | |||
| 1a6b550e89 | |||
| f8d07dab61 | |||
| 0434ffc750 | |||
| e2d8d314f6 | |||
| 0c8782ea75 | |||
| c042adc51b | |||
| 776ad6466f | |||
| 9c98638ec0 | |||
| aa8ac6cad9 | |||
| 3237ce4774 | |||
| 7c5f30df7d | |||
| 5e0a9b526b | |||
| 538aec3ed9 | |||
| 692f532972 | |||
| f460fff406 | |||
| 2c712b06d1 | |||
| 1b09371042 | |||
| 14595651fe | |||
| 8739294a72 | |||
| e07993c536 | |||
| 5cb70d702b | |||
| 9a4ed54cf7 | |||
| 57c423cdb1 | |||
| ecbbb4dd8f |
+13
-1
@@ -72,9 +72,21 @@
|
||||
# DASHBOARD_SESSION_SYNC_MS=30000
|
||||
|
||||
# Hourly database upkeep: a non-blocking WAL checkpoint (the WAL then shrinks
|
||||
# to 64 MB) and PRAGMA optimize. 0 turns it off. Never VACUUMs.
|
||||
# to 64 MB), PRAGMA optimize, the opt-in retention below and a bounded
|
||||
# incremental_vacuum. 0 turns it off. Never a full VACUUM (that is Settings →
|
||||
# Data → Compact, on a click).
|
||||
# DASHBOARD_DB_MAINTAIN_MS=3600000
|
||||
|
||||
# Automatic retention (OFF unless set): finished sessions that started more
|
||||
# than N days ago (1–3650) are purged by the hourly upkeep. Usually set from
|
||||
# Settings → Data → Session cleanup, which writes it to the settings file.
|
||||
# AGENTHUB_RETENTION_DAYS=90
|
||||
|
||||
# Update check interval (ms, default 3600000 = 1 h, minimum 60000). A tick
|
||||
# with no browser connected is skipped; the next browser to connect gets one.
|
||||
# DASHBOARD_UPDATE_CHECK=0 turns the scheduled check off.
|
||||
# DASHBOARD_UPDATE_CHECK_INTERVAL_MS=3600000
|
||||
|
||||
# Poll interval (ms) for Antigravity CLI sessions and tokens, read from agy's
|
||||
# own ~/.gemini/antigravity-cli/conversations/*.db (0 disables; default: 15000).
|
||||
# DASHBOARD_ANTIGRAVITY_POLL_MS=15000
|
||||
|
||||
@@ -11,3 +11,4 @@ client/package-lock.json
|
||||
*.md
|
||||
*.yml
|
||||
*.yaml
|
||||
server/vendor/
|
||||
|
||||
+27
-15
@@ -310,7 +310,7 @@ graph TD
|
||||
|---------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `server/index.js` | Express app setup, middleware, route mounting, static file serving in production, HTTP server creation. Static middleware sets explicit `Cache-Control` headers — `immutable` for `/assets/*`, `no-cache, must-revalidate` for `index.html` / `sw.js` / `manifest.json`, a short revalidation window otherwise — so a rebuild always replaces the in-browser bundle without a hard refresh. Runs a periodic maintenance sweep — cadence derived from `DASHBOARD_STALE_MINUTES` (¼ of the threshold, clamped to 60 s – 5 min, default ~45 min) — that abandons stale sessions with transcript cache eviction and scans active sessions for new compaction entries by reading `sessions.transcript_path` directly (an O(active sessions) lookup; the previous `SELECT DISTINCT json_extract(events.data,'$.transcript_path')` scan grew with the events table and is gone). **Error detection watchdog** runs every 15 seconds: finds active sessions with no recent hook events (>10 s stale), re-reads their transcript files looking for API errors (401 auth, rate limits, quota exhaustion), derives transcript paths from session `cwd` for imported sessions, and marks sessions/agents as `error` when API errors are found — catches cases where the CLI doesn't fire a hook after API errors. The same watchdog also performs **user-interrupt (Esc) recovery**: `Esc` fires no hook, so a cancelled turn would otherwise leave the main agent stuck `working`. It detects this two ways — (a) the transcript's `[Request interrupted by user]` marker, surfaced as `result.pendingInterrupt` from `TranscriptCache` (computed from transcript ordering alone, immune to server/transcript clock skew), and (b) an idle-working fallback for an Esc pressed *before any output* (which writes no marker): when the main agent has been `working` with `current_tool` null and neither a hook event nor the transcript mtime has advanced for `DASHBOARD_WORKING_IDLE_SECONDS` (default 120) — and moves the session to **Waiting** (agent → `waiting`, `awaiting_input_since` stamped) with an `Interrupted` event. Triggers legacy session import (with active-session detection for recently-modified JSONL files) and compaction backfill on startup, plus a boot **liveness reap** — immediately for rows from a previous run and again ~5 s later for rows the startup sync just imported (see the `routes/hooks.js` row) — so sessions that died while the dashboard was down never render as Waiting. Also starts the **Remote Data Sources** sync poller (`startRemoteSourceSync`) that pulls each enabled remote source on an interval — `DASHBOARD_REMOTE_SYNC_MS` (default 60000; 0 disables) — reusing `server/lib/remote-sync.js`. **Graceful shutdown** (SIGTERM/SIGINT) tears down in order: drop realtime clients first (`closeWebSocket` terminates WS clients so their sockets release), then `httpServer.close()` to stop new connections, then `httpServer.closeAllConnections()` to drop lingering keep-alive sockets so `close()` fires promptly, and only **then** close SQLite — inside the `close()` callback, after the HTTP server has drained. Closing the DB before drain made in-flight requests throw `The database connection is not open`; leaving WS/keep-alive sockets open stalled the shutdown until the 5 s force-exit backstop (the "waiting for graceful termination" hang under `node --watch`). A second signal forces an immediate exit |
|
||||
| `server/openapi.js` | OpenAPI 3.0.3 document generator for the backend API (metadata, schemas, endpoint paths), merging supplementary fragments from `server/openapi-extra/` in `createOpenApiSpec()`. Feeds the raw spec endpoint (`/api/openapi.json`), Swagger UI (`/api/docs`), **ReDoc** (`/api/redoc`, served via `server/lib/redoc.js` with a self-hosted bundle — never a CDN), and the committed `openapi.yaml` regenerated by `npm run openapi:yaml` |
|
||||
| `server/lib/redoc.js` | Serves the **ReDoc** API reference (`/api/redoc`) as a self-hosted three-panel rendering of the OpenAPI spec, with the ReDoc bundle served locally from `/api/redoc/redoc.standalone.js` (bundled via the `redoc` dependency, never a CDN) so the reference works fully offline / air-gapped |
|
||||
| `server/lib/redoc.js` | Serves the **ReDoc** API reference (`/api/redoc`) as a self-hosted three-panel rendering of the OpenAPI spec, with the ReDoc bundle served locally from `/api/redoc/redoc.standalone.js` (vendored under `server/vendor/redoc/` — the `redoc` npm package is not a dependency — never a CDN) so the reference works fully offline / air-gapped |
|
||||
| `server/openapi-extra/` | Supplementary OpenAPI path/schema fragments merged into the spec by `createOpenApiSpec()` — covers `cc-config.js`, `push.js`, `run.js`, and `misc.js` route groups |
|
||||
| `server/db.js` | SQLite connection with WAL mode, schema migration (CREATE TABLE IF NOT EXISTS + ALTER TABLE for column additions), all prepared statements as a reusable `stmts` object. Tries `better-sqlite3` first, falls back to `node:sqlite` via `compat-sqlite.js`. Migrations use literal defaults for ALTER TABLE since SQLite does not support expressions like `strftime()` in column defaults added via ALTER TABLE |
|
||||
| `server/compat-sqlite.js` | Compatibility wrapper that gives Node.js built-in `node:sqlite` (`DatabaseSync`) the same API as `better-sqlite3` — pragma, transaction, prepare. Used as automatic fallback when the native module is unavailable (Node 22+) Maps every better-sqlite3 API the server uses — `function` (the `trim_event_data` SQL function `db.js` registers at boot), `stmt.pluck()`, the `readonly`/`fileMustExist` open options — and `db.js` exports the chosen class as `Database` so other SQLite files (agy's conversations) open with the same driver. A plugin install has NO better-sqlite3 build, so this is production there; `server/__tests__/compat-sqlite.test.js` boots `db.js` with better-sqlite3 blocked because the rest of the suite never runs on it. |
|
||||
@@ -1066,7 +1066,7 @@ flowchart TD
|
||||
- Uses Node.js `http` module directly — no dependencies.
|
||||
- Resolution order is **env override → discovery file → default**:
|
||||
- `CLAUDE_DASHBOARD_PORT` forces a single target (no fan-out, no discovery).
|
||||
- Otherwise `server/lib/server-info.js` reads `~/.claude/.agenthub.json`, prunes dead-PID entries, and returns one live `port` per unique `dataDir` (lowest port wins when two instances share `~/.claude/agenthub`). The handler POSTs to each returned port in parallel. The other instance learns of a lane the hooks moved through `broadcastForeignLaneChanges` (`server/routes/lanes.js`): every `DASHBOARD_LANE_SYNC_MS` (2 s) it reads `PRAGMA data_version`, which moves only on another connection's commit, and re-broadcasts each lane whose `updated_at` changed (and `removed` for a vanished one) to its own browsers. Sessions and events are not re-broadcast this way — a page on that instance shows them on its next fetch.
|
||||
- Otherwise `server/lib/server-info.js` reads `~/.claude/.agenthub.json`, prunes dead-PID entries, and returns one live `port` per unique `dataDir` (lowest port wins when two instances share `~/.claude/agenthub`). The handler POSTs to each returned port in parallel. The other instance learns of a lane the hooks moved through `broadcastForeignLaneChanges` (`server/routes/lanes.js`): every `DASHBOARD_LANE_SYNC_MS` (2 s) — while `peersSharingDataDir()` finds another live dashboard on its data dir, which it checks every 30 s — it reads `PRAGMA data_version`, which moves only on another connection's commit, and re-broadcasts each lane whose `updated_at` changed (and `removed` for a vanished one) to its own browsers. Sessions and events are not re-broadcast this way — a page on that instance shows them on its next fetch.
|
||||
- If neither yields anything, the handler falls back to `4820`.
|
||||
- Per-target promises never reject — a dead listener can't starve the others, and the handler can wait on `Promise.all` for clean exit timing.
|
||||
|
||||
@@ -1223,7 +1223,12 @@ common "nothing changed" tick is just a handful of `stat` calls), funnels them
|
||||
through the same `parseSessionFile` + `importSession` pipeline as every other
|
||||
path, and broadcasts `session_created` for newly imported sessions /
|
||||
`session_updated` for grown ones — the same events hooks emit, so the UI
|
||||
refreshes live. All timers and watchers are `unref`'d and best-effort; nothing
|
||||
refreshes live. Two kinds of transcript are not re-parsed: one whose session
|
||||
hooks are feeding **live** (status `active`, newest event a hook's, a hook
|
||||
within `DASHBOARD_SYNC_LIVE_HOOK_MS`, default 5 min) is left until it goes
|
||||
quiet or ends — then caught up once — and one whose session a hook created
|
||||
(no `imported` flag, which `importSession` skips anyway) only has its durable
|
||||
snapshot refreshed, never a parse. All timers and watchers are `unref`'d and best-effort; nothing
|
||||
here blocks shutdown or can take down the server.
|
||||
|
||||
### Upload request sequence
|
||||
@@ -1579,7 +1584,11 @@ re-point if that changes.
|
||||
`DASHBOARD_CODEX_POLL_MS` beat, a first pass over every journal in the
|
||||
background at startup). Each file's read offset and parse state live in the
|
||||
`codex_rollouts` table, so an unchanged file is never re-read and a grown one is
|
||||
read from where it stopped. `token_count` events are summed per `turn_context`
|
||||
read from where it stopped (a pass loads every row's size/mtime in one query).
|
||||
The first pass and one every five minutes walk every journal; the poll's other
|
||||
passes stat only the two newest day folders plus the journals the table saw
|
||||
written in the last hour (or not yet read to the end), so a thread resumed
|
||||
after an hour's quiet in an older folder is picked up by the next full pass. `token_count` events are summed per `turn_context`
|
||||
model — `input_tokens` includes `cached_input_tokens` (stored as input − cached
|
||||
plus cache_read) and `output_tokens` includes reasoning. A **fork** copies its
|
||||
parent's whole history into its own file, token events included, so every total
|
||||
@@ -1654,7 +1663,9 @@ the binary's own proto descriptors.
|
||||
|
||||
**Sessions and tokens** (`server/lib/antigravity-usage.js`,
|
||||
`startAntigravityUsagePoll`, every `DASHBOARD_ANTIGRAVITY_POLL_MS`, default
|
||||
15000, `0` disables). agy writes one SQLite file per conversation,
|
||||
15000, `0` disables; after a pass that read nothing, wrote nothing and left no
|
||||
fresh session to settle it slows to once a minute until one finds work —
|
||||
`server/lib/backoff-poll.js`). agy writes one SQLite file per conversation,
|
||||
`~/.gemini/antigravity-cli/conversations/<conversation id>.db`. Each is opened
|
||||
read-only whenever its mtime (or its non-empty `-wal`'s) changes;
|
||||
`antigravity_conversations` holds the mtime read, so a session the user deleted
|
||||
@@ -1743,7 +1754,8 @@ is ever inferred.
|
||||
## Cursor Sessions
|
||||
|
||||
`server/lib/cursor-usage.js` (`startCursorUsagePoll`, every
|
||||
`DASHBOARD_CURSOR_POLL_MS`, default 15000, `0` disables) reads the agent
|
||||
`DASHBOARD_CURSOR_POLL_MS`, default 15000, `0` disables; idle passes slow it to
|
||||
once a minute like the Antigravity poll) reads the agent
|
||||
transcripts Cursor writes — the CLI (`cursor-agent`) and the editor's agent both —
|
||||
at `~/.cursor/projects/<project slug>/agent-transcripts/<id>/<id>.jsonl`
|
||||
(`AGENTHUB_CURSOR_DIR` overrides `~/.cursor`). Each becomes a `sessions` row with
|
||||
@@ -2225,7 +2237,7 @@ sequenceDiagram
|
||||
| Component | Responsibility |
|
||||
| --- | --- |
|
||||
| **`server/lib/update-check.js`** | `getUpdatesStatus(root?, { skipFetch?, latestRelease? })` + `applyUpdate(root?)`. Runs every git call via `execFile` (no shell, 10s–120s timeouts) and reads the newest release over `fetch` with an `AbortSignal.timeout` — `fetchLatestRelease` NEVER throws, resolving to `{ok:false, reason}` (`no_remote`, `remote_not_http`, `no_releases`, `unreachable`, `bad_payload`, `http_<status>`) so a status poll degrades instead of erroring. `releaseApiUrl` derives the endpoint from a repository URL (https, scp-style and `ssh://` forms) and returns null for anything not owner/repo shaped, which is what keeps local-path remotes off the network entirely. `fetchLatestRelease` finds that URL via the `origin` remote; `fetchReleaseFromUrl` takes one directly, which is how the **git-free plugin path** works: `pluginMarketplaceUrl(mkt, plugin)` reads `known_marketplaces.json` → the marketplace's `.claude-plugin/marketplace.json` → the entry's `source.url`, returning null (never a guessed host) for a `github` `owner/repo` shorthand and never throwing. **Order matters:** `detectPluginInstall` runs FIRST in `getUpdatesStatus`, ahead of the `.git` probe — a plugin cache unpacked from an archive has no `.git`, and probing first is the 1.5.1 bug that made `update_available` permanently false. `compareVersions` orders numeric triples and strips a leading `v`, since this repo carries both `v1.5.0` and `1.4.6`. `isShallowClone` gates every commit-distance calculation; `detectPluginInstall` reads the marketplace and plugin name out of the cache path. **Branch- and fork-aware** as before: resolves `<remote>/master`/`/main`/`/HEAD`, reads the current branch and its tracked upstream, and shapes `manual_command` per situation — the `claude plugin ...` commands for `plugin_managed`; `git fetch <remote> --tags && git merge --ff-only <tag>` for `release_available`; `git pull --ff-only` when the branch tracks the canonical ref; `git fetch <remote> && git merge --ff-only <ref>` for forks; `git fetch <remote>` only on a feature branch or detached HEAD. Non-git installs, missing remotes, fetch failures, and unresolvable upstream refs are returned as soft payloads — never throws. |
|
||||
| **`server/update-scheduler.js`** | Ticks the lib every `DASHBOARD_UPDATE_CHECK_INTERVAL_MS` (default 300 000, floor 60 000). First tick is scheduled 8s after server start with `.unref()` so it doesn't block shutdown. Broadcasts only when the fingerprint `{update_available, remote_sha, commits_behind, fetch_error, manual_command}` changes — `manual_command` is included so situation transitions (e.g. user switches branches, or adds an `upstream` remote) trigger a re-broadcast even when the SHA and commit count are unchanged. Emits a framed message to stdout on "up-to-date → behind" transitions, and only suggests "restart the dashboard" when the printed command actually rewrites the working tree. `DASHBOARD_UPDATE_CHECK=0\|false\|off` disables the scheduler entirely. |
|
||||
| **`server/update-scheduler.js`** | Ticks the lib every `DASHBOARD_UPDATE_CHECK_INTERVAL_MS` (default 3 600 000 = 1 h, floor 60 000). A tick with no `/ws` browser connected (`getConnectionCount`) is skipped, and the first browser to connect afterwards (`onConnection` in `server/websocket.js`) gets one check 3 s later if the last one is older than the interval; the manual `POST /api/updates/check` is unaffected. First tick is scheduled 8s after server start with `.unref()` so it doesn't block shutdown. Broadcasts only when the fingerprint `{update_available, remote_sha, commits_behind, fetch_error, manual_command}` changes — `manual_command` is included so situation transitions (e.g. user switches branches, or adds an `upstream` remote) trigger a re-broadcast even when the SHA and commit count are unchanged. Emits a framed message to stdout on "up-to-date → behind" transitions, and only suggests "restart the dashboard" when the printed command actually rewrites the working tree. `DASHBOARD_UPDATE_CHECK=0\|false\|off` disables the scheduler entirely. |
|
||||
| **`server/routes/updates.js`** | Three endpoints: `GET /status` (read-only check), `POST /check` (check + broadcast), `POST /apply`. `/apply` has **two modes**, chosen by how this copy was installed, never by the request body: **checkout** — fast-forward onto the release tag, `npm run setup`, `npm run build` in production (both through the bootstrap's `npmCommand` with `NODE_ENV` dropped — `npmInvocation` in `server/lib/update-check.js` — since the server's `NODE_ENV=production` makes npm omit the client's devDependency build toolchain, and a bare `npm.cmd` is EINVAL under `execFile` on Windows), then `scheduleRestart()`; **plugin** — `applyUpdate`'s `plugin_managed` refusal is caught and `schedulePluginUpdate()` runs instead, answering `200 {applied:true, mode:"plugin"}`. The plugin branch fires only when there is genuinely something to pull (`update_available` plus a marketplace and plugin name parsed from the cache path) — a `plugin_managed` refusal with nothing newer published stays a 409 rather than spawning a chain that would stop the server for nothing. Otherwise 409 `UPDATE_NOT_APPLICABLE` with `reason` `"up_to_date"`, `"not_fast_forwardable"`, or `"plugin_managed"`. Both modes write the response BEFORE anything stops the process. Mounted under `/api/*`, so all three ride the default loopback bind as their trust boundary and are gated by `DASHBOARD_TOKEN` when one is set. |
|
||||
| **`server/lib/plugin-update.js`** + **`scripts/plugin-update-helper.js`** | The plugin-cache update chain. The lib only spawns the helper detached (cwd = the runtime dir, **not** the plugin root, which is about to be superseded) and clears any previous verdict first so a stale success can't be read back as this run's result. All work is in the helper because its last step SIGTERMs the server that asked for the update — the same split as `self-restart.js`, one step further. The helper re-validates the marketplace and plugin names against `/^[A-Za-z0-9][\w.-]*$/` (they reach an argv across a process boundary), resolves `claude` on PATH or at `~/.local/bin/claude` (a hook-spawned server does not inherit the login shell's PATH), picks the newest cache directory that isn't the old root and does contain `scripts/plugin-bootstrap.js`, and records **every** outcome — including an unexpected throw — to `plugin-update.json` under `~/.claude/agenthub/runtime/`, outside the cache so it survives the swap. Steps: `validate`, `resolve_claude`, `marketplace_update`, `plugin_update`, `already_current` (`ok:true`; nothing was pulled and the old server was left running), `bootstrap`, `done`. `readLastPluginUpdate()` surfaces it as `last_plugin_update` on the status payload — without it a failed chain is indistinguishable from a slow one. |
|
||||
| **`UpdateNotifier.tsx`** | Modal. Hydrates from `api.updates.status()` on mount and mirrors the payload back into the local `eventBus` so the Sidebar can listen without a second git fetch. Subscribes to `update_status` WS frames for ongoing sync. Keeps the dismissal key in `localStorage` (`agent-monitor-update-dismissed-sha`) and in React state — the **release tag** when one was read, else the upstream SHA, so a newer release re-opens the prompt while a dismissed one stays dismissed; a window event `dashboard:reset-update-dismissal` from the Sidebar clears both. ESC / backdrop click dismisses. |
|
||||
@@ -2325,7 +2337,7 @@ The same shape is used by `GET /status`, `POST /check`, and the `update_status`
|
||||
- **Branch coverage.** A feature branch or detached HEAD would need branch-aware integration (rebase vs merge vs switch) and merge-conflict handling. That belongs in the user's shell, not in a background daemon — which is why those situations get a printed `git fetch` and no button.
|
||||
- **Ownership.** A plugin-cache install is not ours to rewrite by hand — its directory names are content hashes Claude Code manages. So that case does not fast-forward anything; it calls `claude plugin update` and then re-runs `plugin-bootstrap.js` from the directory that tool produced. The residual limit is only `/reload-plugins`, which is REPL-scoped: an interactive session keeps its old skills/commands until the user reloads, even though the dashboard server has already moved.
|
||||
|
||||
What `--ff-only` buys is the absence of the first three problems inside the supported cases: the move is either a clean fast-forward or it aborts, `npm run setup`/`build` failures reject the request before any restart is scheduled, and the restart itself goes through `self-restart.js`: a server our own service unit runs (its cgroup ends in `/agenthub.service`, or it is our launchd job) is restarted BY that manager (`systemctl restart --no-block`, via `sudo -n` for the server-mode system unit; launchd's KeepAlive after an exit), anything else by the detached `scripts/restart-helper.js`. A detached replacement under a `Restart=always` unit held the port while the unit restarted every 3 s, each start exiting "already running" (v2.7.2).
|
||||
What `--ff-only` buys is the absence of the first three problems inside the supported cases: the move is either a clean fast-forward or it aborts, `npm run setup`/`build` failures reject the request before any restart is scheduled, and the restart itself goes through `self-restart.js`: a server our own service unit runs (its cgroup ends in `/agenthub.service`, or it is our launchd job) is restarted BY that manager (`systemctl restart --no-block`, via `sudo -n` for the server-mode system unit; launchd's KeepAlive after an exit), anything else by the detached `scripts/restart-helper.js`, which appends the new server's stdout/stderr to `data/agenthub-server.log` like `agenthub start` (it used to discard them, so a crash after an update left no stack trace). A detached replacement under a `Restart=always` unit held the port while the unit restarted every 3 s, each start exiting "already running" (v2.7.2).
|
||||
|
||||
---
|
||||
|
||||
@@ -2356,7 +2368,7 @@ graph TD
|
||||
SHELL["Sagi.tsx<br/>shell: open/closed state,<br/>⌘B / Esc, reduced-motion,<br/>navigation"]
|
||||
AVATAR["SagiAvatar.tsx<br/>SVG mascot; data-mood drives CSS;<br/>cursor-tracking pupils"]
|
||||
BUBBLE["SpeechBubble.tsx<br/>bubble"]
|
||||
PANEL["SagiPanel.tsx<br/>status + Needs you + Mute / Clear alerts + Ask box"]
|
||||
PANEL["PetChat.tsx + SagiInfo.tsx<br/>the chat window: status strip, Needs you,<br/>Mute / Clear alerts, local answers, AI chat"]
|
||||
CSS["sagi.css<br/>keyframes + per-mood expressions"]
|
||||
end
|
||||
|
||||
@@ -2386,8 +2398,8 @@ flowchart LR
|
||||
SUB --> DERIVED["derived state<br/>{ mood, status, bubble }"]
|
||||
DERIVED --> AVATAR["SagiAvatar"]
|
||||
DERIVED --> BUBBLE["SpeechBubble"]
|
||||
DERIVED --> PANEL["SagiPanel"]
|
||||
PANEL -->|"unmatched Ask"| ENSURE["POST /api/lanes/ensure<br/>(adopt 'Sagi' lane, cwd = home)"]
|
||||
DERIVED --> PANEL["PetChat (+ SagiInfo)"]
|
||||
PANEL -->|"unmatched question"| ENSURE["POST /api/lanes/ensure<br/>(adopt 'Sagi' lane, cwd = home)"]
|
||||
ENSURE --> START["POST /api/run<br/>(spawn tmux-backed claude<br/>in Sagi lane, initialPrompt=…)"]
|
||||
START --> RUN["/run?laneId=…<br/>(existing Run page picks up<br/>the live run)"]
|
||||
|
||||
@@ -2407,10 +2419,10 @@ The mood state machine in `deriveMood` resolves to a single expression using a f
|
||||
| **`useSagiBrain.ts`** | The **only** consumer of the global `eventBus`. Wires the pure brain to real timers (idle / sleep / stuck), the speech bubble (naming the session it is about; the same pulse again within 8 s joins it as a count), the one-shot `moment` animation, a once-per-browser-session greeting, an idle fidget every minute or so, pat, mute, and clear-alerts (the dismissed ids persist in `localStorage` `agenthub.pet.dismissedErrors`, newest 200). The reducer runs outside React's updater so StrictMode never doubles a pulse. Produces `{ mood, status, attention, bubble, moment }`. |
|
||||
| **`SagiAvatar.tsx`** | Pure presentational SVG mascot. The `data-mood` attribute drives CSS; pupils track the cursor. Rendered at 90×90 px (`size` default, kept in sync with `SAGI_SIZE` in `useSagiPosition.ts`, which is the draggable button's real footprint) through a `viewBox` cropped to the artwork's measured bounding box instead of the exporter's padded `0 0 1280 1280` canvas. Path data is never rescaled, so `MAX_PUPIL_SHIFT` stays in the original 1280-unit space. The `transform-origin` values in `sagi.css` do **not**: an origin on an inner SVG element resolves against `transform-box: view-box`, whose 0,0 sits at the `viewBox` min (`253.5 106`), so those are mascot coordinates **minus that offset**. The root `.sagi-avatar` origin is a percentage on purpose — on the outermost `<svg>` a length would be measured against the 68 px CSS box, and the resulting ~9× overshoot turns every mood animation into a whole-avatar lurch. |
|
||||
| **`SpeechBubble.tsx`** | Pure presentational speech bubble: the line, the session(s) it is about, and an **Open** button for a single one (`workspaceHref` — its live pane, else its replay, else `/sessions/<id>`; never a resume). |
|
||||
| **`SagiPanel.tsx`** | Pure presentational panel: the pet's name (click to rename — `agenthub.pet.names`, synced to the account), a pat button, status readout, a **Needs you** list of errored/waiting sessions by name, Mute and Clear alerts, and the Ask box. |
|
||||
| **`PetChat.tsx`** | The pet's chat window (Ask handoff, or **Chat with …** in the panel), styled as a soft accent gradient with chat bubbles. The first message starts a session of the user's default AI tool (`agenthub.defaultTool`; Claude or Codex — any other tool still opens its pane in the workspace) in the "Sagi" workspace's first free tab; replies are read from the session's transcript every 1.5 s, and follow-ups and menu answers (`PaneMenuCard`) go through a `TerminalView` attached off-screen (`submitToRun`), so no new route exists. The chat is kept in `localStorage` (`agenthub.pet.chat`) while its pane lives; an ended pane is never resumed — the next message starts a new session. |
|
||||
| **`Sagi.tsx`** | Shell component. A pat (the panel's heart, the Ask box, or stroking the pet — four left/right turns of the pointer over it within 1.2 s) shows hearts and a line ON the pet, so it reads while the panel or chat is open. Mounted once in `client/src/components/Layout.tsx` as a sibling of `UpdateNotifier`. Owns open/closed state, the `⌘B` / `Esc` shortcuts, reduced-motion detection, and navigation. |
|
||||
| **`intents.ts`** | Local Q&A over the cached status snapshot, in English and Vietnamese keywords, answering in the UI language and naming the sessions it counts (also: who are you, hello, thanks, pat). Queries that don't match a local intent open `PetChat`; with a default tool it cannot read back, `Sagi.tsx` instead calls `POST /api/lanes/ensure` (adopting the "Sagi" lane whose cwd defaults to `$HOME`), then `POST /api/run` to spawn a fresh tmux-backed `claude` session in that lane with the prompt as its `initialPrompt`, then navigates to `/run?laneId=…&autostart=1` so the live run is observable. |
|
||||
| **`SagiInfo.tsx`** | Pure presentational pieces of the chat window: `PetNameButton` (click to rename — `agenthub.pet.names`, synced to the account) and `SagiInfo`, the status strip under the header — live / waiting / errored and the connection always shown; expanded (by default while something needs you) a **Needs you** list of errored/waiting sessions by name, Mute and Clear alerts. |
|
||||
| **`PetChat.tsx`** | The pet's whole UI — clicking the pet opens it (there is no separate panel): header (name, pat heart, Open in workspace, New chat, close), the `SagiInfo` strip, then the conversation. Before a chat runs, a question `intents.ts` can answer is answered in place (`onLocal`); anything else goes to the AI. Styled as a soft accent gradient with chat bubbles. The first message starts a session of the user's default AI tool (`agenthub.defaultTool`; Claude or Codex — any other tool still opens its pane in the workspace) in the "Sagi" workspace's first free tab; replies are read from the session's transcript every 1.5 s, and follow-ups and menu answers (`PaneMenuCard` — numbered menus and Claude's unnumbered folder-trust prompt alike) go through a `TerminalView` attached off-screen (`submitToRun`), so no new route exists. 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 (`DELETE /api/run/:id?archive=1`, asking first only mid-reply); an ended pane is never resumed — the next message starts a new session. |
|
||||
| **`Sagi.tsx`** | Shell component. A pat (the chat's heart, a typed pat, or stroking the pet — four left/right turns of the pointer over it within 1.2 s) shows hearts and a line ON the pet, so it reads while the chat is open. Mounted once in `client/src/components/Layout.tsx` as a sibling of `UpdateNotifier`. Owns the chat's open/closed state, the `⌘B` / `Esc` shortcuts, reduced-motion detection, and navigation. |
|
||||
| **`intents.ts`** | Local Q&A over the cached status snapshot, in English and Vietnamese keywords, answering in the UI language and naming the sessions it counts (also: who are you, hello, thanks, pat). Queries that don't match a local intent go to `PetChat`'s AI session; with a default tool it cannot read back, `Sagi.tsx` instead calls `POST /api/lanes/ensure` (adopting the "Sagi" lane whose cwd defaults to `$HOME`), then `POST /api/run` to spawn a fresh tmux-backed `claude` session in that lane with the prompt as its `initialPrompt`, then navigates to `/run?laneId=…&autostart=1` so the live run is observable. |
|
||||
| **`quips.ts`** | Mood/pulse → phrase pools, plus greetings and pat lines, read from `settings:sagi.{quips,greet,pat}` so they follow the UI language. |
|
||||
| **`prefs.ts`** | `localStorage`-backed enabled / muted / pet / size / nickname preferences with cross-tab sync; `petName` = nickname, else the worn pet's own name, else Sagi. |
|
||||
| **`sagi.css`** | Keyframes and per-mood expressions (`data-mood`), plus the one-shot moments (`data-moment`: jump, wave, fail, wait, review) on Sagi's art and an uploaded image. |
|
||||
|
||||
+407
-82
@@ -1,6 +1,6 @@
|
||||
# Installation
|
||||
|
||||
A step-by-step guide to get AgentHub — the local control surface for Claude Code agents — up and running on your machine, with optional sections for importing history and running in a container.
|
||||
The one install-and-setup guide for AgentHub — the local control surface for Claude Code agents: the plugin install, a checkout install, configuration, the database, importing history, and troubleshooting. (`SETUP.md` is only a pointer here now.)
|
||||
|
||||
## Fastest path — install it as a Claude Code plugin
|
||||
|
||||
@@ -17,17 +17,20 @@ on PATH — no clone, no `npm run setup`, no
|
||||
result and `/agenthub-open` to build the UI and get the URL.
|
||||
|
||||
Requires Node **>= 22.5** (a plugin install has no native `better-sqlite3`, so
|
||||
the server stores data through `node:sqlite`). Full behavior, including the
|
||||
uninstall cleanup (`agenthub uninstall`), is in [`docs/PLUGINS.md`](docs/PLUGINS.md).
|
||||
the server stores data through `node:sqlite`), plus `git` and `tmux` from
|
||||
[Step 0](#step-0--system-packages). Full behavior, including the uninstall
|
||||
cleanup (`agenthub uninstall`), is in [`docs/PLUGINS.md`](docs/PLUGINS.md). If
|
||||
the dashboard never comes up, see
|
||||
[Plugin bootstrap never comes up](#plugin-bootstrap-never-comes-up).
|
||||
|
||||
Follow the steps below instead when you want a checkout — to develop the
|
||||
dashboard, run it from source, or stay on Node 20.
|
||||
dashboard or run it from source.
|
||||
|
||||
## Requirements
|
||||
|
||||
| Requirement | Version | Notes |
|
||||
|---|---|---|
|
||||
| Node.js | 20+ (22+ recommended) | Required for server and client |
|
||||
| Node.js | **22.5+** | Required for server and client (`node:sqlite`). Node 20 is end-of-life and unsupported |
|
||||
| npm | 9+ | Comes with Node.js |
|
||||
| Claude Code | 2.x+ | Required for hook integration |
|
||||
| Git | Any | Cloning the repository, and every lane operation (worktrees, branch/status reads) |
|
||||
@@ -76,7 +79,7 @@ Check it:
|
||||
```bash
|
||||
tmux -V # e.g. tmux 3.4
|
||||
git --version
|
||||
node -v # v20.x / v22.x / v24.x
|
||||
node -v # v22.5+ / v24.x
|
||||
```
|
||||
|
||||
Once the server is up you can ask it directly — this is the same probe the
|
||||
@@ -113,8 +116,8 @@ brew install node git tmux # Node >= 22.5 for the plugin install (nvm work
|
||||
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc # where the plugin puts `agenthub`
|
||||
```
|
||||
|
||||
- **Node:** a plugin install needs **>= 22.5** (it stores data through
|
||||
`node:sqlite`); a checkout still runs on Node 20. The `agenthub` launcher
|
||||
- **Node:** **>= 22.5** (the plugin stores data through `node:sqlite`; a
|
||||
checkout needs the same). The `agenthub` launcher
|
||||
pins the node that installed it and falls back to `node` on `PATH` when a
|
||||
`brew upgrade` or `nvm` switch deletes that binary.
|
||||
- **Starts at login once you turn it on (LaunchAgent).** Off by default; with
|
||||
@@ -172,24 +175,20 @@ cd AgentHub
|
||||
## Step 2 — Install dependencies
|
||||
|
||||
```bash
|
||||
npm run setup
|
||||
npm run setup # or: make setup
|
||||
```
|
||||
|
||||
This installs all server and client dependencies, plus the VS Code extension, and links the `agenthub` CLI.
|
||||
This runs `npm install` (whose `postinstall` hook installs the client's
|
||||
dependencies in `client/`), installs `client/` once more explicitly, and links
|
||||
the `agenthub` CLI (`npm link`; if that has no permission it prints how to link
|
||||
it by hand). A plain `npm install` is enough to build and run the dashboard —
|
||||
only the CLI link is missing.
|
||||
|
||||
A plain root install already covers server **and** client — a `postinstall` hook installs the client dependencies automatically, so this alone is enough to build and run the dashboard:
|
||||
With `--ignore-scripts` the `postinstall` hook is skipped; install the client
|
||||
yourself with `npm --prefix client install`.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
`npm run setup` additionally installs the VS Code extension and links the `agenthub` CLI. (If you install with `--ignore-scripts`, the `postinstall` hook is skipped — run `cd client && npm install` manually in that case.)
|
||||
|
||||
Or via Makefile:
|
||||
|
||||
```bash
|
||||
make setup
|
||||
```
|
||||
`better-sqlite3` gyp/compile warnings here are harmless — see
|
||||
[Troubleshooting](#better-sqlite3-errors-during-npm-install--npm-run-setup).
|
||||
|
||||
---
|
||||
|
||||
@@ -234,7 +233,7 @@ After starting a Claude Code session, you should see:
|
||||
- **Dashboard** — stats updating in real-time
|
||||
- **Settings page** — data export and cleanup tools on the Dashboard tab; Claude Code's model pricing rules, hook configuration status and CLAUDE_HOME on **Settings → Claude Code → Overview**
|
||||
|
||||
If nothing appears after 30 seconds, see [SETUP.md](./SETUP.md#troubleshooting).
|
||||
If nothing appears after 30 seconds, see [Troubleshooting](#no-sessions-appearing-after-starting-claude-code).
|
||||
|
||||
### PWA install (optional)
|
||||
|
||||
@@ -246,57 +245,110 @@ The dashboard is a Progressive Web App. After opening it in a supported browser
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — (Optional) Import existing Claude Code history
|
||||
## Step 5 — (Optional) Importing existing Claude Code history
|
||||
|
||||
The server **automatically imports** every session under
|
||||
`~/.claude/projects/` on startup, so if you've used Claude Code on this
|
||||
machine before, your historical sessions, agents, events, token counts,
|
||||
and cost totals should already be visible in the Sessions list.
|
||||
The dashboard automatically imports sessions from `~/.claude/projects/` on
|
||||
**every startup**, so if Claude Code has been used on this machine, you'll
|
||||
see history immediately after the first launch. If you need to bring in
|
||||
history from another machine, from a backup, or just force a rescan, use
|
||||
**Settings → Import History** in the UI — it's a guided, drag-and-drop
|
||||
experience with live progress.
|
||||
|
||||
To bring in history from another machine, a backup, or a `.tar.gz`
|
||||
archive a teammate sent you, use **Settings → Import History** in the
|
||||
UI. It supports three modes:
|
||||
### Pick the right mode
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
UI["Settings →<br/>Import History"] --> M1["Rescan default folder<br/>~/.claude/projects"]
|
||||
UI --> M2["Scan a folder<br/>any absolute path"]
|
||||
UI --> M3["Upload files<br/>.jsonl / .zip / .tar.gz / .gz"]
|
||||
M1 --> P["Same parser as live<br/>hook ingestion"]
|
||||
M2 --> P
|
||||
M3 --> P
|
||||
P --> DB[("SQLite<br/>sessions + tokens + cost")]
|
||||
flowchart TD
|
||||
Q["Where is the history?"] --> Q1{Is it on this machine<br/>under ~/.claude/projects?}
|
||||
Q1 -->|yes, and I just want<br/>to re-scan| M1["Mode: Rescan default folder<br/>one click"]
|
||||
Q1 -->|yes, but in another folder<br/>on this machine| M2["Mode: Scan a folder<br/>paste the absolute path"]
|
||||
Q1 -->|no — it's on another machine<br/>or in an archive file| M3["Mode: Upload files<br/>drag-drop JSONL or archive"]
|
||||
|
||||
style UI fill:#a855f7,stroke:#c084fc,color:#fff
|
||||
style P fill:#f59e0b,stroke:#fbbf24,color:#000
|
||||
style DB fill:#10b981,stroke:#34d399,color:#fff
|
||||
M3 --> PREP["Archive source:<br/>tar -czf claude-history.tar.gz<br/>-C ~/.claude projects"]
|
||||
|
||||
style M1 fill:#10b981,stroke:#34d399,color:#fff
|
||||
style M2 fill:#f59e0b,stroke:#fbbf24,color:#000
|
||||
style M3 fill:#a855f7,stroke:#c084fc,color:#fff
|
||||
```
|
||||
|
||||
Re-imports are idempotent: sessions are deduplicated by UUID and
|
||||
compaction baselines preserve pre-compaction token totals, so running
|
||||
the importer twice never double-counts tokens or cost.
|
||||
### Step-by-step: moving history from one machine to another
|
||||
|
||||
Verify it worked by opening the **Analytics** page and checking that
|
||||
per-model token totals and estimated cost look correct. Full walkthrough
|
||||
with per-OS archive commands in
|
||||
[SETUP.md → Importing existing Claude Code history](./SETUP.md#importing-existing-claude-code-history).
|
||||
### Optional: tune import limits
|
||||
**On the source machine**, bundle the projects folder:
|
||||
|
||||
If you regularly import very large archives, these environment variables
|
||||
can be raised (the defaults are generous for typical use):
|
||||
```bash
|
||||
# macOS / Linux
|
||||
tar -czf claude-history.tar.gz -C ~/.claude projects
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
| --------------------------------- | ------- | ----------------------------------------------------------- |
|
||||
| `AGENTHUB_IMPORT_MAX_BYTES` | 1 GB | Maximum size per uploaded file |
|
||||
| `AGENTHUB_IMPORT_MAX_FILES` | 2000 | Maximum files per upload request |
|
||||
| `AGENTHUB_IMPORT_MAX_EXTRACT_BYTES` | 4 GB | Ceiling on uncompressed bytes per archive (zip-bomb guard) |
|
||||
# Windows (PowerShell, via built-in tar)
|
||||
tar -czf claude-history.tar.gz -C "$env:USERPROFILE\.claude" projects
|
||||
```
|
||||
|
||||
Set them before `npm run dev` or `npm start`:
|
||||
Transfer the resulting `claude-history.tar.gz` to the destination machine
|
||||
however you like — AirDrop, `scp`, USB, cloud storage.
|
||||
|
||||
**On the destination machine**, in the dashboard:
|
||||
|
||||
1. Open **Settings → Import History**.
|
||||
2. Pick **Upload files** (the third tab).
|
||||
3. Drag the archive onto the drop zone.
|
||||
4. Click **Upload & Import** and watch the progress.
|
||||
5. When the green result card appears, open **Analytics → Cost** to confirm
|
||||
per-model token totals and estimated cost.
|
||||
|
||||
### Supported inputs
|
||||
|
||||
Any of the following can be dropped onto the upload zone or found inside a
|
||||
folder given to **Scan a folder**:
|
||||
|
||||
- `.jsonl` — session transcripts
|
||||
- `.meta.json` — subagent metadata sidecars
|
||||
- `.zip` — extracted with path-traversal protection
|
||||
- `.tar`, `.tar.gz`, `.tgz` — extracted via the `tar` package
|
||||
- `.gz` — single gzipped JSONL (streaming-decompressed)
|
||||
|
||||
### Accuracy guarantees
|
||||
|
||||
- **Idempotent** — re-importing never double-counts. Sessions are
|
||||
deduplicated by UUID.
|
||||
- **Cost-preserving** — the `token_usage` table uses `baseline_*` columns
|
||||
to preserve pre-compaction token totals, so re-ingesting a compacted
|
||||
transcript never erases historical cost.
|
||||
- **Same parser as live** — `parseSessionFile` + `importSession` is the
|
||||
single source of truth for both hook-driven ingestion and manual
|
||||
import, so imported numbers match captured numbers exactly.
|
||||
|
||||
### Safety
|
||||
|
||||
Archive extraction is hardened against path traversal and archive bombs.
|
||||
The defaults are generous for real-world transcripts but tight enough to
|
||||
stop obvious attacks. If you regularly import very large archives, raise them
|
||||
before `npm run dev` or `npm start` (also listed under
|
||||
[Environment variables](#environment-variables)):
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
| ----------------------------------- | ------- | ---------------------------------------------------------- |
|
||||
| `AGENTHUB_IMPORT_MAX_BYTES` | 1 GB | Maximum size per uploaded file |
|
||||
| `AGENTHUB_IMPORT_MAX_FILES` | 2000 | Maximum files per upload request |
|
||||
| `AGENTHUB_IMPORT_MAX_EXTRACT_BYTES` | 4 GB | Ceiling on uncompressed bytes per archive (zip-bomb guard) |
|
||||
|
||||
```bash
|
||||
AGENTHUB_IMPORT_MAX_EXTRACT_BYTES=17179869184 npm start # allow 16 GB extraction
|
||||
```
|
||||
|
||||
### CLI alternative
|
||||
|
||||
For scripts and automation, the same logic runs from the terminal:
|
||||
|
||||
```bash
|
||||
# Import (or re-import) everything under ~/.claude/projects
|
||||
npm run import-history
|
||||
|
||||
# Dry run — show what would be imported without writing
|
||||
node scripts/import-history.js --dry-run
|
||||
|
||||
# Scope to a single project dir
|
||||
node scripts/import-history.js --project my-project
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Production mode
|
||||
@@ -328,44 +380,260 @@ and, for plugin installs, [`docs/PLUGINS.md` → Auto-start on boot](docs/PLUGIN
|
||||
|
||||
---
|
||||
|
||||
## Optional: Agent extension packs
|
||||
## How it works
|
||||
|
||||
This repository includes extension packs for both Claude Code and Codex.
|
||||
AgentHub integrates with Claude Code through its native hook system. When Claude Code performs any action (session start, tool use, turn completion, subagent finish, session exit), it fires a hook that calls a small Node.js script bundled with this project. That script forwards the event over HTTP to the dashboard server, which stores it in SQLite and broadcasts it to the browser over WebSocket.
|
||||
|
||||
- Claude Code loads project extensions from:
|
||||
```
|
||||
Claude Code → hook fires → hook-handler.js → POST /api/hooks/event
|
||||
↓
|
||||
Browser ← WebSocket broadcast ← Express server ← SQLite
|
||||
```
|
||||
|
||||
No extra Claude Code configuration is required in the normal host-run path — when you start the dashboard with `npm run dev` or `npm start`, the server configures the hooks automatically on startup (or, with the `agenthub` plugin enabled in `~/.claude/settings.json`, removes the checkout's copies — the plugin's own hooks already run, and both would post every event twice). Container deployments are the exception: after the container is up, run `npm run install-hooks` on the host so Claude Code points at `http://localhost:4820`.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Hook auto-installation
|
||||
|
||||
When the dashboard is running directly on the host, the server writes the following to `~/.claude/settings.json` every time it starts:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [{ "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" SessionStart" }] }],
|
||||
"PreToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" PreToolUse" }] }],
|
||||
"PostToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" PostToolUse" }] }],
|
||||
"Stop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" Stop" }] }],
|
||||
"SubagentStop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" SubagentStop" }] }],
|
||||
"Notification": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" Notification" }] }],
|
||||
"SessionEnd": [{ "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" SessionEnd" }] }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Note: `SessionStart` and `SessionEnd` hooks do not support the `matcher` field — they fire unconditionally on every session start and exit.
|
||||
|
||||
Existing hooks in that file are preserved. The dashboard only adds or updates entries that contain `hook-handler.js`.
|
||||
|
||||
To re-run hook installation manually:
|
||||
|
||||
```bash
|
||||
npm run install-hooks
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> Container note: if you run the dashboard inside a container (a Dev Container, Codespaces, or your own image), do not install hooks from in there — the hook path a container writes points at the container filesystem, not the host. Run `npm run install-hooks` on the host instead. As a safeguard (issue #193) the installer **detects container execution and refuses to run** (exiting non-zero) so it can never poison a bind-mounted host `~/.claude`. If Claude Code itself genuinely runs inside that container, override with `AGENTHUB_ALLOW_CONTAINER_HOOKS=1 npm run install-hooks`.
|
||||
|
||||
> [!NOTE]
|
||||
> Prefer a ready-made dev environment? This repo ships an **optional** Dev Container (`.devcontainer/`) for VS Code / GitHub Codespaces — Node 22, native build tools for `better-sqlite3`, Python, and ports `4820`/`5173` preconfigured. It's purely opt-in and changes nothing for host-based development. See [`.devcontainer/README.md`](.devcontainer/README.md). (Hooks remain host-side there too.)
|
||||
|
||||
### Environment variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `DASHBOARD_PORT` | `4820` | Port the Express server listens on |
|
||||
| `CLAUDE_DASHBOARD_PORT` | `4820` | Port the hook handler uses when posting events to the dashboard |
|
||||
| `DASHBOARD_DB_PATH` | `data/dashboard.db` | Path to the SQLite database file |
|
||||
| `NODE_ENV` | `development` | Set to `production` to serve built client |
|
||||
| `DASHBOARD_CLIENT_DIST` | `client/dist` | Directory the built UI is served from in production. Set by the `agenthub` plugin bootstrap to its own runtime dir, because the plugin cache is replaced on every update |
|
||||
| `AGENTHUB_IMPORT_MAX_BYTES` | `1073741824` (1 GB) | Maximum size per uploaded file on `/api/import/upload` |
|
||||
| `AGENTHUB_IMPORT_MAX_FILES` | `2000` | Maximum number of files per upload request |
|
||||
| `AGENTHUB_IMPORT_MAX_EXTRACT_BYTES` | `4294967296` (4 GB) | Maximum uncompressed bytes any single archive is allowed to expand to (zip-bomb defense) |
|
||||
| `DASHBOARD_DATA_DIR` | `~/.claude/agenthub` | AgentHub's data dir; a plugin install keeps its runtime (deps, UI build, logs) in `runtime/` under it |
|
||||
|
||||
Example with a custom port:
|
||||
|
||||
```bash
|
||||
DASHBOARD_PORT=9000 npm run dev
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> You usually do **not** need to set `DASHBOARD_PORT` manually. `npm run dev` is wrapped by `scripts/dev.js`, which probes both `127.0.0.1` and `::1` (so an SSH `LocalForward` bound to one loopback can't slip past) and picks the first free port in `4820–4859` automatically. The chosen port is propagated to the Vite dev proxy via `DASHBOARD_PORT`, and the Express server writes it to `~/.claude/.agenthub.json` so the Claude Code hook handler discovers it without any env var.
|
||||
>
|
||||
> Multiple dashboards can run side by side — for example two `npm run dev` checkouts, or `npm run dev` alongside `npm start`. Each one appends its `{port, pid, startedAt}` entry to the discovery file, and `scripts/hook-handler.js` fan-outs every hook event to every live entry, so both UIs keep their real-time stream.
|
||||
>
|
||||
> Setting `CLAUDE_DASHBOARD_PORT=N` overrides discovery entirely and forces the hook handler to a single port — useful for tests and container setups where the in-process discovery file isn't reachable from the host.
|
||||
>
|
||||
> If you bypass the picker (e.g. `npm run dev:raw`, container builds, or anything else that calls `node server/index.js` directly), make sure your client is built / proxied against the port the server actually bound.
|
||||
|
||||
### Agent extension setup (Claude Code + Codex)
|
||||
|
||||
This repository ships extension files for both agent ecosystems:
|
||||
|
||||
- Claude Code:
|
||||
- `CLAUDE.md`
|
||||
- `.claude/rules/`
|
||||
- `skills/` (shipped as the `agenthub` plugin's skills)
|
||||
- Codex project packs live under `.codex/`:
|
||||
- `.claude/rules/*`
|
||||
- `skills/*` (the `agenthub` plugin's own skills)
|
||||
- Codex:
|
||||
- `AGENTS.md`
|
||||
- `.codex/rules/`
|
||||
- `.codex/agents/`
|
||||
- `.codex/skills/`
|
||||
- `.codex/config.toml`
|
||||
- `.codex/rules/default.rules`
|
||||
- `.codex/agents/*`
|
||||
- `.codex/skills/*`
|
||||
|
||||
See [`.codex/README.md`](./.codex/README.md) for Codex extension details.
|
||||
|
||||
### PWA configuration (optional)
|
||||
|
||||
The dashboard ships as a Progressive Web App. No configuration is required — its manifest and service worker are included out of the box.
|
||||
|
||||
**Customising the manifest:** Edit `client/public/manifest.json`. Common fields to change:
|
||||
|
||||
- `name` / `short_name` — displayed on the home screen / dock
|
||||
- `theme_color` — address bar / title bar tint (default: `#6366f1`)
|
||||
- `background_color` — splash screen background
|
||||
- `start_url` — entry point when launched from home screen
|
||||
|
||||
**Updating the service worker cache:** The SW has a `CACHE_NAME` constant (e.g. `dashboard-v2`). After deploying new assets, bump the version string to force browsers to re-fetch — though for the dashboard this is rarely needed: hashed `/assets/*` URLs are immutable per build, everything else is fetched network-first with cache fallback, and a `controllerchange` listener in the client reloads the page exactly once when a new SW takes over, so a rebuild propagates without a hard refresh.
|
||||
|
||||
**Browser support:** PWA install prompts appear in Chrome 107+, Edge 107+, and Firefox 110+ (desktop and Android). Safari supports `apple-mobile-web-app-capable` for iOS home-screen mode but does not show an install banner.
|
||||
|
||||
**Verifying PWA status:** Open DevTools → Application → Manifest to confirm the manifest loads. Check the Service Workers section to verify the SW is registered and active. The Lighthouse PWA audit should pass all core checks.
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
|
||||
The SQLite database is created automatically on first run (`data/dashboard.db` in a checkout, unless `DASHBOARD_DB_PATH` says otherwise). The directory is created if it does not exist. The database uses WAL mode for concurrent reads and foreign keys for referential integrity.
|
||||
|
||||
### Clear all data
|
||||
|
||||
To remove all sessions, agents, events, and token usage (useful after running seed data or for a clean start):
|
||||
|
||||
```bash
|
||||
npm run clear-data
|
||||
```
|
||||
|
||||
### Data management via Settings page
|
||||
|
||||
The Settings page (`/settings`) provides a UI for the following. **Model Pricing**
|
||||
and **Hook Configuration** are not on its Dashboard tab: they sit under the
|
||||
Claude Code tab's **Overview** (Settings → Claude Code → Overview), beside
|
||||
CLAUDE_HOME; Codex (its Overview) and Antigravity have their own pricing and
|
||||
hooks in their own tabs.
|
||||
|
||||
- **Model Pricing** — view and edit per-model cost rates, reset to defaults, add custom models
|
||||
- **Hook Configuration** — check which hooks are installed and reinstall them
|
||||
- **Data Export** — download all sessions, agents, events, and pricing as a JSON file
|
||||
- **Session Cleanup** — abandon stale active sessions after N hours, purge old completed sessions after N days
|
||||
- **Clear All Data** — remove all sessions, agents, events, and token usage
|
||||
- **Data Management** and **About** sections render with loading placeholders while server info is being fetched, so the page is always fully navigable
|
||||
|
||||
### Seed demo data
|
||||
|
||||
To populate the dashboard with sample sessions, agents, and events for UI exploration:
|
||||
|
||||
```bash
|
||||
npm run seed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scripts reference
|
||||
|
||||
| Script | Command | Description |
|
||||
|---|---|---|
|
||||
| `setup` | `npm run setup` | Install all dependencies (server + client) |
|
||||
| `dev` | `npm run dev` | Start server + client in development mode |
|
||||
| `start` | `npm start` | Start server in production mode |
|
||||
| `build` | `npm run build` | Build the React client to `client/dist/` |
|
||||
| `install-hooks` | `npm run install-hooks` | Write Claude Code hooks to `~/.claude/settings.json` |
|
||||
| `clear-data` | `npm run clear-data` | Delete all data from the database |
|
||||
| `seed` | `npm run seed` | Insert demo sessions/agents/events |
|
||||
| `import-history` | `npm run import-history` | Import legacy sessions from `~/.claude/` (also runs on startup) |
|
||||
| `claude` | Claude CLI | Uses `CLAUDE.md`, `.claude/rules`, and `.claude/skills` automatically |
|
||||
| `test` | `npm test` | Run all server and client tests |
|
||||
| `test:server` | `npm run test:server` | Run server integration tests only |
|
||||
| `test:client` | `npm run test:client` | Run client unit tests only |
|
||||
| `format` | `npm run format` | Format all files with Prettier |
|
||||
| `format:check` | `npm run format:check` | Check formatting without writing |
|
||||
|
||||
---
|
||||
|
||||
## Makefile targets
|
||||
|
||||
All npm scripts are mirrored as `make` targets for convenience. Run `make help` to list them:
|
||||
|
||||
```bash
|
||||
make help
|
||||
```
|
||||
|
||||
Commonly used targets:
|
||||
|
||||
| Make target | Equivalent npm command | Description |
|
||||
|---|---|---|
|
||||
| `make setup` | `npm run setup` | Install all dependencies (root + client) |
|
||||
| `make dev` | `npm run dev` | Start server + client in watch mode |
|
||||
| `make build` | `npm run build` | Build the React client for production |
|
||||
| `make start` | `npm start` | Start the production server |
|
||||
| `make prod` | `npm run build && npm start` | Build then start in one step |
|
||||
| `make test` | `npm test` | Run all tests (server + client) |
|
||||
| `make test-server` | `npm run test:server` | Run server tests only |
|
||||
| `make test-client` | `npm run test:client` | Run client tests only |
|
||||
| `make format` | `npm run format` | Format all files with Prettier |
|
||||
| `make format-check` | `npm run format:check` | Check formatting without writing |
|
||||
| `make seed` | `npm run seed` | Load demo data |
|
||||
| `make clear-data` | `npm run clear-data` | Delete all data rows |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `npm run setup` shows `better-sqlite3` errors
|
||||
### Plugin bootstrap never comes up
|
||||
|
||||
This is expected and **non-fatal**. `better-sqlite3` is a native C++ module listed as an optional dependency. If prebuilt binaries are not available for your Node version or platform, npm will print gyp/compilation errors but still complete successfully.
|
||||
The plugin's `SessionStart` hook only checks a fast path and hands the real
|
||||
work (npm install, UI build, server start) to a detached background worker, so
|
||||
a failure there does not show in the session. When the worker's last run
|
||||
failed, the next session start prints a one-time **AgentHub:** message naming
|
||||
the log to read. Everything is logged under `~/.claude/agenthub/runtime/`:
|
||||
|
||||
At runtime the server uses this fallback chain:
|
||||
| File | What it holds |
|
||||
|---|---|
|
||||
| `bootstrap.log` | Each bootstrap step and why it stopped (Node too old, install failed, superseded build, …) |
|
||||
| `npm-install.log` | npm's output for the runtime dependency install |
|
||||
| `client-build.log` | The dashboard UI build (the API still works when only this fails) |
|
||||
| `server.log` | The server's own output, when it was started detached |
|
||||
| `last-failure.json` | The last failure the session-start message reports (removed once a bootstrap succeeds) |
|
||||
|
||||
1. **`better-sqlite3`** — used when prebuilt binaries are available (Node 20/22/24 on Windows x64, macOS arm64/x64, Linux x64/arm64)
|
||||
2. **`node:sqlite`** — Node.js built-in SQLite module, used automatically on Node 22+ when `better-sqlite3` is unavailable
|
||||
Then run `/agenthub-doctor` from Claude Code — it checks Node, the runtime
|
||||
dependencies, the server and the service unit, and names the fix for each
|
||||
failing row. `/agenthub-update` re-runs the bootstrap in the foreground with its
|
||||
output visible. The most common causes: Node older than 22.5 (`node -v` in the
|
||||
shell Claude Code was started from), no network or a proxy blocking the npm
|
||||
registry (`npm-install.log`), and a port held by another program (`server.log`,
|
||||
see [Port 4820 already in use](#port-4820-already-in-use)).
|
||||
|
||||
If you see an error box at startup saying *"SQLite backend not available"*, either:
|
||||
### `better-sqlite3` errors during `npm install` / `npm run setup`
|
||||
|
||||
- **Upgrade to Node.js 22+** (recommended — zero native dependencies needed), or
|
||||
- **Install build tools** so `better-sqlite3` can compile from source:
|
||||
- **Windows:** `npm install -g windows-build-tools` or install [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) with the C++ workload
|
||||
- **macOS:** `xcode-select --install`
|
||||
- **Linux:** `sudo apt install python3 make g++` (Debian/Ubuntu) or equivalent
|
||||
Only a **checkout** install sees these — the plugin installs with
|
||||
`--ignore-scripts` and never builds `better-sqlite3`. They are **harmless**:
|
||||
`better-sqlite3` is an optional dependency, and when it cannot be built npm
|
||||
skips it and the server uses Node's built-in `node:sqlite` (Node 22.5+). You do
|
||||
**not** need Python, Visual Studio Build Tools or a C++ compiler.
|
||||
|
||||
Then run: `npm rebuild better-sqlite3`
|
||||
To build it anyway (it is used when present), install a toolchain and run
|
||||
`npm rebuild better-sqlite3`:
|
||||
|
||||
- **Windows:** [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) with the C++ workload
|
||||
- **macOS:** `xcode-select --install`
|
||||
- **Linux:** `sudo apt install python3 make g++` (Debian/Ubuntu) or equivalent
|
||||
|
||||
### "SQLite backend not available" error on startup
|
||||
|
||||
Neither `better-sqlite3` nor `node:sqlite` could be loaded — almost always a
|
||||
Node older than 22.5. Upgrade Node.
|
||||
|
||||
### Database is locked / busy errors
|
||||
|
||||
The SQLite database uses WAL mode with a 5-second busy timeout. If you see lock errors:
|
||||
|
||||
- Ensure only one dashboard server instance is running
|
||||
- Check for zombie `node server/index.js` processes: `ps aux | grep server/index`
|
||||
- Delete `data/dashboard.db-wal` and `data/dashboard.db-shm` if the server was killed uncleanly, then restart
|
||||
|
||||
### `npm run dev` fails immediately
|
||||
|
||||
@@ -375,7 +643,7 @@ Ensure both server and client dependencies are installed:
|
||||
npm run setup
|
||||
```
|
||||
|
||||
If the error mentions a missing module like `express` or `react`, dependencies may be incomplete. Delete `node_modules` in both root and `client/`, then re-run setup:
|
||||
If the error mentions a missing module like `express` or `react`, dependencies may be incomplete. Delete `node_modules` in both the root and `client/`, then re-run setup:
|
||||
|
||||
```bash
|
||||
rm -rf node_modules client/node_modules
|
||||
@@ -409,9 +677,66 @@ that case.
|
||||
|
||||
### No sessions appearing after starting Claude Code
|
||||
|
||||
See [SETUP.md — Troubleshooting](./SETUP.md#troubleshooting) for detailed hook debugging steps.
|
||||
**Check 1 — Is the server running?**
|
||||
|
||||
---
|
||||
```bash
|
||||
curl http://localhost:4820/api/health
|
||||
# Expected: {"status":"ok","timestamp":"..."}
|
||||
```
|
||||
|
||||
**Check 2 — Are hooks installed?**
|
||||
|
||||
With the plugin, its hooks come with it — run `/agenthub-doctor` instead. For a
|
||||
checkout, open `~/.claude/settings.json` and confirm it contains a `hooks`
|
||||
section with entries referencing `hook-handler.js`. If not, run:
|
||||
|
||||
```bash
|
||||
npm run install-hooks
|
||||
```
|
||||
|
||||
**Check 3 — Did you start a new Claude Code session after the server started?**
|
||||
|
||||
Hooks only apply to sessions started after installation. Restart Claude Code.
|
||||
|
||||
**Check 4 — Is Node.js in PATH when Claude Code runs hooks?**
|
||||
|
||||
On some systems, the shell environment when Claude Code fires hooks may not include the full PATH. Test with:
|
||||
|
||||
```bash
|
||||
node --version
|
||||
```
|
||||
|
||||
If Node.js is not found, use the full path to `node` in the hook command. Edit `scripts/install-hooks.js`, replace `node` with the absolute path (e.g. `/usr/local/bin/node`), and re-run `npm run install-hooks`.
|
||||
|
||||
### Dashboard shows "Disconnected" in the sidebar
|
||||
|
||||
The WebSocket connection to the server failed. Ensure the server is running:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The client will automatically reconnect every 2 seconds once the server is available.
|
||||
|
||||
### Port 4820 already in use
|
||||
|
||||
When another program holds the port, the server stops with
|
||||
`Port 4820 is already in use by another program…` (a running AgentHub there is
|
||||
detected and simply reused). Pick another port:
|
||||
|
||||
```bash
|
||||
DASHBOARD_PORT=4821 npm start # or: DASHBOARD_PORT=4821 npm run dev
|
||||
```
|
||||
|
||||
The Vite dev proxy and the Claude Code hooks follow the port on their own
|
||||
(`client/vite.config.ts` reads `DASHBOARD_PORT`; the server publishes the port it bound).
|
||||
|
||||
And make sure Claude Code posts hooks to the new port:
|
||||
|
||||
```bash
|
||||
CLAUDE_DASHBOARD_PORT=4821 claude
|
||||
# or edit scripts/hook-handler.js and change the default port
|
||||
```
|
||||
|
||||
## Ports
|
||||
|
||||
|
||||
@@ -1,422 +1,14 @@
|
||||
# Setup Guide
|
||||
|
||||
A comprehensive guide to setting up and configuring the Agent Dashboard, including how it integrates with Claude Code, environment variables, container deployment, and troubleshooting common issues.
|
||||
|
||||
## How it works
|
||||
|
||||
Agent Dashboard integrates with Claude Code through its native hook system. When Claude Code performs any action (session start, tool use, turn completion, subagent finish, session exit), it fires a hook that calls a small Node.js script bundled with this project. That script forwards the event over HTTP to the dashboard server, which stores it in SQLite and broadcasts it to the browser over WebSocket.
|
||||
|
||||
```
|
||||
Claude Code → hook fires → hook-handler.js → POST /api/hooks/event
|
||||
↓
|
||||
Browser ← WebSocket broadcast ← Express server ← SQLite
|
||||
```
|
||||
|
||||
No extra Claude Code configuration is required in the normal host-run path — when you start the dashboard with `npm run dev` or `npm start`, the server configures the hooks automatically on startup (or, with the `agenthub` plugin enabled in `~/.claude/settings.json`, removes the checkout's copies — the plugin's own hooks already run, and both would post every event twice). Container deployments are the exception: after the container is up, run `npm run install-hooks` on the host so Claude Code points at `http://localhost:4820`.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Hook auto-installation
|
||||
|
||||
When the dashboard is running directly on the host, the server writes the following to `~/.claude/settings.json` every time it starts:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [{ "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" SessionStart" }] }],
|
||||
"PreToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" PreToolUse" }] }],
|
||||
"PostToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" PostToolUse" }] }],
|
||||
"Stop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" Stop" }] }],
|
||||
"SubagentStop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" SubagentStop" }] }],
|
||||
"Notification": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" Notification" }] }],
|
||||
"SessionEnd": [{ "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" SessionEnd" }] }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Note: `SessionStart` and `SessionEnd` hooks do not support the `matcher` field — they fire unconditionally on every session start and exit.
|
||||
|
||||
Existing hooks in that file are preserved. The dashboard only adds or updates entries that contain `hook-handler.js`.
|
||||
|
||||
To re-run hook installation manually:
|
||||
|
||||
```bash
|
||||
npm run install-hooks
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> Container note: if you run the dashboard inside a container (a Dev Container, Codespaces, or your own image), do not install hooks from in there — the hook path a container writes points at the container filesystem, not the host. Run `npm run install-hooks` on the host instead. As a safeguard (issue #193) the installer **detects container execution and refuses to run** (exiting non-zero) so it can never poison a bind-mounted host `~/.claude`. If Claude Code itself genuinely runs inside that container, override with `AGENTHUB_ALLOW_CONTAINER_HOOKS=1 npm run install-hooks`.
|
||||
|
||||
> [!NOTE]
|
||||
> Prefer a ready-made dev environment? This repo ships an **optional** Dev Container (`.devcontainer/`) for VS Code / GitHub Codespaces — Node 22, native build tools for `better-sqlite3`, Python, and ports `4820`/`5173` preconfigured. It's purely opt-in and changes nothing for host-based development. See [`.devcontainer/README.md`](.devcontainer/README.md). (Hooks remain host-side there too.)
|
||||
|
||||
### Environment variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `DASHBOARD_PORT` | `4820` | Port the Express server listens on |
|
||||
| `CLAUDE_DASHBOARD_PORT` | `4820` | Port the hook handler uses when posting events to the dashboard |
|
||||
| `DASHBOARD_DB_PATH` | `data/dashboard.db` | Path to the SQLite database file |
|
||||
| `NODE_ENV` | `development` | Set to `production` to serve built client |
|
||||
| `DASHBOARD_CLIENT_DIST` | `client/dist` | Directory the built UI is served from in production. Set by the `agenthub` plugin bootstrap to its own runtime dir, because the plugin cache is replaced on every update |
|
||||
| `AGENTHUB_IMPORT_MAX_BYTES` | `1073741824` (1 GB) | Maximum size per uploaded file on `/api/import/upload` |
|
||||
| `AGENTHUB_IMPORT_MAX_FILES` | `2000` | Maximum number of files per upload request |
|
||||
| `AGENTHUB_IMPORT_MAX_EXTRACT_BYTES` | `4294967296` (4 GB) | Maximum uncompressed bytes any single archive is allowed to expand to (zip-bomb defense) |
|
||||
|
||||
Example with a custom port:
|
||||
|
||||
```bash
|
||||
DASHBOARD_PORT=9000 npm run dev
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> You usually do **not** need to set `DASHBOARD_PORT` manually. `npm run dev` is wrapped by `scripts/dev.js`, which probes both `127.0.0.1` and `::1` (so an SSH `LocalForward` bound to one loopback can't slip past) and picks the first free port in `4820–4859` automatically. The chosen port is propagated to the Vite dev proxy via `DASHBOARD_PORT`, and the Express server writes it to `~/.claude/.agenthub.json` so the Claude Code hook handler discovers it without any env var.
|
||||
>
|
||||
> Multiple dashboards can run side by side — for example two `npm run dev` checkouts, or `npm run dev` alongside `npm start`. Each one appends its `{port, pid, startedAt}` entry to the discovery file, and `scripts/hook-handler.js` fan-outs every hook event to every live entry, so both UIs keep their real-time stream.
|
||||
>
|
||||
> Setting `CLAUDE_DASHBOARD_PORT=N` overrides discovery entirely and forces the hook handler to a single port — useful for tests and container setups where the in-process discovery file isn't reachable from the host.
|
||||
>
|
||||
> If you bypass the picker (e.g. `npm run dev:raw`, container builds, or anything else that calls `node server/index.js` directly), make sure your client is built / proxied against the port the server actually bound.
|
||||
|
||||
### Agent extension setup (Claude Code + Codex)
|
||||
|
||||
This repository ships extension files for both agent ecosystems:
|
||||
|
||||
- Claude Code:
|
||||
- `CLAUDE.md`
|
||||
- `.claude/rules/*`
|
||||
- `skills/*` (the `agenthub` plugin's own skills)
|
||||
- `agents/*` (the `agenthub` plugin's own agents)
|
||||
- Codex:
|
||||
- `AGENTS.md`
|
||||
- `.codex/config.toml`
|
||||
- `.codex/rules/default.rules`
|
||||
- `.codex/agents/*`
|
||||
- `.codex/skills/*`
|
||||
|
||||
See [`.codex/README.md`](./.codex/README.md) for Codex extension details.
|
||||
|
||||
### PWA configuration (optional)
|
||||
|
||||
The dashboard ships as a Progressive Web App. No configuration is required — its manifest and service worker are included out of the box.
|
||||
|
||||
**Customising the manifest:** Edit `client/public/manifest.json`. Common fields to change:
|
||||
|
||||
- `name` / `short_name` — displayed on the home screen / dock
|
||||
- `theme_color` — address bar / title bar tint (default: `#6366f1`)
|
||||
- `background_color` — splash screen background
|
||||
- `start_url` — entry point when launched from home screen
|
||||
|
||||
**Updating the service worker cache:** The SW has a `CACHE_NAME` constant (e.g. `dashboard-v2`). After deploying new assets, bump the version string to force browsers to re-fetch — though for the dashboard this is rarely needed: hashed `/assets/*` URLs are immutable per build, everything else is fetched network-first with cache fallback, and a `controllerchange` listener in the client reloads the page exactly once when a new SW takes over, so a rebuild propagates without a hard refresh.
|
||||
|
||||
**Browser support:** PWA install prompts appear in Chrome 107+, Edge 107+, and Firefox 110+ (desktop and Android). Safari supports `apple-mobile-web-app-capable` for iOS home-screen mode but does not show an install banner.
|
||||
|
||||
**Verifying PWA status:** Open DevTools → Application → Manifest to confirm the manifest loads. Check the Service Workers section to verify the SW is registered and active. The Lighthouse PWA audit should pass all core checks.
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
|
||||
The SQLite database is created automatically at `data/dashboard.db` on first run. The directory is created if it does not exist. The database uses WAL mode for concurrent reads and foreign keys for referential integrity.
|
||||
|
||||
### Clear all data
|
||||
|
||||
To remove all sessions, agents, events, and token usage (useful after running seed data or for a clean start):
|
||||
|
||||
```bash
|
||||
npm run clear-data
|
||||
```
|
||||
|
||||
### Data management via Settings page
|
||||
|
||||
The Settings page (`/settings`) provides a UI for the following. **Model Pricing**
|
||||
and **Hook Configuration** are not on its Dashboard tab: they sit under the
|
||||
Claude Code tab's **Overview** (Settings → Claude Code → Overview), beside
|
||||
CLAUDE_HOME; Codex (its Overview) and Antigravity have their own pricing and
|
||||
hooks in their own tabs.
|
||||
|
||||
- **Model Pricing** — view and edit per-model cost rates, reset to defaults, add custom models
|
||||
- **Hook Configuration** — check which hooks are installed and reinstall them
|
||||
- **Data Export** — download all sessions, agents, events, and pricing as a JSON file
|
||||
- **Session Cleanup** — abandon stale active sessions after N hours, purge old completed sessions after N days
|
||||
- **Clear All Data** — remove all sessions, agents, events, and token usage
|
||||
- **Data Management** and **About** sections render with loading placeholders while server info is being fetched, so the page is always fully navigable
|
||||
|
||||
### Seed demo data
|
||||
|
||||
To populate the dashboard with sample sessions, agents, and events for UI exploration:
|
||||
|
||||
```bash
|
||||
npm run seed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Importing existing Claude Code history
|
||||
|
||||
The dashboard automatically imports sessions from `~/.claude/projects/` on
|
||||
**every startup**, so if Claude Code has been used on this machine, you'll
|
||||
see history immediately after the first launch. If you need to bring in
|
||||
history from another machine, from a backup, or just force a rescan, use
|
||||
**Settings → Import History** in the UI — it's a guided, drag-and-drop
|
||||
experience with live progress.
|
||||
### Pick the right mode
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Q["Where is the history?"] --> Q1{Is it on this machine<br/>under ~/.claude/projects?}
|
||||
Q1 -->|yes, and I just want<br/>to re-scan| M1["Mode: Rescan default folder<br/>one click"]
|
||||
Q1 -->|yes, but in another folder<br/>on this machine| M2["Mode: Scan a folder<br/>paste the absolute path"]
|
||||
Q1 -->|no — it's on another machine<br/>or in an archive file| M3["Mode: Upload files<br/>drag-drop JSONL or archive"]
|
||||
|
||||
M3 --> PREP["Archive source:<br/>tar -czf claude-history.tar.gz<br/>-C ~/.claude projects"]
|
||||
|
||||
style M1 fill:#10b981,stroke:#34d399,color:#fff
|
||||
style M2 fill:#f59e0b,stroke:#fbbf24,color:#000
|
||||
style M3 fill:#a855f7,stroke:#c084fc,color:#fff
|
||||
```
|
||||
|
||||
### Step-by-step: moving history from one machine to another
|
||||
|
||||
**On the source machine**, bundle the projects folder:
|
||||
|
||||
```bash
|
||||
# macOS / Linux
|
||||
tar -czf claude-history.tar.gz -C ~/.claude projects
|
||||
|
||||
# Windows (PowerShell, via built-in tar)
|
||||
tar -czf claude-history.tar.gz -C "$env:USERPROFILE\.claude" projects
|
||||
```
|
||||
|
||||
Transfer the resulting `claude-history.tar.gz` to the destination machine
|
||||
however you like — AirDrop, `scp`, USB, cloud storage.
|
||||
|
||||
**On the destination machine**, in the dashboard:
|
||||
|
||||
1. Open **Settings → Import History**.
|
||||
2. Pick **Upload files** (the third tab).
|
||||
3. Drag the archive onto the drop zone.
|
||||
4. Click **Upload & Import** and watch the progress.
|
||||
5. When the green result card appears, open **Analytics → Cost** to confirm
|
||||
per-model token totals and estimated cost.
|
||||
|
||||
### Supported inputs
|
||||
|
||||
Any of the following can be dropped onto the upload zone or found inside a
|
||||
folder given to **Scan a folder**:
|
||||
|
||||
- `.jsonl` — session transcripts
|
||||
- `.meta.json` — subagent metadata sidecars
|
||||
- `.zip` — extracted with path-traversal protection
|
||||
- `.tar`, `.tar.gz`, `.tgz` — extracted via the `tar` package
|
||||
- `.gz` — single gzipped JSONL (streaming-decompressed)
|
||||
|
||||
### Accuracy guarantees
|
||||
|
||||
- **Idempotent** — re-importing never double-counts. Sessions are
|
||||
deduplicated by UUID.
|
||||
- **Cost-preserving** — the `token_usage` table uses `baseline_*` columns
|
||||
to preserve pre-compaction token totals, so re-ingesting a compacted
|
||||
transcript never erases historical cost.
|
||||
- **Same parser as live** — `parseSessionFile` + `importSession` is the
|
||||
single source of truth for both hook-driven ingestion and manual
|
||||
import, so imported numbers match captured numbers exactly.
|
||||
|
||||
### Safety
|
||||
|
||||
Archive extraction is hardened against path traversal and archive bombs.
|
||||
The defaults are generous for real-world transcripts but tight enough to
|
||||
stop obvious attacks; see the env vars table above for
|
||||
`AGENTHUB_IMPORT_MAX_BYTES`, `AGENTHUB_IMPORT_MAX_FILES`, and
|
||||
`AGENTHUB_IMPORT_MAX_EXTRACT_BYTES`.
|
||||
|
||||
### CLI alternative
|
||||
|
||||
For scripts and automation, the same logic runs from the terminal:
|
||||
|
||||
```bash
|
||||
# Import (or re-import) everything under ~/.claude/projects
|
||||
npm run import-history
|
||||
|
||||
# Dry run — show what would be imported without writing
|
||||
node scripts/import-history.js --dry-run
|
||||
|
||||
# Scope to a single project dir
|
||||
node scripts/import-history.js --project my-project
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scripts reference
|
||||
|
||||
| Script | Command | Description |
|
||||
|---|---|---|
|
||||
| `setup` | `npm run setup` | Install all dependencies (server + client) |
|
||||
| `dev` | `npm run dev` | Start server + client in development mode |
|
||||
| `start` | `npm start` | Start server in production mode |
|
||||
| `build` | `npm run build` | Build the React client to `client/dist/` |
|
||||
| `install-hooks` | `npm run install-hooks` | Write Claude Code hooks to `~/.claude/settings.json` |
|
||||
| `clear-data` | `npm run clear-data` | Delete all data from the database |
|
||||
| `seed` | `npm run seed` | Insert demo sessions/agents/events |
|
||||
| `import-history` | `npm run import-history` | Import legacy sessions from `~/.claude/` (also runs on startup) |
|
||||
| `claude` | Claude CLI | Uses `CLAUDE.md`, `.claude/rules`, and `.claude/skills` automatically |
|
||||
| `test` | `npm test` | Run all server and client tests |
|
||||
| `test:server` | `npm run test:server` | Run server integration tests only |
|
||||
| `test:client` | `npm run test:client` | Run client unit tests only |
|
||||
| `format` | `npm run format` | Format all files with Prettier |
|
||||
| `format:check` | `npm run format:check` | Check formatting without writing |
|
||||
|
||||
---
|
||||
|
||||
## Makefile targets
|
||||
|
||||
All npm scripts are mirrored as `make` targets for convenience. Run `make help` to list them:
|
||||
|
||||
```bash
|
||||
make help
|
||||
```
|
||||
|
||||
Commonly used targets:
|
||||
|
||||
| Make target | Equivalent npm command | Description |
|
||||
|---|---|---|
|
||||
| `make setup` | `npm run setup` | Install all dependencies (root + client) |
|
||||
| `make dev` | `npm run dev` | Start server + client in watch mode |
|
||||
| `make build` | `npm run build` | Build the React client for production |
|
||||
| `make start` | `npm start` | Start the production server |
|
||||
| `make prod` | `npm run build && npm start` | Build then start in one step |
|
||||
| `make test` | `npm test` | Run all tests (server + client) |
|
||||
| `make test-server` | `npm run test:server` | Run server tests only |
|
||||
| `make test-client` | `npm run test:client` | Run client tests only |
|
||||
| `make format` | `npm run format` | Format all files with Prettier |
|
||||
| `make format-check` | `npm run format:check` | Check formatting without writing |
|
||||
| `make seed` | `npm run seed` | Load demo data |
|
||||
| `make clear-data` | `npm run clear-data` | Delete all data rows |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `better-sqlite3` errors during `npm install` / `npm run setup`
|
||||
|
||||
These warnings are **harmless**. `better-sqlite3` is an optional dependency — if it cannot compile, npm skips it and the server falls back to Node.js built-in `node:sqlite` (available on Node 22+).
|
||||
|
||||
You do **not** need Python, Visual Studio Build Tools, or any C++ compiler to run this project on Node 22+.
|
||||
|
||||
If you are on Node 20 or 21 and `better-sqlite3` prebuilds are not available for your platform (there is no `node:sqlite` fallback below Node 22), you have two options:
|
||||
|
||||
1. **Upgrade to Node.js 22+** — the built-in `node:sqlite` fallback requires no native compilation at all
|
||||
2. **Install build tools** and run `npm rebuild better-sqlite3`:
|
||||
- **Windows:** install [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) with the C++ workload
|
||||
- **macOS:** `xcode-select --install`
|
||||
- **Linux:** `sudo apt install python3 make g++` (Debian/Ubuntu)
|
||||
|
||||
### "SQLite backend not available" error on startup
|
||||
|
||||
This means neither `better-sqlite3` nor `node:sqlite` could be loaded. The most common cause is running Node.js < 22 without `better-sqlite3` prebuilds. Upgrade to Node.js 22+ to resolve this.
|
||||
|
||||
### Database is locked / busy errors
|
||||
|
||||
The SQLite database uses WAL mode with a 5-second busy timeout. If you see lock errors:
|
||||
|
||||
- Ensure only one dashboard server instance is running
|
||||
- Check for zombie `node server/index.js` processes: `ps aux | grep server/index`
|
||||
- Delete `data/dashboard.db-wal` and `data/dashboard.db-shm` if the server was killed uncleanly, then restart
|
||||
|
||||
---
|
||||
|
||||
### The run console won't start a run / the terminal never attaches
|
||||
|
||||
Runs are tmux-backed: the server executes the `tmux` binary and has no fallback.
|
||||
|
||||
```bash
|
||||
tmux -V # missing → sudo apt install tmux
|
||||
curl -s http://localhost:4820/api/run/tmux # {"available":true}
|
||||
curl -s "http://localhost:4820/api/run/binary?tool=claude" # {"found":true,"path":"…"}
|
||||
```
|
||||
|
||||
`{"available":false}` while your own shell finds tmux means the **server's**
|
||||
`PATH` differs from yours — typically a systemd `--user` service, which never
|
||||
reads your shell profile. Install tmux system-wide (`/usr/bin/tmux`) and
|
||||
re-run `agenthub service uninstall && agenthub service install`. The same
|
||||
applies to `claude` / `codex`: a run launches them by name.
|
||||
|
||||
Install instructions per OS: [INSTALL.md → Step 0](./INSTALL.md#step-0--system-packages).
|
||||
|
||||
---
|
||||
|
||||
### No sessions appearing after starting Claude Code
|
||||
|
||||
**Check 1 — Is the server running?**
|
||||
|
||||
```bash
|
||||
curl http://localhost:4820/api/health
|
||||
# Expected: {"status":"ok","timestamp":"..."}
|
||||
```
|
||||
|
||||
**Check 2 — Are hooks installed?**
|
||||
|
||||
Open `~/.claude/settings.json` and confirm it contains a `hooks` section with entries referencing `hook-handler.js`. If not, run:
|
||||
|
||||
```bash
|
||||
npm run install-hooks
|
||||
```
|
||||
|
||||
**Check 3 — Did you start a new Claude Code session after the server started?**
|
||||
|
||||
Hooks only apply to sessions started after installation. Restart Claude Code.
|
||||
|
||||
**Check 4 — Is Node.js in PATH when Claude Code runs hooks?**
|
||||
|
||||
On some systems, the shell environment when Claude Code fires hooks may not include the full PATH. Test with:
|
||||
|
||||
```bash
|
||||
node --version
|
||||
```
|
||||
|
||||
If Node.js is not found, use the full path to `node` in the hook command. Edit `scripts/install-hooks.js`, replace `node` with the absolute path (e.g. `/usr/local/bin/node`), and re-run `npm run install-hooks`.
|
||||
|
||||
---
|
||||
|
||||
### Dashboard shows "Disconnected" in the sidebar
|
||||
|
||||
The WebSocket connection to the server failed. Ensure the server is running:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The client will automatically reconnect every 2 seconds once the server is available.
|
||||
|
||||
---
|
||||
|
||||
### Events Today shows 0 despite recent activity
|
||||
|
||||
This was a known timezone bug (fixed in current version). If you are still seeing this, ensure you are running the latest code and restart the server.
|
||||
|
||||
---
|
||||
|
||||
### Port 4820 already in use
|
||||
|
||||
```bash
|
||||
DASHBOARD_PORT=4821 npm run dev
|
||||
```
|
||||
|
||||
Then update the Vite proxy in `client/vite.config.ts`:
|
||||
|
||||
```ts
|
||||
proxy: {
|
||||
"/api": "http://localhost:4821",
|
||||
"/ws": { target: "ws://localhost:4821", ws: true }
|
||||
}
|
||||
```
|
||||
|
||||
And make sure Claude Code posts hooks to the new port:
|
||||
|
||||
```bash
|
||||
CLAUDE_DASHBOARD_PORT=4821 claude
|
||||
# or edit scripts/hook-handler.js and change the default port
|
||||
```
|
||||
This guide has moved into **[INSTALL.md](./INSTALL.md)** — the one install and
|
||||
setup guide (it and this file had grown into near-duplicates). Everything that
|
||||
was here is there:
|
||||
|
||||
| Was in SETUP.md | Now in INSTALL.md |
|
||||
|---|---|
|
||||
| How it works | [How it works](./INSTALL.md#how-it-works) |
|
||||
| Hook auto-installation, environment variables, agent extensions, PWA | [Configuration](./INSTALL.md#configuration) |
|
||||
| Database (clear, Settings page, seed) | [Database](./INSTALL.md#database) |
|
||||
| Importing existing Claude Code history | [Step 5](./INSTALL.md#step-5--optional-importing-existing-claude-code-history) |
|
||||
| Scripts reference, Makefile targets | [Scripts reference](./INSTALL.md#scripts-reference) |
|
||||
| Troubleshooting (incl. "Port 4820 already in use") | [Troubleshooting](./INSTALL.md#troubleshooting) |
|
||||
|
||||
+87
-6
@@ -2855,8 +2855,8 @@ const COMMAND_GROUPS = [
|
||||
],
|
||||
[
|
||||
"accounts",
|
||||
"list|add|remove [name] [--tool codex]",
|
||||
"Claude/Codex account pool: new sessions spread over these accounts",
|
||||
"list|add|remove [name]|pin <account> <user>|unpin <user> [--tool codex]",
|
||||
"Claude/Codex account pool: new sessions spread over these accounts; pin = one user always on one",
|
||||
],
|
||||
["repl", "", "Open the interactive shell (also: shell, i)"],
|
||||
],
|
||||
@@ -3814,7 +3814,7 @@ const SUBCOMMANDS = {
|
||||
service: ["install", "uninstall", "status"],
|
||||
server: ["setup", "off", "status"],
|
||||
users: ["list", "add", "passwd", "role", "disable", "enable", "assign", "audit"],
|
||||
accounts: ["list", "add", "remove"],
|
||||
accounts: ["list", "add", "remove", "pin", "unpin"],
|
||||
pricing: ["set", "delete", "reset"],
|
||||
webhooks: ["test"],
|
||||
import: ["rescan", "path"],
|
||||
@@ -4286,7 +4286,18 @@ async function cmdCodexAccounts(sub, name, flags) {
|
||||
}
|
||||
pool.resetQuota();
|
||||
table(
|
||||
["codex account", "kind", "live", "$ 5h", "$ 7d", "tok 5h", "tok 7d", "quota", "state"],
|
||||
[
|
||||
"codex account",
|
||||
"kind",
|
||||
"live",
|
||||
"$ 5h",
|
||||
"$ 7d",
|
||||
"tok 5h",
|
||||
"tok 7d",
|
||||
"quota",
|
||||
"state",
|
||||
"pinned",
|
||||
],
|
||||
pool
|
||||
.poolStatus()
|
||||
.map((a) => [
|
||||
@@ -4301,10 +4312,64 @@ async function cmdCodexAccounts(sub, name, flags) {
|
||||
? a.quota.windows.map((w) => `${w.label} ${Math.round(w.usedPercent)}%`).join(" ")
|
||||
: "–",
|
||||
a.full ? "full" : "ok",
|
||||
pinnedUsers("codex", a.name),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub accounts pin <account> <user>` / `unpin <user>` [--tool codex] —
|
||||
* that user's new sessions run on that account (server/lib/account-pins.js).
|
||||
*/
|
||||
async function cmdAccountPin(sub, positional, tool) {
|
||||
const pins = require(path.join(REPO_ROOT, "server", "lib", "account-pins.js"));
|
||||
const [first, second] = positional;
|
||||
const username = sub === "pin" ? second : first;
|
||||
if (!username || (sub === "pin" && !first)) {
|
||||
throw new Error("usage: agenthub accounts pin <account> <user> | unpin <user> [--tool codex]");
|
||||
}
|
||||
const user = require(path.join(REPO_ROOT, "server", "db.js"))
|
||||
.db.prepare("SELECT id FROM users WHERE username = ?")
|
||||
.get(username);
|
||||
if (!user) throw new Error(`no user ${username}`);
|
||||
const pin = pins.setPin(user.id, tool, sub === "pin" ? first : null);
|
||||
if (!require(path.join(REPO_ROOT, "server", "lib", "auth.js")).isServerMode()) {
|
||||
console.log(c.dim(" Pins apply in server mode only."));
|
||||
}
|
||||
if (!pin.account) {
|
||||
console.log(`${c.green("✔")} ${pin.username} unpinned — new ${tool} sessions use the balancer`);
|
||||
return;
|
||||
}
|
||||
const switched = await pins.applyPin(user.id, tool);
|
||||
console.log(`${c.green("✔")} ${pin.username}'s ${tool} sessions run on ${pin.account}`);
|
||||
console.log(c.dim(` ${SWITCH_NOTE[switched] || SWITCH_NOTE["next-session"]}`));
|
||||
}
|
||||
|
||||
/** What `applyPin` did, for the CLI. */
|
||||
const SWITCH_NOTE = {
|
||||
switched: "Switched now: running sessions take it at their next login check, new ones at once.",
|
||||
kept: "Their home already holds it.",
|
||||
"own-login":
|
||||
"They signed in with their own login, which is left alone: no pool login is written over it.",
|
||||
expired: "That login has expired: new sessions fall back to the balancer until it is re-added.",
|
||||
"next-session": "New sessions use it; running ones keep their account until they end.",
|
||||
};
|
||||
|
||||
/** The `pinned` column: who an admin pinned to `account`. */
|
||||
function pinnedUsers(tool, account) {
|
||||
try {
|
||||
return (
|
||||
require(path.join(REPO_ROOT, "server", "lib", "account-pins.js"))
|
||||
.listPins(tool)
|
||||
.filter((p) => p.account === account)
|
||||
.map((p) => p.username)
|
||||
.join(",") || "–"
|
||||
);
|
||||
} catch {
|
||||
return "–";
|
||||
}
|
||||
}
|
||||
|
||||
/** `agenthub accounts …` — the Claude account pool (server/lib/claude-pool.js). */
|
||||
async function cmdAccounts(sub, positional, flags = {}) {
|
||||
serverAuth(); // the same settings + database the server reads
|
||||
@@ -4316,6 +4381,8 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
||||
if (flags.tool != null && flags.tool !== "claude" && flags.tool !== "codex") {
|
||||
throw new Error("--tool is claude (the default) or codex");
|
||||
}
|
||||
if (sub === "pin" || sub === "unpin")
|
||||
return cmdAccountPin(sub, positional, flags.tool || "claude");
|
||||
if (flags.tool === "codex") return cmdCodexAccounts(sub, name, flags);
|
||||
if (sub === "add") {
|
||||
console.log(
|
||||
@@ -4337,7 +4404,18 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
||||
await pool.refreshQuotas({ force: true });
|
||||
const pct = (w) => (w?.utilization != null ? `${Math.round(w.utilization)}%` : "–");
|
||||
table(
|
||||
["account", "live", "$ 5h", "$ 7d", "tok 5h", "tok 7d", "quota 5h", "quota wk", "state"],
|
||||
[
|
||||
"account",
|
||||
"live",
|
||||
"$ 5h",
|
||||
"$ 7d",
|
||||
"tok 5h",
|
||||
"tok 7d",
|
||||
"quota 5h",
|
||||
"quota wk",
|
||||
"state",
|
||||
"pinned",
|
||||
],
|
||||
pool
|
||||
.poolStatus()
|
||||
.map((a) => [
|
||||
@@ -4356,6 +4434,7 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
||||
: a.quota?.error
|
||||
? `no quota (${a.quota.error})`
|
||||
: "ok",
|
||||
pinnedUsers("claude", a.name),
|
||||
])
|
||||
);
|
||||
const codex = require(path.join(REPO_ROOT, "server", "lib", "codex-pool.js"));
|
||||
@@ -4363,7 +4442,9 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
||||
return;
|
||||
}
|
||||
console.error(
|
||||
c.red("✖ Usage: agenthub accounts list | add <name> | remove <name> [--tool codex]")
|
||||
c.red(
|
||||
"✖ Usage: agenthub accounts list | add <name> | remove <name> | pin <account> <user> | unpin <user> [--tool codex]"
|
||||
)
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useState, useCallback, useEffect, useSyncExternalStore } from "react";
|
||||
import { lazy, Suspense, useState, useCallback, useEffect, useSyncExternalStore } from "react";
|
||||
import { Outlet, useLocation } from "react-router-dom";
|
||||
import { Menu } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -85,9 +85,15 @@ import { UpdateNotifier } from "./UpdateNotifier";
|
||||
import { InterruptedSessionsPrompt } from "./lanes/InterruptedSessionsPrompt";
|
||||
import { SagiMount } from "./Sagi/SagiMount";
|
||||
import { StatusBar } from "./StatusBar";
|
||||
import { OnboardingWizard } from "./onboarding/OnboardingWizard";
|
||||
import { useOnboarding } from "../lib/userPrefs";
|
||||
import { OnboardingTour } from "./onboarding/OnboardingTour";
|
||||
|
||||
// Loaded only for an account that has not finished onboarding: the wizard
|
||||
// carries the appearance, pet and notification settings screens.
|
||||
const OnboardingWizard = lazy(() =>
|
||||
import("./onboarding/OnboardingWizard").then((m) => ({ default: m.OnboardingWizard }))
|
||||
);
|
||||
|
||||
const WIDTH_KEY = "agenthub.sidebar.width";
|
||||
/** The default width, and the narrowest the edge drags to. */
|
||||
export const SIDEBAR_MIN = 240;
|
||||
@@ -136,6 +142,7 @@ export function Layout({ wsConnected }: LayoutProps) {
|
||||
const { t } = useTranslation("nav");
|
||||
const [collapsed, setCollapsed] = useState(loadCollapsed);
|
||||
const [connectionOpen, setConnectionOpen] = useState(false);
|
||||
const onboarding = useOnboarding();
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||
const drawer = useMedia(DRAWER_QUERY);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
@@ -177,7 +184,11 @@ export function Layout({ wsConnected }: LayoutProps) {
|
||||
<UpdateNotifier />
|
||||
<InterruptedSessionsPrompt />
|
||||
<SagiMount />
|
||||
<OnboardingWizard />
|
||||
{onboarding.loaded && !onboarding.onboardedAt && (
|
||||
<Suspense fallback={null}>
|
||||
<OnboardingWizard />
|
||||
</Suspense>
|
||||
)}
|
||||
<OnboardingTour />
|
||||
{drawer && drawerOpen && (
|
||||
<div
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
/**
|
||||
* @file PetChat.tsx
|
||||
* @description The pet's chat window. A question its Ask box cannot answer
|
||||
* locally opens here instead of leaving the page: the first message starts a
|
||||
* @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
|
||||
@@ -11,16 +15,17 @@
|
||||
* 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; **New chat**
|
||||
* forgets it (the session stays in the workspace), **Open in workspace**
|
||||
* `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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowLeft, ArrowUp, ExternalLink, Loader2, SquarePen, Wrench, X } from "lucide-react";
|
||||
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";
|
||||
@@ -33,6 +38,8 @@ import {
|
||||
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";
|
||||
@@ -141,14 +148,21 @@ function toolSummary(names: string[]): string {
|
||||
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;
|
||||
/** A question to send as soon as the chat opens (the Ask box's handoff). */
|
||||
initialPrompt?: string | null;
|
||||
/** Called once the initial prompt has been taken, so a re-render never sends it twice. */
|
||||
onPromptTaken?: () => void;
|
||||
onBack: () => void;
|
||||
/** 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;
|
||||
@@ -158,9 +172,10 @@ interface PetChatProps {
|
||||
|
||||
export function PetChat({
|
||||
name,
|
||||
initialPrompt,
|
||||
onPromptTaken,
|
||||
onBack,
|
||||
title,
|
||||
headerActions,
|
||||
info,
|
||||
onLocal,
|
||||
onClose,
|
||||
onOpenWorkspace,
|
||||
onBusy,
|
||||
@@ -175,6 +190,8 @@ export function PetChat({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [menu, setMenu] = useState<PaneMenu | null>(null);
|
||||
// The pet's own answers, given before any chat ran.
|
||||
const [local, setLocal] = useState<Line[]>([]);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const remember = (ref: PetChatRef | null) => {
|
||||
@@ -248,7 +265,7 @@ export function PetChat({
|
||||
return () => window.clearInterval(timer);
|
||||
}, [chat]);
|
||||
|
||||
const lines = chatLines(messages);
|
||||
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()))
|
||||
@@ -269,6 +286,19 @@ export function PetChat({
|
||||
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.
|
||||
@@ -288,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) {
|
||||
@@ -302,17 +332,35 @@ export function PetChat({
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[chat, starting, t]
|
||||
[chat, starting, t, onLocal]
|
||||
);
|
||||
|
||||
// The Ask box's question, sent once.
|
||||
const taken = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initialPrompt || taken.current) return;
|
||||
taken.current = true;
|
||||
onPromptTaken?.();
|
||||
void send(initialPrompt);
|
||||
}, [initialPrompt, onPromptTaken, send]);
|
||||
// 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();
|
||||
@@ -333,11 +381,11 @@ export function PetChat({
|
||||
>
|
||||
{/* header */}
|
||||
<div className="pet-chat-head flex items-center gap-1 px-2 py-2">
|
||||
<IconButton label={t("sagi.chat.back")} onClick={onBack}>
|
||||
<ArrowLeft size={16} />
|
||||
</IconButton>
|
||||
<span className="pl-1.5 text-base leading-none" aria-hidden>
|
||||
🐾
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 px-1">
|
||||
<div className="truncate text-sm font-semibold text-fg-primary">{name}</div>
|
||||
{title ?? <div className="truncate text-sm font-semibold text-fg-primary">{name}</div>}
|
||||
<div className="flex items-center gap-1 text-[10px] uppercase tracking-wider text-fg-muted">
|
||||
<span
|
||||
className={`inline-block h-1.5 w-1.5 rounded-full ${
|
||||
@@ -352,6 +400,7 @@ export function PetChat({
|
||||
{busy ? t("sagi.chat.typing") : toolLabel}
|
||||
</div>
|
||||
</div>
|
||||
{headerActions}
|
||||
{chat && (
|
||||
<IconButton
|
||||
label={t("sagi.chat.openWorkspace")}
|
||||
@@ -362,8 +411,8 @@ export function PetChat({
|
||||
)}
|
||||
<IconButton
|
||||
label={t("sagi.chat.new")}
|
||||
onClick={() => remember(null)}
|
||||
disabled={!chat || starting}
|
||||
onClick={() => void newChat()}
|
||||
disabled={(!chat && !local.length) || starting}
|
||||
>
|
||||
<SquarePen size={15} />
|
||||
</IconButton>
|
||||
@@ -372,6 +421,8 @@ export function PetChat({
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{info}
|
||||
|
||||
{/* conversation */}
|
||||
<div ref={listRef} className="flex-1 space-y-2 overflow-y-auto px-3 py-3">
|
||||
{empty && (
|
||||
@@ -475,10 +526,9 @@ export function PetChat({
|
||||
e.preventDefault();
|
||||
submit();
|
||||
} else if (e.key === "Escape") {
|
||||
// Esc leaves the chat for the panel, not the whole mascot.
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
onBack();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -5,17 +5,20 @@
|
||||
* enabled; `Sagi` gates on the same preference itself, and the body (brain,
|
||||
* tick, WS reducer) runs only while it is on. Persists across routes and
|
||||
* shares the single WebSocket.
|
||||
* Owns the open/closed panel state, the ⌘B / Esc shortcuts, reduced-motion
|
||||
* Clicking the pet opens its chat window (`PetChat`), which is its whole UI:
|
||||
* the status strip (`SagiInfo`) sits on top, a question the pet can answer
|
||||
* itself is answered there, anything else goes to the AI.
|
||||
* Owns the open/closed chat state, the ⌘B / Esc shortcuts, reduced-motion
|
||||
* detection, and route navigation. Reactive personality + status/Ask come
|
||||
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
|
||||
* useSagiPosition (a v2 Codex pet also looks toward the pointer), and the
|
||||
* bubble/panel render in a self-clamping flyout so
|
||||
* they never spill off any screen edge regardless of where the mascot is docked.
|
||||
*
|
||||
* The "do the job" path: an unmatched Ask query starts a claude session in
|
||||
* the "Sagi" workspace carrying it as the first prompt, then opens that
|
||||
* workspace's session view (`/workspace?lane=<id>&run=<runId>`) — no new LLM
|
||||
* backend. ⌘B/Ctrl+B is taken only while the mascot is shown and focus is not
|
||||
* The "do the job" path: an unmatched question is the chat's — it starts a
|
||||
* session of the default AI tool in the "Sagi" workspace; a default tool the
|
||||
* chat cannot read back starts there and its session view opens
|
||||
* (`/workspace?lane=<id>&run=<runId>`) — no new LLM backend. ⌘B/Ctrl+B is taken only while the mascot is shown and focus is not
|
||||
* in an editable field (a terminal's Ctrl+B is tmux's prefix).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -39,7 +42,7 @@
|
||||
* ## Internal dependencies
|
||||
* - `./SagiAvatar`
|
||||
* - `./SpeechBubble`
|
||||
* - `./SagiPanel`
|
||||
* - `./SagiInfo`
|
||||
* - `./useSagiBrain`
|
||||
* - `./useSagiPosition`
|
||||
* - `./intents`
|
||||
@@ -78,12 +81,13 @@ import {
|
||||
} from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useSettingsHref } from "../../lib/settingsLink";
|
||||
import { usePrefersReducedMotion } from "../../hooks/usePrefersReducedMotion";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SagiAvatar } from "./SagiAvatar";
|
||||
import { PetSprite } from "./PetSprite";
|
||||
import { PetMenu } from "./PetMenu";
|
||||
import { SpeechBubble } from "./SpeechBubble";
|
||||
import { SagiPanel } from "./SagiPanel";
|
||||
import { PetNameButton, SagiInfo } from "./SagiInfo";
|
||||
import { useSagiBrain } from "./useSagiBrain";
|
||||
import { useSagiPosition } from "./useSagiPosition";
|
||||
import { useSagiEnabled } from "./SagiMount";
|
||||
@@ -94,7 +98,8 @@ import { api } from "../../lib/api";
|
||||
import { readDefaultTool } from "../../lib/sessionDefaults";
|
||||
import { startInFreeTabDetailed } from "../lanes/startSession";
|
||||
import { workspaceHref } from "../SessionRowMenu";
|
||||
import { PetChat, petChatTool } from "./PetChat";
|
||||
import { Heart } from "lucide-react";
|
||||
import { PetChat, petChatTool, type PetLocalReply } from "./PetChat";
|
||||
|
||||
// Stroking: this many left/right turns of the pointer over the pet, within
|
||||
// STROKE_MS and with no button held, is a pat; then a pause before the next.
|
||||
@@ -126,23 +131,6 @@ interface Anchor {
|
||||
dragging: boolean;
|
||||
}
|
||||
|
||||
/** The OS "reduce motion" preference, followed live. */
|
||||
export function usePrefersReducedMotion(): boolean {
|
||||
const [reduced, setReduced] = useState(
|
||||
() =>
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
|
||||
);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia?.("(prefers-reduced-motion: reduce)");
|
||||
if (!mq) return;
|
||||
const onChange = () => setReduced(mq.matches);
|
||||
mq.addEventListener?.("change", onChange);
|
||||
return () => mq.removeEventListener?.("change", onChange);
|
||||
}, []);
|
||||
return reduced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed-position wrapper that places its content next to the avatar and clamps
|
||||
* it inside the viewport. It measures itself (and re-measures on content/size
|
||||
@@ -241,10 +229,8 @@ function SagiBody() {
|
||||
// The name it goes by: the user's nickname, the pet's own, else Sagi.
|
||||
const [name, setName] = useState(() => petName(sagiPrefs.getPet()));
|
||||
const [size, setSize] = useState(() => sagiPrefs.getSize());
|
||||
// The chat window (PetChat.tsx): the pet's whole UI.
|
||||
const [open, setOpen] = useState(false);
|
||||
// The chat window (PetChat.tsx), and the Ask question it should send.
|
||||
const [chatOpen, setChatOpen] = useState(false);
|
||||
const [chatPrompt, setChatPrompt] = useState<string | null>(null);
|
||||
// The right-click pet menu (`PetMenu.tsx`).
|
||||
const [menu, setMenu] = useState(false);
|
||||
const [hover, setHover] = useState(false);
|
||||
@@ -296,7 +282,6 @@ function SagiBody() {
|
||||
setOpen((v) => !v);
|
||||
} else if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
setChatOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
@@ -346,7 +331,6 @@ function SagiBody() {
|
||||
navigate(workspaceHref(s, runs) ?? `/sessions/${encodeURIComponent(s.id)}`);
|
||||
brain.dismissBubble();
|
||||
setOpen(false);
|
||||
setChatOpen(false);
|
||||
};
|
||||
api.run
|
||||
.list()
|
||||
@@ -356,21 +340,17 @@ function SagiBody() {
|
||||
[brain, navigate]
|
||||
);
|
||||
|
||||
const onAsk = useCallback(
|
||||
(query: string): string | null => {
|
||||
// Asked by the chat before any session runs.
|
||||
const onLocal = useCallback(
|
||||
(query: string): PetLocalReply => {
|
||||
const result = matchIntent(query, brain.status, { name, attention: brain.attention });
|
||||
if (result.kind === "answer") {
|
||||
if (result.moment) brain.playMoment(result.moment);
|
||||
return result.text;
|
||||
}
|
||||
// Handoff: a question the pet cannot answer goes to the chat window,
|
||||
// which starts a session of the default AI tool and shows it as chat.
|
||||
if (petChatTool()) {
|
||||
setChatPrompt(result.prompt);
|
||||
setOpen(false);
|
||||
setChatOpen(true);
|
||||
return null;
|
||||
return { answer: result.text };
|
||||
}
|
||||
// A question the pet cannot answer: the chat starts a session of the
|
||||
// default AI tool and shows it as chat.
|
||||
if (petChatTool()) return null;
|
||||
// A default tool the chat cannot read back (not Claude/Codex): start it
|
||||
// in the "Sagi" workspace and open its pane there, as before.
|
||||
const prompt = result.prompt;
|
||||
@@ -392,7 +372,7 @@ function SagiBody() {
|
||||
brain.setThinking(false);
|
||||
}
|
||||
})();
|
||||
return null;
|
||||
return { handled: true };
|
||||
},
|
||||
[brain, navigate, t, name]
|
||||
);
|
||||
@@ -431,46 +411,49 @@ function SagiBody() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* The panel is too big to drag around, so it hides mid-drag. The bubble
|
||||
does not - it is Sagi talking, and it has to stay by its mouth. */}
|
||||
{!place.dragging && open && (
|
||||
<SagiFlyout anchor={anchor}>
|
||||
<SagiPanel
|
||||
name={name}
|
||||
ownName={"kind" in pet ? pet.name || "Sagi" : "Sagi"}
|
||||
nickname={sagiPrefs.getNickname(pet.id)}
|
||||
onRename={(next) => sagiPrefs.setNickname(pet.id, next)}
|
||||
onPat={brain.pat}
|
||||
attention={brain.attention}
|
||||
onOpenSession={openSession}
|
||||
status={brain.status}
|
||||
muted={brain.muted}
|
||||
onToggleMute={brain.toggleMute}
|
||||
onClearAlerts={brain.clearAlerts}
|
||||
onNavigate={onNavigate}
|
||||
onAsk={onAsk}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
</SagiFlyout>
|
||||
)}
|
||||
|
||||
{/* Beside the pet, riding along with it mid-drag too (SagiFlyout follows
|
||||
it 1:1), and kept mounted: unmounting would detach its terminal and
|
||||
lose a half-typed message. */}
|
||||
{chatOpen && (
|
||||
it 1:1). Closing unmounts it; its chat is remembered, so reopening
|
||||
re-attaches the same session. */}
|
||||
{open && (
|
||||
<SagiFlyout anchor={anchor} beside>
|
||||
<PetChat
|
||||
name={name}
|
||||
initialPrompt={chatPrompt}
|
||||
onPromptTaken={() => setChatPrompt(null)}
|
||||
onBack={() => {
|
||||
setChatOpen(false);
|
||||
setOpen(true);
|
||||
}}
|
||||
onClose={() => setChatOpen(false)}
|
||||
title={
|
||||
<PetNameButton
|
||||
name={name}
|
||||
ownName={"kind" in pet ? pet.name || "Sagi" : "Sagi"}
|
||||
nickname={sagiPrefs.getNickname(pet.id)}
|
||||
onRename={(next) => sagiPrefs.setNickname(pet.id, next)}
|
||||
/>
|
||||
}
|
||||
headerActions={
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-status-danger"
|
||||
onClick={brain.pat}
|
||||
aria-label={t("sagi.panel.pat", { name })}
|
||||
title={t("sagi.panel.pat", { name })}
|
||||
>
|
||||
<Heart size={15} />
|
||||
</button>
|
||||
}
|
||||
info={
|
||||
<SagiInfo
|
||||
attention={brain.attention}
|
||||
onOpenSession={openSession}
|
||||
status={brain.status}
|
||||
muted={brain.muted}
|
||||
onToggleMute={brain.toggleMute}
|
||||
onClearAlerts={brain.clearAlerts}
|
||||
onNavigate={onNavigate}
|
||||
defaultOpen={brain.attention.length > 0}
|
||||
/>
|
||||
}
|
||||
onLocal={onLocal}
|
||||
onClose={() => setOpen(false)}
|
||||
onOpenWorkspace={(laneId, runId) => {
|
||||
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
|
||||
setChatOpen(false);
|
||||
setOpen(false);
|
||||
}}
|
||||
onBusy={onBusy}
|
||||
/>
|
||||
@@ -491,7 +474,7 @@ function SagiBody() {
|
||||
</SagiFlyout>
|
||||
)}
|
||||
|
||||
{!open && !menu && !chatOpen && brain.bubble && (
|
||||
{!open && !menu && brain.bubble && (
|
||||
<SagiFlyout anchor={anchor}>
|
||||
<SpeechBubble
|
||||
bubble={brain.bubble}
|
||||
@@ -519,18 +502,16 @@ function SagiBody() {
|
||||
// A drag just ended - swallow the synthetic click so the panel
|
||||
// doesn't toggle when the user only repositioned the avatar.
|
||||
if (place.consumeDrag()) return;
|
||||
if (chatOpen) setChatOpen(false);
|
||||
else setOpen((v) => !v);
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
// Right-click: the pet menu (hide, size, choose, upload, import).
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
setChatOpen(false);
|
||||
setMenu(true);
|
||||
}}
|
||||
aria-label={open || chatOpen ? t("sagi.closeAvatar", { name }) : t("sagi.open", { name })}
|
||||
aria-expanded={open || chatOpen}
|
||||
aria-label={open ? t("sagi.closeAvatar", { name }) : t("sagi.open", { name })}
|
||||
aria-expanded={open}
|
||||
title={t("sagi.avatarTitle", { name })}
|
||||
>
|
||||
{pet.id === "sagi" || !("kind" in pet) ? (
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* @file SagiInfo.tsx
|
||||
* @description What the pet knows, shown inside its chat window (`PetChat`):
|
||||
* the name you rename by clicking it (`PetNameButton`), and a status strip
|
||||
* (`SagiInfo`) — live / waiting / errored counts and the connection state
|
||||
* always in sight, and on expanding, the sessions that need the user, by
|
||||
* name, plus Mute and Clear alerts. Pure presentational: all data and
|
||||
* behavior are injected by the container (`Sagi.tsx`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
BellOff,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Hourglass,
|
||||
Radio,
|
||||
Trash2,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { subjectLabel, type AttentionItem, type SagiStatus, type SagiSubject } from "./brain";
|
||||
import { PET_NAME_MAX } from "./prefs";
|
||||
|
||||
/** How many sessions the "Needs you" list shows before pointing at Sessions. */
|
||||
const ATTENTION_SHOWN = 4;
|
||||
|
||||
/** The pet's name; a click renames it in place (empty = its own name). */
|
||||
export function PetNameButton({
|
||||
name,
|
||||
ownName,
|
||||
nickname,
|
||||
onRename,
|
||||
}: {
|
||||
name: string;
|
||||
ownName: string;
|
||||
nickname: string;
|
||||
onRename: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
// The name being typed, or null while not renaming.
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
const save = () => {
|
||||
if (renaming === null) return;
|
||||
onRename(renaming);
|
||||
setRenaming(null);
|
||||
};
|
||||
return renaming === null ? (
|
||||
<button
|
||||
type="button"
|
||||
className="max-w-full truncate rounded px-0.5 text-sm font-semibold text-fg-primary hover:bg-surface-4"
|
||||
onClick={() => setRenaming(nickname)}
|
||||
aria-label={t("sagi.panel.rename", { name })}
|
||||
title={t("sagi.panel.rename", { name })}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
) : (
|
||||
<input
|
||||
autoFocus
|
||||
className="w-28 rounded border border-accent bg-surface-1 px-1 py-0.5 text-sm text-fg-primary focus:outline-none"
|
||||
value={renaming}
|
||||
maxLength={PET_NAME_MAX}
|
||||
placeholder={ownName}
|
||||
aria-label={t("sagi.panel.renameLabel")}
|
||||
title={t("sagi.panel.renameHint")}
|
||||
onChange={(e) => setRenaming(e.target.value)}
|
||||
onBlur={save}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
save();
|
||||
} else if (e.key === "Escape") {
|
||||
// Esc cancels the rename only, not the whole chat.
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
setRenaming(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface SagiInfoProps {
|
||||
attention: ReadonlyArray<AttentionItem>;
|
||||
onOpenSession: (s: SagiSubject) => void;
|
||||
status: SagiStatus;
|
||||
muted: boolean;
|
||||
onToggleMute: () => void;
|
||||
onClearAlerts: () => void;
|
||||
onNavigate: (route: string) => void;
|
||||
/** Expanded at first: the sessions that need the user and the actions. */
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
/** The status strip under the chat's header. */
|
||||
export function SagiInfo({
|
||||
attention,
|
||||
onOpenSession,
|
||||
status,
|
||||
muted,
|
||||
onToggleMute,
|
||||
onClearAlerts,
|
||||
onNavigate,
|
||||
defaultOpen = false,
|
||||
}: SagiInfoProps) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const Chevron = open ? ChevronDown : ChevronRight;
|
||||
return (
|
||||
<div className="pet-chat-info border-b border-border/60 px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-1.5 rounded-lg text-left"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
aria-label={t("sagi.chat.status")}
|
||||
title={t("sagi.chat.status")}
|
||||
>
|
||||
<StatChip
|
||||
icon={Radio}
|
||||
label={t("sagi.panel.statLive")}
|
||||
value={status.liveCount}
|
||||
tone={status.liveCount > 0 ? "accent" : "muted"}
|
||||
/>
|
||||
<StatChip
|
||||
icon={Hourglass}
|
||||
label={t("sagi.panel.statWaiting")}
|
||||
value={status.waitingCount}
|
||||
tone={status.waitingCount > 0 ? "amber" : "muted"}
|
||||
/>
|
||||
<StatChip
|
||||
icon={AlertTriangle}
|
||||
label={t("sagi.panel.statErrored")}
|
||||
value={status.errorCount}
|
||||
tone={status.errorCount > 0 ? "red" : "muted"}
|
||||
/>
|
||||
<span
|
||||
className={`ml-auto inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
|
||||
status.connected
|
||||
? "bg-status-success/15 text-status-success"
|
||||
: "bg-status-danger/15 text-status-danger"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-1.5 w-1.5 rounded-full ${
|
||||
status.connected ? "bg-status-success" : "bg-status-danger"
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{status.connected ? t("sagi.panel.live") : t("sagi.panel.offline")}
|
||||
</span>
|
||||
<Chevron size={13} className="shrink-0 text-fg-muted" aria-hidden />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="mt-2 max-h-48 overflow-y-auto">
|
||||
{/* the sessions that need the user, by name */}
|
||||
{attention.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-fg-muted">
|
||||
{t("sagi.panel.attention")}
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{attention.slice(0, ATTENTION_SHOWN).map((item) => (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
className="group flex w-full items-center gap-2 rounded-lg bg-surface-1 px-2 py-1.5 text-left text-xs transition-colors hover:bg-surface-4"
|
||||
onClick={() => onOpenSession(item)}
|
||||
title={item.cwd ?? item.id}
|
||||
>
|
||||
{item.kind === "error" ? (
|
||||
<AlertTriangle
|
||||
size={13}
|
||||
className="shrink-0 text-status-danger"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<Hourglass size={13} className="shrink-0 text-status-warning" aria-hidden />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-fg-secondary group-hover:text-fg-primary">
|
||||
{subjectLabel(item)}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-fg-muted">
|
||||
{item.kind === "error"
|
||||
? t("sagi.panel.attentionError")
|
||||
: t("sagi.panel.attentionWaiting")}
|
||||
</span>
|
||||
<ChevronRight size={12} className="shrink-0 text-fg-muted" aria-hidden />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{attention.length > ATTENTION_SHOWN && (
|
||||
<button
|
||||
className="mt-1 text-[11px] text-accent hover:underline"
|
||||
onClick={() => onNavigate("/sessions")}
|
||||
>
|
||||
{t("sagi.panel.attentionMore", { count: attention.length - ATTENTION_SHOWN })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<ActionButton
|
||||
icon={muted ? BellOff : Bell}
|
||||
label={muted ? t("sagi.panel.unmute") : t("sagi.panel.mute")}
|
||||
onClick={onToggleMute}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={Trash2}
|
||||
label={t("sagi.panel.clearAlerts")}
|
||||
disabled={status.errorCount === 0}
|
||||
onClick={onClearAlerts}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Tone {
|
||||
wrap: string;
|
||||
value: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const TONE_MUTED: Tone = {
|
||||
wrap: "border-border bg-surface-1",
|
||||
value: "text-fg-secondary",
|
||||
icon: "text-fg-muted",
|
||||
};
|
||||
|
||||
const TONES: Record<string, Tone> = {
|
||||
accent: { wrap: "border-accent/30 bg-accent/10", value: "text-fg-primary", icon: "text-accent" },
|
||||
amber: {
|
||||
wrap: "border-status-warning/30 bg-status-warning/10",
|
||||
value: "text-status-warning",
|
||||
icon: "text-status-warning",
|
||||
},
|
||||
red: {
|
||||
wrap: "border-status-danger/30 bg-status-danger/10",
|
||||
value: "text-status-danger",
|
||||
icon: "text-status-danger",
|
||||
},
|
||||
muted: TONE_MUTED,
|
||||
};
|
||||
|
||||
/** One count, compact: icon, number, label on one line. */
|
||||
function StatChip({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: number;
|
||||
tone: string;
|
||||
}) {
|
||||
const t = TONES[tone] ?? TONE_MUTED;
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 ${t.wrap}`}>
|
||||
<Icon size={11} className={t.icon} aria-hidden />
|
||||
<span className={`text-xs font-semibold tabular-nums ${t.value}`}>{value}</span>
|
||||
<span className="text-[9px] uppercase tracking-wider text-fg-muted">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className="flex w-full items-center gap-1.5 rounded-lg bg-surface-1 px-2 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon size={14} className="shrink-0" />
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,392 +0,0 @@
|
||||
/**
|
||||
* @file SagiPanel.tsx
|
||||
* @description Expanded Sagi panel: a live status strip (live / waiting /
|
||||
* errored stat chips + connection state), quick navigation actions, and a
|
||||
* local "Ask" box. Pure presentational - all data and the ask/navigation
|
||||
* behavior are injected by the container.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Sagi is the optional on-screen mascot assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `./brain`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `SagiPanel` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **SagiPanel**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useState, type FormEvent, type ReactNode } from "react";
|
||||
import {
|
||||
Bell,
|
||||
BellOff,
|
||||
Trash2,
|
||||
X,
|
||||
Send,
|
||||
AlertTriangle,
|
||||
Hourglass,
|
||||
Radio,
|
||||
Heart,
|
||||
ChevronRight,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { subjectLabel, type AttentionItem, type SagiStatus, type SagiSubject } from "./brain";
|
||||
import { PET_NAME_MAX } from "./prefs";
|
||||
|
||||
/** How many sessions the "Needs you" list shows before pointing at Sessions. */
|
||||
const ATTENTION_SHOWN = 4;
|
||||
|
||||
interface SagiPanelProps {
|
||||
/** The name the pet goes by now. */
|
||||
name: string;
|
||||
/** The pet's own name, which an empty nickname falls back to. */
|
||||
ownName: string;
|
||||
/** The user's nickname for the pet, "" when none. */
|
||||
nickname: string;
|
||||
onRename: (name: string) => void;
|
||||
onPat: () => void;
|
||||
/** Errored, then waiting sessions, named. */
|
||||
attention: ReadonlyArray<AttentionItem>;
|
||||
onOpenSession: (s: SagiSubject) => void;
|
||||
status: SagiStatus;
|
||||
muted: boolean;
|
||||
onToggleMute: () => void;
|
||||
onClearAlerts: () => void;
|
||||
onNavigate: (route: string) => void;
|
||||
/** Returns an answer to display, or null when the query was handed off. */
|
||||
onAsk: (query: string) => string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SagiPanel({
|
||||
name,
|
||||
ownName,
|
||||
nickname,
|
||||
onRename,
|
||||
onPat,
|
||||
attention,
|
||||
onOpenSession,
|
||||
status,
|
||||
muted,
|
||||
onToggleMute,
|
||||
onClearAlerts,
|
||||
onNavigate,
|
||||
onAsk,
|
||||
onClose,
|
||||
}: SagiPanelProps) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [query, setQuery] = useState("");
|
||||
const [answer, setAnswer] = useState<string | null>(null);
|
||||
// The name being typed, or null while not renaming.
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
const saveName = () => {
|
||||
if (renaming === null) return;
|
||||
onRename(renaming);
|
||||
setRenaming(null);
|
||||
};
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const result = onAsk(query);
|
||||
setAnswer(result); // null means it handed off (container navigates/closes)
|
||||
setQuery("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-72 overflow-hidden rounded-2xl border border-border-light bg-surface-2/95 shadow-2xl backdrop-blur-md animate-slide-up"
|
||||
role="dialog"
|
||||
aria-label={t("sagi.panel.label", { name })}
|
||||
>
|
||||
{/* header */}
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border/70 bg-gradient-to-r from-accent/10 to-transparent px-3.5 py-2.5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-base leading-none" aria-hidden>
|
||||
🐾
|
||||
</span>
|
||||
{renaming === null ? (
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 truncate rounded px-0.5 text-sm font-semibold text-fg-primary hover:bg-surface-4"
|
||||
onClick={() => setRenaming(nickname)}
|
||||
aria-label={t("sagi.panel.rename", { name })}
|
||||
title={t("sagi.panel.rename", { name })}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
) : (
|
||||
<input
|
||||
autoFocus
|
||||
className="w-28 rounded border border-accent bg-surface-1 px-1 py-0.5 text-sm text-fg-primary focus:outline-none"
|
||||
value={renaming}
|
||||
maxLength={PET_NAME_MAX}
|
||||
placeholder={ownName}
|
||||
aria-label={t("sagi.panel.renameLabel")}
|
||||
title={t("sagi.panel.renameHint")}
|
||||
onChange={(e) => setRenaming(e.target.value)}
|
||||
onBlur={saveName}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
saveName();
|
||||
} else if (e.key === "Escape") {
|
||||
// Esc cancels the rename only, not the whole panel.
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
setRenaming(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={`ml-0.5 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
|
||||
status.connected
|
||||
? "bg-status-success/15 text-status-success"
|
||||
: "bg-status-danger/15 text-status-danger"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-1.5 w-1.5 rounded-full ${
|
||||
status.connected ? "bg-status-success" : "bg-status-danger"
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{status.connected ? t("sagi.panel.live") : t("sagi.panel.offline")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center">
|
||||
<button
|
||||
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-status-danger"
|
||||
onClick={onPat}
|
||||
aria-label={t("sagi.panel.pat", { name })}
|
||||
title={t("sagi.panel.pat", { name })}
|
||||
>
|
||||
<Heart size={15} />
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-secondary"
|
||||
onClick={onClose}
|
||||
aria-label={t("sagi.panel.close", { name })}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3">
|
||||
{/* status stat chips */}
|
||||
<div className="mb-3 grid grid-cols-3 gap-1.5">
|
||||
<StatChip
|
||||
icon={Radio}
|
||||
label={t("sagi.panel.statLive")}
|
||||
value={status.liveCount}
|
||||
tone={status.liveCount > 0 ? "accent" : "muted"}
|
||||
/>
|
||||
<StatChip
|
||||
icon={Hourglass}
|
||||
label={t("sagi.panel.statWaiting")}
|
||||
value={status.waitingCount}
|
||||
tone={status.waitingCount > 0 ? "amber" : "muted"}
|
||||
/>
|
||||
<StatChip
|
||||
icon={AlertTriangle}
|
||||
label={t("sagi.panel.statErrored")}
|
||||
value={status.errorCount}
|
||||
tone={status.errorCount > 0 ? "red" : "muted"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* the sessions that need the user, by name */}
|
||||
{attention.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-fg-muted">
|
||||
{t("sagi.panel.attention")}
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{attention.slice(0, ATTENTION_SHOWN).map((item) => (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
className="group flex w-full items-center gap-2 rounded-lg bg-surface-1 px-2 py-1.5 text-left text-xs transition-colors hover:bg-surface-4"
|
||||
onClick={() => onOpenSession(item)}
|
||||
title={item.cwd ?? item.id}
|
||||
>
|
||||
{item.kind === "error" ? (
|
||||
<AlertTriangle
|
||||
size={13}
|
||||
className="shrink-0 text-status-danger"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<Hourglass size={13} className="shrink-0 text-status-warning" aria-hidden />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-fg-secondary group-hover:text-fg-primary">
|
||||
{subjectLabel(item)}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-fg-muted">
|
||||
{item.kind === "error"
|
||||
? t("sagi.panel.attentionError")
|
||||
: t("sagi.panel.attentionWaiting")}
|
||||
</span>
|
||||
<ChevronRight size={12} className="shrink-0 text-fg-muted" aria-hidden />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{attention.length > ATTENTION_SHOWN && (
|
||||
<button
|
||||
className="mt-1 text-[11px] text-accent hover:underline"
|
||||
onClick={() => onNavigate("/sessions")}
|
||||
>
|
||||
{t("sagi.panel.attentionMore", { count: attention.length - ATTENTION_SHOWN })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* quick actions */}
|
||||
<div className="mb-3 grid grid-cols-2 gap-1.5">
|
||||
<ActionButton
|
||||
icon={muted ? BellOff : Bell}
|
||||
label={muted ? t("sagi.panel.unmute") : t("sagi.panel.mute")}
|
||||
onClick={onToggleMute}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={Trash2}
|
||||
label={t("sagi.panel.clearAlerts")}
|
||||
disabled={status.errorCount === 0}
|
||||
onClick={onClearAlerts}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ask */}
|
||||
<form onSubmit={submit} className="flex items-center gap-1.5">
|
||||
<input
|
||||
className="flex-1 rounded-lg border border-border bg-surface-1 px-2.5 py-1.5 text-xs text-fg-secondary placeholder-fg-muted transition-colors focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
placeholder={t("sagi.panel.askPlaceholder", { name })}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
aria-label={t("sagi.panel.ask", { name })}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary justify-center px-2.5 py-2"
|
||||
aria-label={t("sagi.panel.send")}
|
||||
>
|
||||
<Send size={14} />
|
||||
</button>
|
||||
</form>
|
||||
{answer && (
|
||||
<p className="mt-2 rounded-lg bg-surface-1/70 px-2.5 py-2 text-xs leading-relaxed text-fg-secondary">
|
||||
{answer}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Tone {
|
||||
wrap: string;
|
||||
value: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const TONE_MUTED: Tone = {
|
||||
wrap: "border-border bg-surface-1",
|
||||
value: "text-fg-secondary",
|
||||
icon: "text-fg-muted",
|
||||
};
|
||||
|
||||
const TONES: Record<string, Tone> = {
|
||||
accent: { wrap: "border-accent/30 bg-accent/10", value: "text-fg-primary", icon: "text-accent" },
|
||||
amber: {
|
||||
wrap: "border-status-warning/30 bg-status-warning/10",
|
||||
value: "text-status-warning",
|
||||
icon: "text-status-warning",
|
||||
},
|
||||
red: {
|
||||
wrap: "border-status-danger/30 bg-status-danger/10",
|
||||
value: "text-status-danger",
|
||||
icon: "text-status-danger",
|
||||
},
|
||||
muted: TONE_MUTED,
|
||||
};
|
||||
|
||||
function StatChip({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: number;
|
||||
tone: string;
|
||||
}): ReactNode {
|
||||
const t = TONES[tone] ?? TONE_MUTED;
|
||||
return (
|
||||
<div className={`flex flex-col items-center gap-0.5 rounded-xl border py-1.5 ${t.wrap}`}>
|
||||
<Icon size={13} className={t.icon} aria-hidden />
|
||||
<span className={`text-base font-semibold leading-none tabular-nums ${t.value}`}>
|
||||
{value}
|
||||
</span>
|
||||
<span className="text-[9px] uppercase tracking-wider text-fg-muted">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className="flex w-full items-center gap-1.5 rounded-lg bg-surface-1 px-2 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon size={14} className="shrink-0" />
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file Sagi.test.tsx
|
||||
* @description Render tests for the Sagi companion component — mounting, mood rendering, the ⌘B panel toggle, and accessibility attributes.
|
||||
* @description Render tests for the Sagi companion component — mounting, mood rendering, the ⌘B chat toggle (the chat window is the pet's whole UI: status strip, local answers, New chat stopping the old session), and accessibility attributes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useSagiPosition } from "../useSagiPosition";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { Sagi } from "../Sagi";
|
||||
import { eventBus } from "../../../lib/eventBus";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { WSMessage, Session } from "../../../lib/types";
|
||||
|
||||
function renderSagi() {
|
||||
@@ -54,13 +55,13 @@ describe("Sagi widget", () => {
|
||||
expect(screen.getByRole("img", { name: /sagi/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the panel on click and answers a local status question", () => {
|
||||
it("opens the chat on click and answers a local status question there", () => {
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
const panel = screen.getByRole("dialog", { name: /sagi companion/i });
|
||||
const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
|
||||
expect(panel).toBeInTheDocument();
|
||||
|
||||
const input = within(panel).getByLabelText(/ask sagi/i);
|
||||
const input = within(panel).getByLabelText(/ask sagi anything/i);
|
||||
fireEvent.change(input, { target: { value: "status" } });
|
||||
fireEvent.submit(input.closest("form")!);
|
||||
expect(within(panel).getByText(/live ·/i)).toBeInTheDocument();
|
||||
@@ -88,7 +89,7 @@ describe("Sagi widget", () => {
|
||||
expect(within(btn).getByText("1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names the errored session in its bubble and in the panel", () => {
|
||||
it("names the errored session in its bubble and in the chat", () => {
|
||||
renderSagi();
|
||||
act(() => {
|
||||
eventBus.publish({
|
||||
@@ -101,7 +102,7 @@ describe("Sagi widget", () => {
|
||||
expect(within(bubble).getByText("Fix login")).toBeInTheDocument();
|
||||
expect(within(bubble).getByRole("button", { name: "Open" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
const panel = screen.getByRole("dialog", { name: /sagi companion/i });
|
||||
const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
|
||||
expect(within(panel).getByText("Needs you")).toBeInTheDocument();
|
||||
expect(within(panel).getByRole("button", { name: /Fix login/ })).toBeInTheDocument();
|
||||
});
|
||||
@@ -121,43 +122,54 @@ describe("Sagi widget", () => {
|
||||
expect(JSON.parse(localStorage.getItem("agenthub.pet.dismissedErrors")!)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("renames the pet from the panel header", () => {
|
||||
it("renames the pet from the chat header", () => {
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /rename sagi/i }));
|
||||
const input = screen.getByLabelText("Pet name");
|
||||
fireEvent.change(input, { target: { value: "Mochi" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.getByRole("dialog", { name: /mochi companion/i })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/ask mochi/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("dialog", { name: /chat with mochi/i })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/ask mochi anything/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hands a question it cannot answer to the chat window", () => {
|
||||
it("hands a question it cannot answer to the AI", () => {
|
||||
localStorage.setItem("agenthub.defaultTool", "claude");
|
||||
const ensure = vi.spyOn(api.lanes, "ensure").mockReturnValue(new Promise(() => {}));
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
const input = screen.getByLabelText(/ask sagi/i);
|
||||
fireEvent.change(input, { target: { value: "write me a haiku" } });
|
||||
fireEvent.submit(input.closest("form")!);
|
||||
const chat = screen.getByRole("dialog", { name: /chat with sagi/i });
|
||||
expect(within(chat).getByText("write me a haiku")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog", { name: /sagi companion/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has no Chat button — Ask opens the chat — and Back returns to the panel", () => {
|
||||
localStorage.setItem("agenthub.defaultTool", "claude");
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
expect(screen.queryByRole("button", { name: /chat with sagi/i })).not.toBeInTheDocument();
|
||||
const input = screen.getByLabelText(/ask sagi/i);
|
||||
const input = within(chat).getByLabelText(/ask sagi anything/i);
|
||||
fireEvent.change(input, { target: { value: "write me a haiku" } });
|
||||
fireEvent.submit(input.closest("form")!);
|
||||
expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Back" }));
|
||||
expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument();
|
||||
expect(within(chat).getByText("write me a haiku")).toBeInTheDocument();
|
||||
expect(ensure).toHaveBeenCalledWith({ title: "Sagi" });
|
||||
ensure.mockRestore();
|
||||
});
|
||||
|
||||
it("a pat shows its reaction on the pet even while the panel is open", () => {
|
||||
it("reopens the remembered chat, and New chat stops its session", async () => {
|
||||
localStorage.setItem(
|
||||
"agenthub.pet.chat",
|
||||
JSON.stringify({ laneId: 7, runId: "agenthub-lane-7-1", tool: "claude" })
|
||||
);
|
||||
const get = vi
|
||||
.spyOn(api.run, "get")
|
||||
.mockReturnValue(new Promise(() => {}) as ReturnType<typeof api.run.get>);
|
||||
const kill = vi.spyOn(api.run, "kill").mockResolvedValue({ ok: true });
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
expect(screen.getByRole("button", { name: /open in workspace/i })).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /new chat/i }));
|
||||
});
|
||||
expect(kill).toHaveBeenCalledWith("agenthub-lane-7-1", { archive: true });
|
||||
expect(localStorage.getItem("agenthub.pet.chat")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /open in workspace/i })).not.toBeInTheDocument();
|
||||
get.mockRestore();
|
||||
kill.mockRestore();
|
||||
});
|
||||
|
||||
it("a pat shows its reaction on the pet even while the chat is open", () => {
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /pat sagi/i }));
|
||||
@@ -175,16 +187,16 @@ describe("Sagi widget", () => {
|
||||
expect(pet.querySelector(".sagi-reaction-text")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("reflects the live count in the panel status", () => {
|
||||
it("reflects the live count in the chat status", () => {
|
||||
renderSagi();
|
||||
act(() => {
|
||||
eventBus.publish(sessionMsg("a", "active"));
|
||||
eventBus.publish(sessionMsg("b", "active"));
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
const panel = screen.getByRole("dialog", { name: /sagi companion/i });
|
||||
const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
|
||||
// The "live" stat chip shows value 2 next to its label.
|
||||
const liveChip = within(panel).getByText("live").closest("div")!;
|
||||
const liveChip = within(panel).getByText("live").closest("span.rounded-full")! as HTMLElement;
|
||||
expect(within(liveChip).getByText("2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -194,7 +206,7 @@ describe("Sagi widget", () => {
|
||||
expect(screen.queryByRole("button", { name: /open sagi companion/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("a tap (no movement) still opens the panel", () => {
|
||||
it("a tap (no movement) still opens the chat", () => {
|
||||
renderSagi();
|
||||
const btn = screen.getByRole("button", { name: /open sagi companion/i });
|
||||
act(() => {
|
||||
@@ -202,7 +214,7 @@ describe("Sagi widget", () => {
|
||||
fireEvent.pointerUp(btn, { clientX: 990, clientY: 700 });
|
||||
});
|
||||
fireEvent.click(btn);
|
||||
expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("dragging snaps to an edge, persists position, and does not open the panel", () => {
|
||||
@@ -231,7 +243,7 @@ describe("Sagi widget", () => {
|
||||
fireEvent.pointerUp(btn, { clientX: 992, clientY: 701 });
|
||||
});
|
||||
fireEvent.click(btn);
|
||||
expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument();
|
||||
// No position was persisted because no real drag happened.
|
||||
expect(localStorage.getItem("agent-dashboard-sagi-pos")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -9,8 +9,12 @@
|
||||
* Workspace page, the open workspace's total (`?lane=` on the same call: the
|
||||
* sessions it owns) and the shown session's (`GET /api/pricing/cost/:id` for
|
||||
* the URL's `session`, or the focused pane's session from the page's run poll,
|
||||
* `LIVE_RUNS_EVENT`), all re-read every 30 s while the tab is visible and
|
||||
* whenever the URL names another workspace or session. Each figure carries its
|
||||
* `LIVE_RUNS_EVENT`), all re-read at once whenever the URL names another
|
||||
* workspace or session, then at most every 30 s while events or session
|
||||
* updates arrive (spend only moves with work) and every 2 min otherwise, while
|
||||
* the tab is visible. The session counts re-read only when a session is
|
||||
* created or its status / waiting / archived state changes (every 60 s
|
||||
* otherwise). Each figure carries its
|
||||
* token total, and hovering it lists tokens and cost per model. At the right
|
||||
* end, the plan quota spent per native tool (`GET /api/pricing/quota`, every
|
||||
* 5 min while visible; a click forces a re-read): one "N% used" chip per tool —
|
||||
@@ -31,9 +35,15 @@ import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
|
||||
import { AiToolIcon } from "./AiToolIcon";
|
||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||
import { useSpin } from "../hooks/useSpin";
|
||||
import { sessionChangeMatcher } from "../lib/sessionChange";
|
||||
import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
|
||||
|
||||
/** Spend moves only with work: at most this often while events arrive… */
|
||||
const COST_POLL_MS = 30_000;
|
||||
/** …and this often regardless (date rollover, budget period, a missed frame). */
|
||||
const COST_FALLBACK_MS = 120_000;
|
||||
/** The counts' safety poll; session frames that change a count drive the rest. */
|
||||
const COUNTS_FALLBACK_MS = 60_000;
|
||||
const QUOTA_POLL_MS = 5 * 60_000;
|
||||
const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = {
|
||||
claude: "Claude",
|
||||
@@ -384,51 +394,57 @@ export function StatusBar({
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
useEffect(loadCounts, [loadCounts]);
|
||||
useLiveRefresh(loadCounts, {
|
||||
intervalMs: 15_000,
|
||||
match: (msg) => msg.type === "session_updated" || msg.type === "session_created",
|
||||
});
|
||||
// Only a frame that moves a session between the counted states (a new
|
||||
// session, a status / waiting / archived change) re-reads: a session row is
|
||||
// re-broadcast on almost every hook.
|
||||
const [countsChanged] = useState(() => sessionChangeMatcher());
|
||||
useLiveRefresh(loadCounts, { intervalMs: COUNTS_FALLBACK_MS, match: countsChanged });
|
||||
|
||||
// Bumped on every lane/session switch: an answer for the previous one is dropped.
|
||||
const costGen = useRef(0);
|
||||
const loadCosts = useCallback(() => {
|
||||
const gen = costGen.current;
|
||||
const live = () => gen === costGen.current;
|
||||
api.pricing
|
||||
.totalCost({ date: localDate() })
|
||||
.then((r) => live() && setToday(r))
|
||||
.catch(() => undefined);
|
||||
api.budgets
|
||||
.status()
|
||||
.then(({ budgets }) => {
|
||||
const hit = budgets.filter((b) => b.crossed.length > 0).sort((a, b) => b.pct - a.pct);
|
||||
if (live()) setBudget(hit[0] ?? null);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
if (lane !== null) {
|
||||
api.pricing
|
||||
.totalCost({ lane })
|
||||
.then((r) => live() && setLaneCost(r))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
if (sessionId) {
|
||||
api.pricing
|
||||
.sessionCost(sessionId)
|
||||
.then((r) => live() && setSessionCost(r))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}, [lane, sessionId]);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
costGen.current++;
|
||||
// Another workspace or session: never show the previous one's figure.
|
||||
setLaneCost(null);
|
||||
setSessionCost(null);
|
||||
const tick = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
api.pricing
|
||||
.totalCost({ date: localDate() })
|
||||
.then((r) => live && setToday(r))
|
||||
.catch(() => undefined);
|
||||
api.budgets
|
||||
.status()
|
||||
.then(({ budgets }) => {
|
||||
const hit = budgets.filter((b) => b.crossed.length > 0).sort((a, b) => b.pct - a.pct);
|
||||
if (live) setBudget(hit[0] ?? null);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
if (lane !== null) {
|
||||
api.pricing
|
||||
.totalCost({ lane })
|
||||
.then((r) => live && setLaneCost(r))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
if (sessionId) {
|
||||
api.pricing
|
||||
.sessionCost(sessionId)
|
||||
.then((r) => live && setSessionCost(r))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
};
|
||||
tick();
|
||||
const id = setInterval(tick, COST_POLL_MS);
|
||||
document.addEventListener("visibilitychange", tick);
|
||||
if (document.visibilityState === "visible") loadCosts();
|
||||
return () => {
|
||||
live = false;
|
||||
clearInterval(id);
|
||||
document.removeEventListener("visibilitychange", tick);
|
||||
costGen.current++;
|
||||
};
|
||||
}, [lane, sessionId]);
|
||||
}, [loadCosts]);
|
||||
useLiveRefresh(loadCosts, {
|
||||
intervalMs: COST_FALLBACK_MS,
|
||||
throttleMs: COST_POLL_MS,
|
||||
match: (msg) =>
|
||||
msg.type === "new_event" || msg.type === "session_updated" || msg.type === "session_created",
|
||||
});
|
||||
|
||||
return (
|
||||
<footer
|
||||
|
||||
@@ -33,13 +33,16 @@ export function Switch({
|
||||
// On: the solid accent (white knob clears 3:1 in every theme). Off: a
|
||||
// border-light track with an inset fg-muted/80 ring (≥3:1 in every theme), so the control's edge
|
||||
// is visible on any surface (surface-4 alone was ~1:1 on a light card).
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
checked ? "bg-accent-solid" : "bg-border-light ring-1 ring-inset ring-fg-muted/80"
|
||||
// Hover darkens the track (enabled only), so it reads as clickable.
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||
checked
|
||||
? "bg-accent-solid enabled:hover:bg-accent-solid-hover"
|
||||
: "bg-border-light ring-1 ring-inset ring-fg-muted/80 enabled:hover:ring-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${
|
||||
className={`inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-1 ring-black/10 transition-transform duration-200 ease-out ${
|
||||
checked ? "translate-x-[18px]" : "translate-x-0.5"
|
||||
}`}
|
||||
/>
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
* `onOpenTerminal`) and hears how full the context is (`onContext`) and
|
||||
* whether its session has started (`onNotStarted`: no row yet — the CLI may
|
||||
* be asking in the terminal, e.g. to trust the folder, which this then says).
|
||||
* 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 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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -79,6 +85,9 @@ import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/typ
|
||||
// hook event. A short visibility-gated poll closes that gap and also rescues
|
||||
// the conversation from missed/late WebSocket frames.
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
// An ended session writes nothing more: a slow safety poll only, in case it was
|
||||
// resumed somewhere whose frames this view missed.
|
||||
const ENDED_POLL_INTERVAL_MS = 60000;
|
||||
// Rescan the transcripts list periodically so new subagents that spawn
|
||||
// mid-session appear in the dropdown without a page reload.
|
||||
const TRANSCRIPTS_REFRESH_MS = 15000;
|
||||
@@ -110,6 +119,8 @@ interface ConversationViewProps {
|
||||
onContext?: (tokens: number, model: string | null) => void;
|
||||
/** A live pane's session has no row yet: the CLI has not started it. */
|
||||
onNotStarted?: (notStarted: boolean) => void;
|
||||
/** The session has ended (and no pane holds it): poll slowly, if at all. */
|
||||
ended?: boolean;
|
||||
}
|
||||
|
||||
function ConversationViewPane({
|
||||
@@ -124,6 +135,7 @@ function ConversationViewPane({
|
||||
stopped = 0,
|
||||
onContext,
|
||||
onNotStarted,
|
||||
ended = false,
|
||||
}: ConversationViewProps) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||
@@ -188,14 +200,18 @@ function ConversationViewPane({
|
||||
}
|
||||
}
|
||||
loadTranscripts();
|
||||
const interval = window.setInterval(() => {
|
||||
if (isShown(onScreen)) loadTranscripts();
|
||||
}, TRANSCRIPTS_REFRESH_MS);
|
||||
// An ended session spawns no new sub-agents: the load above (re-run when
|
||||
// it ends, for the last ones) is enough.
|
||||
const interval = ended
|
||||
? undefined
|
||||
: window.setInterval(() => {
|
||||
if (isShown(onScreen)) loadTranscripts();
|
||||
}, TRANSCRIPTS_REFRESH_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [sessionId]);
|
||||
}, [sessionId, ended]);
|
||||
|
||||
// Sync external initialTranscriptId to internal state
|
||||
useEffect(() => {
|
||||
@@ -355,9 +371,12 @@ function ConversationViewPane({
|
||||
let interval: number | null = null;
|
||||
function start() {
|
||||
if (interval !== null) return;
|
||||
interval = window.setInterval(() => {
|
||||
if (isShown(onScreen)) fetchNewMessages();
|
||||
}, POLL_INTERVAL_MS);
|
||||
interval = window.setInterval(
|
||||
() => {
|
||||
if (isShown(onScreen)) fetchNewMessages();
|
||||
},
|
||||
ended ? ENDED_POLL_INTERVAL_MS : POLL_INTERVAL_MS
|
||||
);
|
||||
}
|
||||
function stop() {
|
||||
if (interval !== null) {
|
||||
@@ -382,7 +401,7 @@ function ConversationViewPane({
|
||||
stop();
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, [fetchNewMessages, onScreen]);
|
||||
}, [fetchNewMessages, onScreen, ended]);
|
||||
|
||||
// Manual refresh - surfaces a control in the toolbar so users can force
|
||||
// a sync without reloading the page.
|
||||
@@ -605,6 +624,9 @@ function ConversationViewPane({
|
||||
waiting={waiting}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
stopped={stopped}
|
||||
sessionId={
|
||||
selectedTranscript && selectedTranscript !== "main" ? undefined : sessionId
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
* `<task-notification>` 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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -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 <TurnDone key={item.key} ms={item.ms} output={item.output} />;
|
||||
return (
|
||||
<TurnDone
|
||||
key={item.key}
|
||||
ms={item.ms}
|
||||
output={item.output}
|
||||
agents={item === items[items.length - 1] ? agents.length : 0}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
{pending && (
|
||||
@@ -727,6 +750,7 @@ function MessageListView({
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
/>
|
||||
)}
|
||||
{agents.length > 0 && <BackgroundAgents agents={agents} sessionId={sessionId} />}
|
||||
</div>
|
||||
</OpenFileContext.Provider>
|
||||
);
|
||||
@@ -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>([^<]+)<\/tool-use-id>/g;
|
||||
// The launch result names the agent's own transcript (`agent-<id>.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
|
||||
* `<task-notification>` naming that call since (any status — completed,
|
||||
* failed, killed — means it stopped). `since` is its launch time. */
|
||||
export function backgroundAgents(
|
||||
messages: TranscriptMessage[],
|
||||
results: Map<string, TranscriptContent>,
|
||||
now = Date.now()
|
||||
): BackgroundAgent[] {
|
||||
const running = new Map<string, BackgroundAgent>();
|
||||
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<HTMLElement | null>): 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<HTMLDivElement>(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<HTMLElement | null>
|
||||
): Map<string, TranscriptMessage[]> {
|
||||
const [tails, setTails] = useState(() => new Map<string, TranscriptMessage[]>());
|
||||
const lastRef = useRef(new Map<string, number>());
|
||||
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<HTMLDivElement>(null);
|
||||
const now = useClock(boxRef);
|
||||
const ids = useMemo(() => agents.flatMap((a) => (a.agentId ? [a.agentId] : [])), [agents]);
|
||||
const tails = useAgentTails(sessionId, ids, boxRef);
|
||||
return (
|
||||
<div
|
||||
ref={boxRef}
|
||||
data-testid="chat-background-agents"
|
||||
className="overflow-hidden rounded-xl border border-border text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border bg-surface-1 px-3 py-2 text-fg-primary">
|
||||
<Bot className="h-3.5 w-3.5 text-fg-muted" />
|
||||
<span className="flex-1">{t("conversation.agentsRunning", { count: agents.length })}</span>
|
||||
</div>
|
||||
<ul className="divide-y divide-border">
|
||||
{agents.map(({ block, since, agentId }, i) => {
|
||||
const input = (block.input ?? {}) as Record<string, unknown>;
|
||||
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 (
|
||||
<li
|
||||
key={block.id ?? i}
|
||||
data-testid="chat-background-agent"
|
||||
className="px-3 py-2 text-fg-secondary"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-3.5 w-3.5 flex-shrink-0 animate-spin text-accent" />
|
||||
<span className="flex-shrink-0 rounded bg-surface-3/60 px-1.5 py-0.5 font-mono text-xs text-fg-muted">
|
||||
{type}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate" title={desc}>
|
||||
{desc}
|
||||
</span>
|
||||
{[
|
||||
since && formatMs(now - parseDate(since).getTime()),
|
||||
context > 0 && `↓ ${t("conversation.agentContext", { tokens: fmt(context) })}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.map((v, j) => (
|
||||
<span key={j} className="flex-shrink-0 font-mono text-xs text-fg-muted">
|
||||
{v}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{tail && (
|
||||
<div
|
||||
data-testid="chat-background-agent-step"
|
||||
className="ml-[1.375rem] mt-0.5 flex items-center gap-1 text-xs text-fg-muted"
|
||||
>
|
||||
<CornerDownRight className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="min-w-0 truncate">{step ?? t("conversation.working")}</span>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div data-testid="chat-turn-done" className="flex items-center gap-1.5 text-xs text-fg-muted">
|
||||
<Check className="h-3 w-3 flex-shrink-0" />
|
||||
{agents > 0 ? (
|
||||
<Loader2 className="h-3 w-3 flex-shrink-0 animate-spin text-accent" />
|
||||
) : (
|
||||
<Check className="h-3 w-3 flex-shrink-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 (`<data 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 (`<data dir>/drops/<16 hex>[-name].ext`, server/routes/run.js; the
|
||||
// older `<tmp>/agenthub-drops-<uid>/` 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 {
|
||||
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
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<Record<string, unknown>>();
|
||||
return { ...actual, api: { sessions: { transcript: vi.fn() } } };
|
||||
});
|
||||
|
||||
const at = new Date().toISOString();
|
||||
const m = (p: Partial<TranscriptMessage>): 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(<MessageList live loading={false} messages={main} sessionId="s1" />);
|
||||
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(<MessageList live loading={false} messages={main} />);
|
||||
expect(screen.getByTestId("chat-background-agent")).toBeInTheDocument();
|
||||
expect(api.sessions.transcript).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
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>): 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: "<task-notification>\n<task-id>a1</task-id>\n<tool-use-id>t1</tool-use-id>\n<status>completed</status>\n</task-notification>",
|
||||
},
|
||||
],
|
||||
});
|
||||
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(<MessageList live loading={false} messages={base} />);
|
||||
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(<MessageList live loading={false} messages={[...base, notified]} />);
|
||||
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(<MessageList loading={false} messages={base} />);
|
||||
expect(screen.queryByTestId("chat-background-agents")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string | null>(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"
|
||||
/>
|
||||
<label
|
||||
className="mt-2 flex items-center gap-1.5 text-[11px] text-fg-secondary"
|
||||
title={t("clone.partialHint")}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="add-lane-clone-partial"
|
||||
checked={clonePartial}
|
||||
onChange={(e) => setClonePartial(e.target.checked)}
|
||||
/>
|
||||
{t("clone.partial")}
|
||||
</label>
|
||||
{cloneToken && (
|
||||
<div className="mt-2">
|
||||
<CloneProgress token={cloneToken} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* @file CloneHereDialog.tsx
|
||||
* @description The Files page's **New → Clone repository**: clones a URL into
|
||||
* the folder the File Explorer has open, through the same
|
||||
* `POST /api/lanes/clone` as Add workspace → Clone — the server holds the URL
|
||||
* to its allowlist and the folder to the user's own, and clones as the
|
||||
* folder's account with that account's own SSH key / HTTPS login — and opens
|
||||
* a workspace on the new folder. A 409 `ECREDENTIALS` opens a login row (sent
|
||||
* once, optionally remembered), `ESSHAUTH` points to Settings → Git.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FolderGit2 } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { cloneDirName } from "./AddLaneModal";
|
||||
|
||||
type Login = { host: string; username: string; token: string; remember: boolean };
|
||||
|
||||
export function CloneHereDialog({
|
||||
open,
|
||||
parent,
|
||||
onClose,
|
||||
onCloned,
|
||||
}: {
|
||||
open: boolean;
|
||||
/** The absolute folder the clone goes into. */
|
||||
parent: string;
|
||||
onClose: () => void;
|
||||
onCloned: (lane: Lane, path: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const titleId = useId();
|
||||
const [url, setUrl] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [login, setLogin] = useState<Login | null>(null);
|
||||
const [sshRefused, setSshRefused] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
useDialog(open, panelRef, busy ? () => {} : onClose);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setUrl("");
|
||||
setName("");
|
||||
setLogin(null);
|
||||
setSshRefused(false);
|
||||
setBusy(false);
|
||||
setError(null);
|
||||
}, [open, parent]);
|
||||
|
||||
if (!open) return null;
|
||||
const folder = name.trim() || cloneDirName(url);
|
||||
const into = `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
|
||||
|
||||
const submit = async () => {
|
||||
const u = url.trim();
|
||||
if (!u || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setSshRefused(false);
|
||||
try {
|
||||
const result = await api.lanes.clone({
|
||||
url: u,
|
||||
parent,
|
||||
name: name.trim() || undefined,
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
onCloned(result.lane, result.path);
|
||||
} catch (err) {
|
||||
const e = err as { code?: string; auth?: { host?: string }; message?: string };
|
||||
const host = e.auth?.host || u;
|
||||
if (e.code === "ECREDENTIALS") {
|
||||
setError(t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host }));
|
||||
setLogin((l) => ({
|
||||
host,
|
||||
username: l?.username ?? "",
|
||||
token: "",
|
||||
remember: l?.remember ?? true,
|
||||
}));
|
||||
} else if (e.code === "ESSHAUTH") {
|
||||
setSshRefused(true);
|
||||
setError(t("clone.sshRefused", { host }));
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
|
||||
onClick={busy ? undefined : onClose}
|
||||
// A portal still bubbles through React: keep keys away from the File
|
||||
// Explorer's shortcuts (Backspace = up a folder, Delete = delete).
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
data-testid="fx-clone-dialog"
|
||||
className="w-full max-w-lg rounded-xl border border-border bg-surface-1 p-4 shadow-2xl shadow-black/50"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 id={titleId} className="mb-3 flex items-center gap-2 text-sm font-semibold text-fg-primary">
|
||||
<FolderGit2 className="h-4 w-4" />
|
||||
{t("fx.cloneRepo")}
|
||||
</h2>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}}
|
||||
className="space-y-3"
|
||||
>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor={`${titleId}-url`}>
|
||||
{t("clone.urlLabel")}
|
||||
</label>
|
||||
<input
|
||||
id={`${titleId}-url`}
|
||||
autoFocus
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://host/owner/repo.git"
|
||||
disabled={busy}
|
||||
className="input w-full font-mono text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-fg-muted">{t("clone.urlHint")}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor={`${titleId}-name`}>
|
||||
{t("clone.nameLabel")}
|
||||
</label>
|
||||
<input
|
||||
id={`${titleId}-name`}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={cloneDirName(url)}
|
||||
disabled={busy}
|
||||
className="input w-full font-mono text-xs"
|
||||
/>
|
||||
<p className="mt-1 break-all text-[11px] text-fg-muted">
|
||||
{t("clone.parentHint", { path: into })}
|
||||
</p>
|
||||
</div>
|
||||
{login && (
|
||||
<div data-testid="fx-clone-login" className="space-y-2 rounded-md border border-border p-2">
|
||||
<input
|
||||
aria-label={t("clone.username")}
|
||||
placeholder={t("clone.username")}
|
||||
autoComplete="username"
|
||||
value={login.username}
|
||||
onChange={(e) => setLogin({ ...login, username: e.target.value })}
|
||||
disabled={busy}
|
||||
className="input w-full text-xs"
|
||||
/>
|
||||
<input
|
||||
aria-label={t("clone.token")}
|
||||
placeholder={t("clone.token")}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={login.token}
|
||||
onChange={(e) => setLogin({ ...login, token: e.target.value })}
|
||||
disabled={busy}
|
||||
className="input w-full text-xs"
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-xs text-fg-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={login.remember}
|
||||
onChange={(e) => setLogin({ ...login, remember: e.target.checked })}
|
||||
disabled={busy}
|
||||
/>
|
||||
{t("clone.remember")}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}{" "}
|
||||
{sshRefused && (
|
||||
<a href="/settings/git#git-identity" className="underline">
|
||||
{t("clone.sshSettings")}
|
||||
</a>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={onClose} disabled={busy} className="btn-ghost text-xs">
|
||||
{t("fx.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !url.trim()}
|
||||
className="btn-primary text-xs"
|
||||
data-testid="fx-clone-submit"
|
||||
>
|
||||
{busy ? t("clone.cloning") : t("fx.cloneSubmit")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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<CloneProgressView | null>(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 (
|
||||
<p data-testid="clone-progress" className="text-[11px] text-status-warning">
|
||||
{t("clone.queued", {
|
||||
position: view.position ?? 1,
|
||||
waiting: view.waiting,
|
||||
running: view.running,
|
||||
limit: view.limit,
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const phase = view.phase
|
||||
? view.phase === "other"
|
||||
? view.label || ""
|
||||
: t(`clone.phase.${view.phase}`)
|
||||
: t("clone.connecting");
|
||||
const percent = view.percent ?? 0;
|
||||
return (
|
||||
<div data-testid="clone-progress" className="space-y-1">
|
||||
<p className="flex justify-between gap-2 text-[11px] text-fg-secondary">
|
||||
<span className="truncate">
|
||||
{phase}
|
||||
{view.phase ? ` ${percent}%` : ""}
|
||||
</span>
|
||||
{view.detail && (
|
||||
<span className="shrink-0 font-mono text-[10px] text-fg-muted">{view.detail}</span>
|
||||
)}
|
||||
</p>
|
||||
{view.phase && (
|
||||
<div
|
||||
className="h-1 overflow-hidden rounded bg-surface-2"
|
||||
role="progressbar"
|
||||
aria-valuenow={percent}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={phase}
|
||||
>
|
||||
<div className="h-full bg-accent-solid transition-all" style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,18 +4,62 @@
|
||||
* (`POST /api/lanes/:id/sources/clone`, the server picks the folder) and
|
||||
* added as an extra folder, one after another. A host that wants a login
|
||||
* (409 `ECREDENTIALS`) opens one login row; Retry re-runs only the failed
|
||||
* URLs with it, like Add workspace → Clone.
|
||||
* URLs with it, like Add workspace → Clone. Before anything runs, each URL
|
||||
* is checked against the user's own git identity (`GET /api/me/git`): an SSH
|
||||
* link while the account has no SSH key is not cloned at all — the user is
|
||||
* 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. The running clone shows its queue place, then git's
|
||||
* progress (`CloneProgress`); **Partial clone** sends `partial: true`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, Loader2, X } from "lucide-react";
|
||||
import { api, type LaneSourcesView } from "../../lib/api";
|
||||
import { api, type GitIdentity, type LaneSourcesView } from "../../lib/api";
|
||||
import { CloneProgress, newCloneToken } from "./CloneProgress";
|
||||
|
||||
type Row = { url: string; state: "wait" | "busy" | "done" | "failed"; note?: string };
|
||||
type Row = {
|
||||
url: string;
|
||||
state: "wait" | "busy" | "done" | "failed";
|
||||
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 };
|
||||
|
||||
/** How a clone URL reaches its host: `ssh://…` or scp-like `user@host:path`, else HTTPS. */
|
||||
export function cloneTarget(url: string): { kind: "ssh" | "https"; host: string } | null {
|
||||
const scp = /^(?:[^@\s/]+@)?([^:/\s]+):(?!\/\/)/.exec(url);
|
||||
if (scp?.[1]) return { kind: "ssh", host: scp[1] };
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (u.protocol === "ssh:") return { kind: "ssh", host: u.hostname };
|
||||
if (u.protocol === "https:" || u.protocol === "http:") return { kind: "https", host: u.host };
|
||||
} catch {
|
||||
/* not a URL yet — the server's allowlist answers that one */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** What the identity lacks for one URL: `ssh` blocks the clone, `https` only asks. */
|
||||
function missingFor(
|
||||
url: string,
|
||||
id: GitIdentity | null
|
||||
): { kind: "ssh" | "https"; host: string } | null {
|
||||
const target = cloneTarget(url);
|
||||
if (!id || !target) return null;
|
||||
if (target.kind === "ssh") {
|
||||
const keys = id.sshKeys?.length ?? (id.ssh ? 1 : 0);
|
||||
return keys > 0 ? null : target;
|
||||
}
|
||||
return id.https.some((h) => h.host === target.host) ? null : target;
|
||||
}
|
||||
|
||||
export function CloneSourcesForm({
|
||||
laneId,
|
||||
parent,
|
||||
@@ -31,6 +75,25 @@ export function CloneSourcesForm({
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [login, setLogin] = useState<Login | null>(null);
|
||||
const [partial, setPartial] = useState(false);
|
||||
const [identity, setIdentity] = useState<GitIdentity | null>(null);
|
||||
|
||||
// An unknown identity (an older server, a failed read) checks nothing: the
|
||||
// server's own ECREDENTIALS / ESSHAUTH answers still cover it.
|
||||
const loadIdentity = useCallback(
|
||||
() =>
|
||||
Promise.resolve()
|
||||
.then(() => api.gitIdentity.get())
|
||||
.then(setIdentity)
|
||||
.catch(() => {}),
|
||||
[]
|
||||
);
|
||||
useEffect(() => {
|
||||
void loadIdentity();
|
||||
const onFocus = () => void loadIdentity();
|
||||
window.addEventListener("focus", onFocus);
|
||||
return () => window.removeEventListener("focus", onFocus);
|
||||
}, [loadIdentity]);
|
||||
|
||||
const urls = (s: string) => [
|
||||
...new Set(
|
||||
@@ -44,16 +107,27 @@ export function CloneSourcesForm({
|
||||
const cloneAll = async (list: string[]) => {
|
||||
if (busy || !list.length) return;
|
||||
setBusy(true);
|
||||
const next: Row[] = list.map((url) => ({ url, state: "wait" }));
|
||||
// An SSH link with no key on the account would only fail at the host:
|
||||
// it is not tried, and its row says what to set up first.
|
||||
const next: Row[] = list.map((url) => {
|
||||
const miss = missingFor(url, identity);
|
||||
return miss?.kind === "ssh"
|
||||
? { url, state: "failed", note: t("clone.sshNoKey", { host: miss.host }), ssh: true }
|
||||
: { url, state: "wait" };
|
||||
});
|
||||
const show = () => setRows([...next]);
|
||||
show();
|
||||
let asked: Login | null = null;
|
||||
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: {
|
||||
@@ -80,10 +154,12 @@ export function CloneSourcesForm({
|
||||
};
|
||||
} else if (e.code === "ESSHAUTH") {
|
||||
row.note = t("clone.sshRefused", { host });
|
||||
row.ssh = true;
|
||||
} else {
|
||||
row.note = e.message || String(err);
|
||||
}
|
||||
}
|
||||
row.token = undefined;
|
||||
show();
|
||||
}
|
||||
if (asked) setLogin(asked);
|
||||
@@ -99,6 +175,18 @@ export function CloneSourcesForm({
|
||||
|
||||
const failed = rows.filter((r) => r.state === "failed").map((r) => r.url);
|
||||
|
||||
// Checked as the user types, so a missing key is said before Clone is pressed.
|
||||
const missing = urls(text)
|
||||
.map((u) => missingFor(u, identity))
|
||||
.filter((m): m is NonNullable<typeof m> => m !== null);
|
||||
const sshHosts = [...new Set(missing.filter((m) => m.kind === "ssh").map((m) => m.host))];
|
||||
const httpsHosts = [...new Set(missing.filter((m) => m.kind === "https").map((m) => m.host))];
|
||||
const settingsLink = (
|
||||
<a href="/settings/git#git-identity" target="_blank" rel="noreferrer" className="underline">
|
||||
{t("clone.sshSettings")}
|
||||
</a>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2 border-t border-border-light pt-2" data-testid="workspace-clone">
|
||||
<p className="text-[10px] uppercase tracking-wide text-fg-muted">{t("home.cloneFolders")}</p>
|
||||
@@ -112,6 +200,41 @@ export function CloneSourcesForm({
|
||||
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
||||
/>
|
||||
<p className="text-[11px] text-fg-muted">{t("home.cloneHint", { path: parent })}</p>
|
||||
<label
|
||||
className="flex items-center gap-1.5 text-[11px] text-fg-secondary"
|
||||
title={t("clone.partialHint")}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="workspace-clone-partial"
|
||||
checked={partial}
|
||||
onChange={(e) => setPartial(e.target.checked)}
|
||||
/>
|
||||
{t("clone.partial")}
|
||||
</label>
|
||||
{sshHosts.length > 0 && (
|
||||
<p
|
||||
role="alert"
|
||||
data-testid="workspace-clone-need-ssh"
|
||||
className="text-[11px] text-status-danger"
|
||||
>
|
||||
{t("home.cloneNeedSsh", { hosts: sshHosts.join(", ") })} {settingsLink}
|
||||
</p>
|
||||
)}
|
||||
{httpsHosts.length > 0 && !login && (
|
||||
<p data-testid="workspace-clone-need-login" className="text-[11px] text-status-warning">
|
||||
{t("home.cloneNeedLogin", { hosts: httpsHosts.join(", ") })}{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
onClick={() =>
|
||||
setLogin({ host: httpsHosts[0] ?? "", username: "", token: "", remember: true })
|
||||
}
|
||||
>
|
||||
{t("home.cloneEnterLogin")}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
{login && (
|
||||
<div className="space-y-1.5 rounded border border-border-light p-2">
|
||||
<div className="flex gap-2">
|
||||
@@ -178,7 +301,17 @@ export function CloneSourcesForm({
|
||||
{r.url}
|
||||
</span>
|
||||
</span>
|
||||
{r.note && <span className="ml-[18px] block text-status-danger">{r.note}</span>}
|
||||
{r.state === "busy" && r.token && (
|
||||
<div className="ml-[18px] mt-0.5">
|
||||
<CloneProgress token={r.token} />
|
||||
</div>
|
||||
)}
|
||||
{r.note && (
|
||||
<span className="ml-[18px] block text-status-danger">
|
||||
{r.note}
|
||||
{r.ssh && <> {settingsLink}</>}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -91,6 +91,7 @@ import { confirmDialog } from "../../lib/confirm";
|
||||
import { formatBytes } from "../../lib/format";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { CloneHereDialog } from "./CloneHereDialog";
|
||||
|
||||
/** One upload batch at most (the server caps one file the same). */
|
||||
export const FX_BATCH_MAX_BYTES = 500 * 1024 * 1024;
|
||||
@@ -268,6 +269,7 @@ export function FileBrowser({
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [renaming, setRenaming] = useState<{ path: string; value: string } | null>(null);
|
||||
const [creating, setCreating] = useState<{ kind: "dir" | "file"; value: string } | null>(null);
|
||||
const [cloning, setCloning] = useState(false);
|
||||
const [ask, setAsk] = useState<Ask | null>(null);
|
||||
const [askAll, setAskAll] = useState(false);
|
||||
const [progress, setProgress] = useState<{
|
||||
@@ -314,6 +316,18 @@ export function FileBrowser({
|
||||
}, []);
|
||||
// Another user's home (an admin's view) is read-only.
|
||||
const ro = isHomePlace(target) && !!homes.find((h) => h.id === target.home)?.readOnly;
|
||||
// Clone into the open folder: the absolute path of a workspace's own folder
|
||||
// or of the user's own home (never a session worktree, an extra folder or
|
||||
// another user's home); the server checks the folder again.
|
||||
const cloneRoot = isHomePlace(target)
|
||||
? target.home === "me"
|
||||
? (homes.find((h) => h.id === "me")?.dir ?? null)
|
||||
: null
|
||||
: typeof target === "number"
|
||||
? (lanes.find((l) => l.id === target)?.cwd ?? null)
|
||||
: null;
|
||||
const cloneParent =
|
||||
cloneRoot && view === "files" ? (path ? `${cloneRoot.replace(/\/+$/, "")}/${path}` : cloneRoot) : null;
|
||||
|
||||
/** Go to `p` in workspace `tgt`, remembering where we were for Back. */
|
||||
const go = (p: string, tgt: FsPlace = target) => {
|
||||
@@ -633,7 +647,7 @@ export function FileBrowser({
|
||||
writePref(VIEW_KEY, l);
|
||||
};
|
||||
const onKey = (e: ReactKeyboardEvent) => {
|
||||
if (renaming || creating || ask) return;
|
||||
if (renaming || creating || ask || cloning) return;
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
const k = e.key.toLowerCase();
|
||||
// Another user's home is read-only: no key that writes.
|
||||
@@ -784,6 +798,19 @@ export function FileBrowser({
|
||||
onClick={() => setMenu(null)}
|
||||
data-testid="file-explorer"
|
||||
>
|
||||
{cloneParent && (
|
||||
<CloneHereDialog
|
||||
open={cloning}
|
||||
parent={cloneParent}
|
||||
onClose={() => setCloning(false)}
|
||||
onCloned={(lane, at) => {
|
||||
setCloning(false);
|
||||
toast.success(t("fx.cloned", { path: at, workspace: lane.title || lane.cwd }));
|
||||
setLanes((ls) => (ls.some((l) => l.id === lane.id) ? ls : [...ls, lane]));
|
||||
changed();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* ── Places ── */}
|
||||
<aside className="hidden w-52 shrink-0 flex-col border-r border-border bg-surface-0 sm:flex">
|
||||
<nav className="min-h-0 flex-1 overflow-y-auto px-2 pb-2" aria-label={t("fx.places")}>
|
||||
@@ -1018,6 +1045,17 @@ export function FileBrowser({
|
||||
>
|
||||
{t("fx.newFile")}
|
||||
</DropItem>
|
||||
{cloneParent && (
|
||||
<DropItem
|
||||
icon={<FolderGit2 className="h-4 w-4" />}
|
||||
onClick={() => {
|
||||
setMenu(null);
|
||||
setCloning(true);
|
||||
}}
|
||||
>
|
||||
{t("fx.cloneRepo")}
|
||||
</DropItem>
|
||||
)}
|
||||
</DropButton>
|
||||
<DropButton
|
||||
label={t("fx.upload")}
|
||||
@@ -1449,6 +1487,9 @@ export function FileBrowser({
|
||||
{t("fx.newFolder")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={close(() => startCreate("file"))}>{t("fx.newFile")}</MenuItem>
|
||||
{cloneParent && (
|
||||
<MenuItem onSelect={close(() => setCloning(true))}>{t("fx.cloneRepo")}</MenuItem>
|
||||
)}
|
||||
<MenuSeparator />
|
||||
<MenuItem onSelect={close(() => fileInput.current?.click())}>
|
||||
{t("fx.uploadFiles")}
|
||||
|
||||
@@ -1728,7 +1728,12 @@ export function LaneChangesPanel({
|
||||
{commits?.[0]?.sha === commitCtx.menu.item.sha && (
|
||||
<MenuItem
|
||||
danger
|
||||
disabled={busy || opOpen || commitCtx.menu.item.parents.length !== 1}
|
||||
// Pushed: the server refuses it (EPUSHED), so say why here
|
||||
// instead of failing after the confirm.
|
||||
disabled={
|
||||
busy || opOpen || headPushed || commitCtx.menu.item.parents.length !== 1
|
||||
}
|
||||
title={headPushed ? t("changes.undoCommitPushed") : undefined}
|
||||
testId="lane-changes-menu-undo-commit"
|
||||
onSelect={pick(
|
||||
commitCtx.close,
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
* first, and a session some tab already holds opens THAT tab instead of a
|
||||
* second `--resume` (the server refuses one too, `ESESSIONLIVE`) — the typed
|
||||
* message is pasted there (never sent: Enter is the user's), with a toast; while
|
||||
* it is live the composer is off. Only Claude and Codex can resume a session by id —
|
||||
* any other tool's session offers a new session from the workspace home
|
||||
* it is live the composer is off. Only Claude, Codex and a catalog tool with
|
||||
* `resumeById` (omp) can resume a session by id — any other tool's session
|
||||
* offers a new session from the workspace home
|
||||
* instead. The header carries the transcript page's Resume/Fork
|
||||
* (`SessionResumeButton`, behind its warning). The session's tool and folder
|
||||
* come from its row (`GET /api/sessions/:id`), so a resume runs on the tool the
|
||||
@@ -32,6 +33,7 @@ import { toast } from "../../lib/toast";
|
||||
import i18n from "../../i18n";
|
||||
import { liveRunFor } from "./WorkspaceSessionList";
|
||||
import { SessionResumeButton } from "./SessionResumeButton";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
|
||||
/** A message for a session that a tab already holds goes into THAT tab as a
|
||||
* paste, once its terminal is on screen (it mounts after the navigation);
|
||||
@@ -47,6 +49,7 @@ function pasteWhenShown(runId: string, text: string) {
|
||||
}
|
||||
|
||||
/** The tools whose launcher resumes a session BY ID (server/lib/pty-run.js). */
|
||||
// Before the catalog loads: the two hand-written launchers resume by id.
|
||||
const RESUMABLE = new Set(["claude", "codex"]);
|
||||
|
||||
export function SessionReplay({
|
||||
@@ -65,6 +68,7 @@ export function SessionReplay({
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
const { byId } = useAiTools();
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
@@ -88,9 +92,9 @@ export function SessionReplay({
|
||||
}, [sessionId]);
|
||||
|
||||
const tool = session?.tool || "claude";
|
||||
const resumable = RESUMABLE.has(tool);
|
||||
const resumable = byId(tool)?.resumeById ?? RESUMABLE.has(tool);
|
||||
const livePane = liveRunFor(lane, liveRuns, sessionId);
|
||||
const text = promptWithImages(prompt, images);
|
||||
const text = promptWithImages(prompt, images, tool === "claude");
|
||||
|
||||
const send = async () => {
|
||||
if (!text || busy || uploading || livePane || !resumable) return;
|
||||
@@ -132,7 +136,11 @@ export function SessionReplay({
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<ConversationView sessionId={sessionId} fill />
|
||||
<ConversationView
|
||||
sessionId={sessionId}
|
||||
fill
|
||||
ended={!livePane && !!session && session.status !== "active"}
|
||||
/>
|
||||
</div>
|
||||
{livePane ? (
|
||||
<div
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file SessionResumeButton.tsx
|
||||
* @description The transcript page's explicit Resume and Fork for a Claude
|
||||
* session inside a workspace. Opening a session never resumes it; this is the
|
||||
* session inside a workspace (Resume only for a catalog tool that resumes by
|
||||
* id — omp's `--resume <id>`). Opening a session never resumes it; this is the
|
||||
* one place that does, behind a warning, because a resume re-reads the whole
|
||||
* transcript into context. Fork resumes it into a NEW session id
|
||||
* (`forkSession: true` → `claude --resume <id> --fork-session`), leaving the
|
||||
@@ -26,6 +27,7 @@ import { ConfirmModal } from "../ConfirmModal";
|
||||
import { startInFreeTabDetailed } from "./startSession";
|
||||
import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
|
||||
/** A path without its trailing separators — `/` or a Windows `\`. */
|
||||
const strip = (p: string) => p.replace(/[\\/]+$/, "");
|
||||
@@ -54,6 +56,7 @@ export function SessionResumeButton({
|
||||
const navigate = useNavigate();
|
||||
const { lanes } = useLanes();
|
||||
const { canEditLane } = usePermissions();
|
||||
const { byId } = useAiTools();
|
||||
const [runs, setRuns] = useState<RunHandle[] | null>(null);
|
||||
const [confirming, setConfirming] = useState<"resume" | "fork" | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -70,12 +73,13 @@ export function SessionResumeButton({
|
||||
};
|
||||
}, [session.id]);
|
||||
|
||||
// Claude only: a Codex session id is not what
|
||||
// `codex resume` takes for every kind of thread.
|
||||
// Claude, or a catalog tool that resumes by id (no launcher of its own): a
|
||||
// Codex session id is not what `codex resume` takes for every kind of thread.
|
||||
const tool = session.tool || "claude";
|
||||
const catalogResume = !!byId(tool)?.resumeById && !byId(tool)?.launcher;
|
||||
// The workspace that owns it, else the one whose folder holds it.
|
||||
const lane =
|
||||
tool !== "claude" || !session.cwd
|
||||
(tool !== "claude" && !catalogResume) || !session.cwd
|
||||
? null
|
||||
: (lanes.find((l) => l.id === session.lane_id && l.tool === tool) ??
|
||||
owningLane(session.cwd, tool, lanes));
|
||||
@@ -140,7 +144,7 @@ export function SessionResumeButton({
|
||||
{t("sessions.resume")}
|
||||
</button>
|
||||
)}
|
||||
{resumable && (
|
||||
{resumable && tool === "claude" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming("fork")}
|
||||
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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<PermissionMode>(readDefaultMode);
|
||||
const [sandbox, setSandbox] = useState<CodexSandbox>("workspace-write");
|
||||
const [approval, setApproval] = useState<CodexApproval>("on-request");
|
||||
// Per session, never remembered.
|
||||
const [sandbox, setSandbox] = useState<CodexSandbox>(() => readCodexDefaults().sandbox);
|
||||
const [approval, setApproval] = useState<CodexApproval>(() => 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<EffortLevel>("");
|
||||
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({
|
||||
</div>
|
||||
)}
|
||||
{canBypass && (
|
||||
<span className="px-2">
|
||||
<Checkbox
|
||||
tone="danger"
|
||||
checked={bypass}
|
||||
onChange={setBypass}
|
||||
label={t("run:fields.bypass")}
|
||||
labelClassName="text-xs text-status-danger"
|
||||
// A chip like the selects beside it: quiet when off, red when on.
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={bypass}
|
||||
data-testid="workspace-home-bypass"
|
||||
title={t(codex ? "run:fields.yoloHint" : "run:fields.bypass")}
|
||||
onClick={() => 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"
|
||||
}`}
|
||||
>
|
||||
<Zap
|
||||
className={`h-3.5 w-3.5 ${bypass ? "fill-current" : "text-fg-muted"}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
{t(codex ? "run:fields.yolo" : "run:fields.bypassShort")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{danger && (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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(<CloneProgress token="t" />);
|
||||
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(<CloneProgress token="t" />);
|
||||
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(<CloneProgress token="t" />);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,10 @@
|
||||
/**
|
||||
* @file Tests for CloneSourcesForm: every URL in the box is cloned in turn as
|
||||
* an extra folder, a host that wants a login opens the login row, and Retry
|
||||
* re-runs only the failed URLs with that login.
|
||||
* 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. The
|
||||
* partial-clone box adds `partial: true` to every clone.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -11,16 +14,20 @@ import userEvent from "@testing-library/user-event";
|
||||
import "../../../i18n";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { lanes: { cloneFolder: vi.fn() } },
|
||||
api: { lanes: { cloneFolder: vi.fn() }, gitIdentity: { get: vi.fn() } },
|
||||
}));
|
||||
|
||||
import { api, type LaneSourcesView } from "../../../lib/api";
|
||||
import { CloneSourcesForm } from "../CloneSourcesForm";
|
||||
import { api, type GitIdentity, type LaneSourcesView } from "../../../lib/api";
|
||||
import { CloneSourcesForm, cloneTarget } from "../CloneSourcesForm";
|
||||
|
||||
const view = { sources: [] } as unknown as LaneSourcesView & { path: string };
|
||||
|
||||
describe("CloneSourcesForm", () => {
|
||||
beforeEach(() => vi.mocked(api.lanes.cloneFolder).mockReset());
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.cloneFolder).mockReset();
|
||||
// Unknown identity: nothing is checked up front.
|
||||
vi.mocked(api.gitIdentity.get).mockReset().mockRejectedValue(new Error("old server"));
|
||||
});
|
||||
|
||||
it("clones each URL, asks for a login where needed, and retries only that one", async () => {
|
||||
const user = userEvent.setup();
|
||||
@@ -53,9 +60,61 @@ 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(<CloneSourcesForm laneId={3} parent="/w" onAdded={vi.fn()} />);
|
||||
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" });
|
||||
expect(cloneTarget("https://h.test/o/r.git")).toEqual({ kind: "https", host: "h.test" });
|
||||
expect(cloneTarget("not a url")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not clone an SSH link before the account has a key, and flags an HTTPS host with no login", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue({
|
||||
ssh: null,
|
||||
sshKeys: [],
|
||||
https: [],
|
||||
} as unknown as GitIdentity);
|
||||
vi.mocked(api.lanes.cloneFolder).mockResolvedValue(view);
|
||||
render(<CloneSourcesForm laneId={3} parent="/w" onAdded={vi.fn()} />);
|
||||
await waitFor(() => expect(api.gitIdentity.get).toHaveBeenCalled());
|
||||
|
||||
await user.type(
|
||||
screen.getByTestId("workspace-clone-input"),
|
||||
"git@github.com:o/r.git\nhttps://h.test/a.git"
|
||||
);
|
||||
expect(screen.getByTestId("workspace-clone-need-ssh")).toHaveTextContent("github.com");
|
||||
expect(screen.getByTestId("workspace-clone-need-login")).toHaveTextContent("h.test");
|
||||
|
||||
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",
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,11 +21,13 @@ import { confirmDialog } from "../../../lib/confirm";
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => false) }));
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
|
||||
gitTarget: (lane: number, session?: string | null, source?: number | null) =>
|
||||
source ? { lane, source } : session ? { lane, session } : lane,
|
||||
isHomePlace: (p: unknown) => typeof p === "object" && p !== null && "home" in p,
|
||||
api: {
|
||||
lanes: {
|
||||
list: vi.fn(),
|
||||
clone: vi.fn(),
|
||||
gitDownloadUrl: (t: number, p: string) => `/api/lanes/${t}/git/download?path=${p}`,
|
||||
},
|
||||
files: {
|
||||
@@ -432,3 +434,87 @@ describe("FileExplorer", () => {
|
||||
expect(api.files.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileExplorer — New → Clone repository", () => {
|
||||
const openClone = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await user.click(screen.getByRole("button", { name: "New" }));
|
||||
await user.click(screen.getByRole("menuitem", { name: "Clone repository" }));
|
||||
return screen.getByTestId("fx-clone-dialog");
|
||||
};
|
||||
|
||||
it("clones into the open folder of the workspace and says where", async () => {
|
||||
vi.mocked(api.lanes.clone).mockResolvedValue({
|
||||
lane: { id: 9, title: "repo", cwd: "/w/one/src/repo" },
|
||||
created: true,
|
||||
path: "/w/one/src/repo",
|
||||
} as never);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
fireEvent.doubleClick(await screen.findByText("src"));
|
||||
await screen.findByText("x.js");
|
||||
const dialog = await openClone(user);
|
||||
await user.type(within(dialog).getByLabelText("Repository URL"), "git@host:o/repo.git");
|
||||
expect(within(dialog).getByText(/\/w\/one\/src\/repo/)).toBeInTheDocument();
|
||||
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.clone).toHaveBeenCalledWith({
|
||||
url: "git@host:o/repo.git",
|
||||
parent: "/w/one/src",
|
||||
name: undefined,
|
||||
})
|
||||
);
|
||||
expect(toast.success).toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("asks for a login on ECREDENTIALS and sends it once", async () => {
|
||||
vi.mocked(api.lanes.clone)
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error("login"), { code: "ECREDENTIALS", auth: { host: "git.example" } })
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
lane: { id: 9, title: "r", cwd: "/w/one/r" },
|
||||
created: true,
|
||||
path: "/w/one/r",
|
||||
} as never);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const dialog = await openClone(user);
|
||||
await user.type(within(dialog).getByLabelText("Repository URL"), "https://git.example/o/r.git");
|
||||
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||
const login = await within(dialog).findByTestId("fx-clone-login");
|
||||
await user.type(within(login).getByLabelText("Username"), "ann");
|
||||
await user.type(within(login).getByLabelText("Password or access token"), "tok");
|
||||
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.clone).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
parent: "/w/one",
|
||||
credentials: { username: "ann", password: "tok", remember: true },
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps typing in the dialog away from the explorer's shortcuts", async () => {
|
||||
vi.mocked(api.files.remove).mockResolvedValue({ trashed: [] });
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
fireEvent.click(row("a.txt"));
|
||||
const dialog = await openClone(user);
|
||||
const url = within(dialog).getByLabelText("Repository URL");
|
||||
await user.type(url, "abc{Backspace}{Delete}");
|
||||
expect(api.files.remove).not.toHaveBeenCalled();
|
||||
expect(url).toHaveValue("ab");
|
||||
});
|
||||
|
||||
it("is not offered in an extra folder", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileExplorer open laneId={1} source={3} onClose={vi.fn()} onChanged={vi.fn()} />);
|
||||
await screen.findByText("a.txt");
|
||||
await user.click(screen.getByRole("button", { name: "New" }));
|
||||
expect(screen.queryByRole("menuitem", { name: "Clone repository" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(<LaneChangesPanel laneId={7} />);
|
||||
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());
|
||||
|
||||
@@ -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 : <WorkspaceHome lane={lane()} onMenuAction={() => undefined} />;
|
||||
return params.get("run") ? null : (
|
||||
<WorkspaceHome lane={lane()} onMenuAction={() => undefined} />
|
||||
);
|
||||
}
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
|
||||
@@ -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}`) }))}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-fg-muted">{t("agent.permissionHint")}</p>
|
||||
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -59,7 +59,13 @@ import { useTranslation } from "react-i18next";
|
||||
import { ArrowUp, FileText, Square, SquareCheck } from "lucide-react";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { ConversationView } from "../conversation/ConversationView";
|
||||
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "./RunSetup";
|
||||
import {
|
||||
ModelMenu,
|
||||
PromptEditor,
|
||||
promptForPane,
|
||||
promptWithImages,
|
||||
type PromptImage,
|
||||
} from "./RunSetup";
|
||||
import { ChatAddMenu } from "./ChatAddMenu";
|
||||
import { addAttachments, dragAttachments } from "../../lib/promptAttach";
|
||||
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
|
||||
@@ -470,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.
|
||||
@@ -491,7 +500,7 @@ export const PaneChat = memo(function PaneChat({
|
||||
const [notStarted, setNotStarted] = useState(false);
|
||||
|
||||
// A control is a command typed into the pane, like the user would.
|
||||
const command = async (line: string) => {
|
||||
const command = async (line: string, files: string[] = []) => {
|
||||
if (
|
||||
notStarted &&
|
||||
!(await confirmDialog({
|
||||
@@ -501,14 +510,15 @@ export const PaneChat = memo(function PaneChat({
|
||||
}))
|
||||
)
|
||||
return false;
|
||||
const ok = submitToRun(runId, line);
|
||||
const ok = submitToRun(runId, line, files);
|
||||
if (!ok) toast.error(t("chat.notSent"));
|
||||
return ok;
|
||||
};
|
||||
|
||||
const send = async () => {
|
||||
if (!text || uploading) return;
|
||||
if (!(await command(text))) return;
|
||||
const out = promptForPane(prompt, images, claude);
|
||||
if (!(await command(out.text, out.files))) return;
|
||||
setFollow((n) => n + 1);
|
||||
setPrompt("");
|
||||
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
|
||||
|
||||
@@ -73,7 +73,7 @@ import { RunDot, type RunState } from "./ConsoleTabs";
|
||||
import { setSessionChat, useSessionChat } from "../../lib/chatUi";
|
||||
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
|
||||
import { pasteToRun, takeRunInput } from "../../lib/panePaste";
|
||||
import { promptWithImages } from "./RunSetup";
|
||||
import { promptForPane } from "./RunSetup";
|
||||
|
||||
export const PANE_DRAG_TYPE = "application/x-agenthub-pane";
|
||||
/** The slot being dragged: `dataTransfer` data cannot be read during dragover. */
|
||||
@@ -244,10 +244,10 @@ 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 = promptWithImages(draft.prompt, draft.images);
|
||||
if (text && pasteToRun(runId, text)) writeChatDraft(sessionId, "", []);
|
||||
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)
|
||||
|
||||
@@ -67,6 +67,7 @@ import { TOKEN_TONE, promptTokens } from "../../lib/promptTokens";
|
||||
import {
|
||||
ATTACH_MIME,
|
||||
addAttachments,
|
||||
claudeMention,
|
||||
droppedAttachments,
|
||||
droppedSkill,
|
||||
droppedText,
|
||||
@@ -203,9 +204,34 @@ 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. `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[],
|
||||
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))),
|
||||
files: images.filter(isImage).map((i) => i.path),
|
||||
};
|
||||
}
|
||||
|
||||
/** A plain-text paste longer than this is attached as `pasted-text.txt`. */
|
||||
|
||||
@@ -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<HTMLDivElement>(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 {
|
||||
@@ -666,8 +672,12 @@ export const TerminalView = memo(function TerminalView({
|
||||
() =>
|
||||
registerPane(
|
||||
runId,
|
||||
(text, submit) => {
|
||||
termRef.current?.paste(text);
|
||||
(text, submit, files = []) => {
|
||||
const term = termRef.current;
|
||||
// The text, then each image's path in a paste of its own — the one
|
||||
// shape `claude` turns into `[Image #n]` on every platform.
|
||||
if (text) term?.paste(files.length ? `${text} ` : text);
|
||||
files.forEach((f, i) => term?.paste(i < files.length - 1 ? `${f} ` : f));
|
||||
// Enter a beat after the paste, so a TUI that times its paste
|
||||
// detection never reads it as a newline inside the paste. A submit
|
||||
// comes from the pane's Chat view, which keeps the keyboard.
|
||||
|
||||
@@ -267,7 +267,7 @@ describe("PaneChat — model, effort and permission mode", () => {
|
||||
renderChat();
|
||||
fireEvent.click(screen.getByRole("button", { name: /Opus/ }));
|
||||
fireEvent.click(await screen.findByRole("menuitemradio", { name: /Sonnet/ }));
|
||||
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "/model sonnet");
|
||||
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "/model sonnet", []);
|
||||
});
|
||||
|
||||
it("shows the mode the pane shows, and asks the server to step it", async () => {
|
||||
@@ -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,10 @@ 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 ',
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
@@ -490,7 +493,7 @@ describe("PaneChat — the unsent prompt", () => {
|
||||
expect(box).toHaveValue("hello");
|
||||
confirmDialog.mockResolvedValueOnce(true);
|
||||
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
||||
await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hello"));
|
||||
await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hello", []));
|
||||
});
|
||||
|
||||
it("sends without asking once the session has started", async () => {
|
||||
@@ -499,7 +502,7 @@ describe("PaneChat — the unsent prompt", () => {
|
||||
renderChat();
|
||||
fireEvent.change(screen.getByPlaceholderText("Write a message…"), { target: { value: "hi" } });
|
||||
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
||||
await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hi"));
|
||||
await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hi", []));
|
||||
expect(confirmDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
ModelPicker,
|
||||
PASTE_AS_FILE_CHARS,
|
||||
PromptEditor,
|
||||
promptForPane,
|
||||
promptWithImages,
|
||||
type PromptImage,
|
||||
} from "../RunSetup";
|
||||
@@ -586,3 +587,45 @@ describe("ModelMenu", () => {
|
||||
expect(onChange).toHaveBeenCalledWith("claude-sonnet-5-5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("promptForPane — images apart, every other file in the text", () => {
|
||||
it("keeps @-mentions and other files in the text, and takes images out", () => {
|
||||
// A Windows path inside a paste of text stayed a plain path in claude; a
|
||||
// paste that is only the path becomes [Image #n].
|
||||
const images: PromptImage[] = [
|
||||
{ path: "C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png", name: "image.png" },
|
||||
{ path: "/data/drops/0123456789abcdef-log.txt", name: "log.txt" },
|
||||
{ path: '@"/home/u/My Docs/a.PNG"', name: "a.PNG" },
|
||||
{ path: "/data/drops/0123456789abcdef.JPG", name: "b.jpg" },
|
||||
];
|
||||
expect(promptForPane(" look ", images)).toEqual({
|
||||
text: 'look /data/drops/0123456789abcdef-log.txt @"/home/u/My Docs/a.PNG"',
|
||||
files: [
|
||||
"C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png",
|
||||
"/data/drops/0123456789abcdef.JPG",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
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"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,17 +9,24 @@
|
||||
* boot/login while the switch is on. Between them, the status-area icon switch
|
||||
* (`GET/PUT /api/settings/tray`, on by default — server/lib/tray.js). Last,
|
||||
* **Uninstall AgentHub**: `POST /api/settings/uninstall` removes AgentHub from
|
||||
* the machine, database included (scripts/uninstall.js).
|
||||
* the machine, database included (scripts/uninstall.js). Quit is a plain
|
||||
* button and Uninstall a danger one, both the same width with an icon, and
|
||||
* each spins while its request runs.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Loader2, Power, Trash2 } from "lucide-react";
|
||||
import { api, type AutostartStatus, type TrayStatus } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { SettingsRow, SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||
|
||||
// The two action buttons share one width, so they line up under the switches.
|
||||
const ACTION = "min-w-[6.5rem] justify-center text-xs";
|
||||
const ICON = "h-3.5 w-3.5 shrink-0";
|
||||
|
||||
export function AutostartSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [status, setStatus] = useState<AutostartStatus | null>(null);
|
||||
@@ -126,9 +133,11 @@ export function AutostartSettings() {
|
||||
type="button"
|
||||
data-testid="quit-agenthub"
|
||||
disabled={quitting}
|
||||
aria-busy={quitting}
|
||||
onClick={() => void quit()}
|
||||
className="btn-ghost text-xs text-status-danger disabled:opacity-40"
|
||||
className={`btn-secondary ${ACTION}`}
|
||||
>
|
||||
{quitting ? <Loader2 className={ICON + " animate-spin"} /> : <Power className={ICON} />}
|
||||
{t("quit.button")}
|
||||
</button>
|
||||
}
|
||||
@@ -141,9 +150,15 @@ export function AutostartSettings() {
|
||||
type="button"
|
||||
data-testid="uninstall-agenthub"
|
||||
disabled={uninstalling}
|
||||
aria-busy={uninstalling}
|
||||
onClick={() => void uninstall()}
|
||||
className="btn-ghost text-xs text-status-danger disabled:opacity-40"
|
||||
className={`btn-danger ${ACTION}`}
|
||||
>
|
||||
{uninstalling ? (
|
||||
<Loader2 className={ICON + " animate-spin"} />
|
||||
) : (
|
||||
<Trash2 className={ICON} />
|
||||
)}
|
||||
{t("uninstall.button")}
|
||||
</button>
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ import { SettingsRow, SettingsSwitchRow } from "./SettingsRow";
|
||||
import { SagiAvatar } from "../Sagi/SagiAvatar";
|
||||
import { PetSprite } from "../Sagi/PetSprite";
|
||||
import { PET_SIZE, sagiPrefs } from "../Sagi/prefs";
|
||||
import { usePrefersReducedMotion } from "../Sagi/Sagi";
|
||||
import { usePrefersReducedMotion } from "../../hooks/usePrefersReducedMotion";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { usePets, useWornPet } from "../Sagi/usePets";
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @file RetentionSettings.tsx
|
||||
* @description Settings → Data → Session cleanup, under the manual purge: the
|
||||
* OPT-IN automatic cleanup (`AGENTHUB_RETENTION_DAYS`, 0 = off, the default)
|
||||
* that the hourly maintenance applies, the database's size and free space, and
|
||||
* **Compact now** — a full VACUUM behind a confirm, polled through
|
||||
* `GET /api/settings/retention` while it runs, then toasted. Backed by
|
||||
* `server/lib/db-retention.js`; a value the environment set is read-only.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle, Minimize2, RefreshCw } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { RetentionState } from "../../lib/api";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { formatBytes, formatDateTime } from "../../lib/format";
|
||||
import { toast } from "../../lib/toast";
|
||||
|
||||
export function RetentionSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [state, setState] = useState<RetentionState | null>(null);
|
||||
const [days, setDays] = useState("0");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.settings.retention
|
||||
.get()
|
||||
.then((s) => {
|
||||
setState(s);
|
||||
setDays(String(s.days));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
// A Compact takes a while on a large database: poll until it ends.
|
||||
const compacting = !!state?.compacting;
|
||||
useEffect(() => {
|
||||
if (!compacting) return;
|
||||
const id = window.setInterval(() => {
|
||||
api.settings.retention
|
||||
.get()
|
||||
.then((s) => {
|
||||
setState(s); // the draft keeps its value
|
||||
if (s.compacting || !s.last_compact) return;
|
||||
if (s.last_compact.ok) {
|
||||
toast.success(
|
||||
t("retention.compactDone", {
|
||||
before: formatBytes(s.last_compact.size_before ?? 0),
|
||||
after: formatBytes(s.last_compact.size_after ?? 0),
|
||||
})
|
||||
);
|
||||
} else {
|
||||
toast.error(t("retention.compactFailed", { error: s.last_compact.error }));
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, 2000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [compacting, t]);
|
||||
|
||||
const max = state?.max_days ?? 3650;
|
||||
const n = Number(days);
|
||||
const valid = days.trim() !== "" && Number.isInteger(n) && n >= 0 && n <= max;
|
||||
const changed = state !== null && valid && n !== state.days;
|
||||
const owned = !!state?.env_owned.days;
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const s = await api.settings.retention.save(n);
|
||||
setState(s);
|
||||
setDays(String(s.days));
|
||||
toast.success(t("retention.saved"));
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function compact() {
|
||||
const ok = await confirmDialog({
|
||||
title: t("retention.compactConfirmTitle"),
|
||||
message: t("retention.compactConfirmBody"),
|
||||
confirmLabel: t("retention.compact"),
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
setState(await api.settings.retention.compact());
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
const purged = state?.last_run?.retention?.purged_sessions;
|
||||
|
||||
return (
|
||||
<div className="border-t border-border pt-4 space-y-3">
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-fg-secondary">{t("retention.title")}</h4>
|
||||
<p className="text-xs text-fg-muted">{t("retention.description")}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="bg-surface-2 rounded-lg px-4 py-3">
|
||||
<label htmlFor="retention-days" className="text-xs text-fg-secondary block mb-2">
|
||||
{t("retention.days")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="retention-days"
|
||||
type="number"
|
||||
min="0"
|
||||
max={max}
|
||||
step="1"
|
||||
inputMode="numeric"
|
||||
value={days}
|
||||
disabled={!state || owned}
|
||||
onChange={(e) => setDays(e.target.value)}
|
||||
aria-invalid={!valid || undefined}
|
||||
aria-describedby={!valid ? "retention-days-error" : undefined}
|
||||
className="input w-20 text-sm text-right font-mono"
|
||||
/>
|
||||
<span className="text-xs text-fg-muted">{t("common:days")}</span>
|
||||
</div>
|
||||
{!valid && (
|
||||
<p id="retention-days-error" className="mt-1 text-[11px] text-status-danger">
|
||||
{t("retention.invalid", { max })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={saving || !changed || owned}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{saving ? t("homeDir.saving") : t("homeDir.save")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-fg-secondary">
|
||||
{state && state.days > 0 ? t("retention.on", { days: state.days }) : t("retention.off")}
|
||||
{state?.last_run && purged !== undefined && (
|
||||
<> {t("retention.lastRun", { time: formatDateTime(state.last_run.at), n: purged })}</>
|
||||
)}
|
||||
</p>
|
||||
{owned && (
|
||||
<p className="text-xs text-status-warning flex items-start gap-1.5">
|
||||
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
|
||||
{t("privacy.envOwned")}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void compact()}
|
||||
disabled={!state || compacting}
|
||||
className="text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 text-fg-secondary hover:text-fg-primary hover:bg-surface-4 border border-border"
|
||||
>
|
||||
{compacting ? (
|
||||
<RefreshCw className="w-3 h-3 animate-spin inline mr-1" />
|
||||
) : (
|
||||
<Minimize2 className="w-3 h-3 inline mr-1" />
|
||||
)}
|
||||
{compacting ? t("retention.compacting") : t("retention.compact")}
|
||||
</button>
|
||||
{state?.space && (
|
||||
<span className="text-xs text-fg-muted">
|
||||
{t("retention.space", {
|
||||
size: formatBytes(state.db_size),
|
||||
free: formatBytes(state.space.free_bytes),
|
||||
mode: state.space.auto_vacuum,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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<DefaultMode, string> = {
|
||||
auto: "Auto",
|
||||
acceptEdits: "AcceptEdits",
|
||||
default: "Default",
|
||||
plan: "Plan",
|
||||
bypassPermissions: "Bypass",
|
||||
};
|
||||
const SANDBOX_KEYS: Record<CodexSandbox, string> = {
|
||||
"read-only": "sandboxReadOnly",
|
||||
"workspace-write": "sandboxWorkspaceWrite",
|
||||
"danger-full-access": "sandboxFullAccess",
|
||||
};
|
||||
const APPROVAL_KEYS: Record<CodexApproval, string> = {
|
||||
"on-request": "approvalOnRequest",
|
||||
never: "approvalNever",
|
||||
};
|
||||
|
||||
function Danger({ children }: { children: string }) {
|
||||
return (
|
||||
<p className="flex items-start gap-2 py-2 text-xs text-status-danger">
|
||||
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
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<DefaultMode>(() => 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<typeof codex>) => {
|
||||
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 (
|
||||
<SettingsSection
|
||||
id="session-defaults"
|
||||
title={t("sessionDefaults.title")}
|
||||
description={t("sessionDefaults.description")}
|
||||
>
|
||||
{shown("claude") && (
|
||||
<>
|
||||
<SettingsRow
|
||||
label={t("sessionDefaults.claudeMode")}
|
||||
description={t("sessionDefaults.claudeModeDesc")}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("sessionDefaults.claudeMode")}
|
||||
value={mode}
|
||||
onChange={(m) => void pickMode(m)}
|
||||
options={DEFAULT_MODES.map((m) => ({
|
||||
value: m,
|
||||
label: t(`lanes:home.permission${MODE_KEYS[m]}`),
|
||||
}))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{mode === "bypassPermissions" && <Danger>{t("run:hint.permissionWarning")}</Danger>}
|
||||
</>
|
||||
)}
|
||||
{shown("codex") && (
|
||||
<>
|
||||
<SettingsRow
|
||||
label={t("sessionDefaults.codexSandbox")}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("sessionDefaults.codexSandbox")}
|
||||
value={codex.sandbox}
|
||||
onChange={(sandbox) => patchCodex({ sandbox })}
|
||||
options={CODEX_SANDBOXES.map((v) => ({
|
||||
value: v,
|
||||
label: t(`run:fields.${SANDBOX_KEYS[v]}`),
|
||||
}))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingsRow
|
||||
label={t("sessionDefaults.codexApproval")}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("sessionDefaults.codexApproval")}
|
||||
value={codex.approval}
|
||||
onChange={(approval) => patchCodex({ approval })}
|
||||
options={CODEX_APPROVALS.map((v) => ({
|
||||
value: v,
|
||||
label: t(`run:fields.${APPROVAL_KEYS[v]}`),
|
||||
}))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingsSwitchRow
|
||||
label={t("sessionDefaults.codexBypass")}
|
||||
description={t("sessionDefaults.codexBypassDesc")}
|
||||
checked={codex.bypass}
|
||||
onChange={(on) => void pickCodexBypass(on)}
|
||||
/>
|
||||
{codex.bypass && <Danger>{t("run:hint.bypassWarning", { tool: "Codex" })}</Danger>}
|
||||
</>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,8 @@
|
||||
* switch row (`SettingsSwitchRow`) and a segmented radio group
|
||||
* (`SettingsSegmented`, arrow keys + one roving tab stop). Rows divide
|
||||
* themselves with a hairline, none after the last, so a section is just its
|
||||
* rows in order.
|
||||
* rows in order. A string description shows its `backticked` words as code,
|
||||
* and a `disabled` row dims its label, so an unavailable control reads as one.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -32,30 +33,55 @@ export function SettingsSection({
|
||||
);
|
||||
}
|
||||
|
||||
/** `text` with every `backticked` run drawn as inline code (a command to type). */
|
||||
export function withCode(text: string): ReactNode {
|
||||
const parts = text.split(/`([^`]+)`/);
|
||||
if (parts.length === 1) return text;
|
||||
return parts.map((p, i) =>
|
||||
i % 2 ? (
|
||||
<code
|
||||
key={i}
|
||||
className="whitespace-nowrap rounded border border-border bg-surface-2 px-1 py-px font-mono text-[11px] text-fg-secondary"
|
||||
>
|
||||
{p}
|
||||
</code>
|
||||
) : (
|
||||
p
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsRow({
|
||||
label,
|
||||
description,
|
||||
control,
|
||||
alignTop = false,
|
||||
disabled = false,
|
||||
id,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
control: ReactNode;
|
||||
alignTop?: boolean;
|
||||
/** The control cannot be used here: the label is dimmed with it. */
|
||||
disabled?: boolean;
|
||||
/** A deep-link anchor (`#id`) for one row inside a grouped section. */
|
||||
id?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className={`${id ? "scroll-mt-24 " : ""}flex justify-between gap-4 border-b border-border/60 last:border-b-0 ${
|
||||
className={`${id ? "scroll-mt-24 " : ""}flex justify-between gap-6 border-b border-border/60 last:border-b-0 ${
|
||||
description ? "py-3" : "py-2"
|
||||
} ${alignTop ? "items-start" : "items-center"}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-fg-secondary">{label}</p>
|
||||
{description && <p className="mt-0.5 text-xs text-fg-muted">{description}</p>}
|
||||
<p className={`text-sm ${disabled ? "text-fg-muted" : "text-fg-secondary"}`}>{label}</p>
|
||||
{description && (
|
||||
<p className="mt-0.5 text-xs leading-relaxed text-fg-muted">
|
||||
{typeof description === "string" ? withCode(description) : description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0">{control}</div>
|
||||
</div>
|
||||
@@ -82,6 +108,7 @@ export function SettingsSwitchRow({
|
||||
id={id}
|
||||
label={label}
|
||||
description={description}
|
||||
disabled={disabled}
|
||||
control={<Switch checked={checked} onChange={onChange} label={label} disabled={disabled} />}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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(<SessionDefaultsSettings />);
|
||||
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(<SessionDefaultsSettings />);
|
||||
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(<SessionDefaultsSettings />);
|
||||
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(<SessionDefaultsSettings />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
aiTools.tools = saved;
|
||||
});
|
||||
});
|
||||
@@ -8,26 +8,31 @@
|
||||
* `.credentials.json`, or a `claude setup-token` token; for Codex an OpenAI
|
||||
* API key or a pasted `auth.json` — or removes one, each time with their own
|
||||
* password again; an analyst only reads. The secret only goes IN: the server
|
||||
* never sends it back.
|
||||
* never sends it back. An account's Edit (pencil) picks the users who run on
|
||||
* it (`PUT /api/accounts/:name/users`): their sessions skip the balancer, and
|
||||
* a Claude login goes into their home at once — no restart.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { KeyRound, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { KeyRound, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type AccountPin,
|
||||
type AccountPool,
|
||||
type PoolAccountInput,
|
||||
type PoolProfile,
|
||||
type PoolTool,
|
||||
type ServerUserUsage,
|
||||
} from "../../lib/api";
|
||||
import { fmt, fmtCost, formatDateTime } from "../../lib/format";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
|
||||
type Row = {
|
||||
tool: PoolTool;
|
||||
@@ -59,6 +64,8 @@ function rowsOf(pool: AccountPool): Row[] {
|
||||
];
|
||||
}
|
||||
|
||||
const pinsOf = (pool: AccountPool, tool: PoolTool): AccountPin[] => pool.pins?.[tool] ?? [];
|
||||
|
||||
function errorText(t: TFunction<"users">, e: unknown): string {
|
||||
const err = e as Error & { code?: string };
|
||||
return err.code && t(`pool.errors.${err.code}`, { defaultValue: "" })
|
||||
@@ -72,6 +79,7 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [removing, setRemoving] = useState<Row | null>(null);
|
||||
const [editing, setEditing] = useState<Row | null>(null);
|
||||
|
||||
const load = useCallback(
|
||||
(force = false) =>
|
||||
@@ -148,6 +156,16 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
<div className="text-[11px] text-fg-muted">
|
||||
{t("pool.added", { at: formatDateTime(r.addedAt) })}
|
||||
</div>
|
||||
{pool && pinsOf(pool, r.tool).some((p) => p.account === r.name) && (
|
||||
<div className="text-[11px] text-fg-secondary">
|
||||
{t("pool.pinned", {
|
||||
users: pinsOf(pool, r.tool)
|
||||
.filter((p) => p.account === r.name)
|
||||
.map((p) => p.username)
|
||||
.join(", "),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-sm text-fg-secondary">
|
||||
{t(`pool.kind.${r.kind}`)}
|
||||
@@ -188,7 +206,16 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
</div>
|
||||
</td>
|
||||
{isAdmin && (
|
||||
<td className="px-5 py-2 text-right">
|
||||
<td className="px-5 py-2 text-right whitespace-nowrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(r)}
|
||||
title={t("pool.edit", { name: r.name })}
|
||||
aria-label={t("pool.edit", { name: r.name })}
|
||||
className="btn-icon"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRemoving(r)}
|
||||
@@ -206,7 +233,6 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{adding && (
|
||||
<AddAccountDialog
|
||||
onClose={() => setAdding(false)}
|
||||
@@ -228,10 +254,163 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{editing && pool && (
|
||||
<EditAccountDialog
|
||||
pool={pool}
|
||||
row={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onDone={(r) => {
|
||||
const left = r.added.filter(
|
||||
(u) => u.switched === "own-login" || u.switched === "expired"
|
||||
);
|
||||
toast.success(t("pool.editDone", { name: editing.name }));
|
||||
for (const u of left) {
|
||||
toast.info(
|
||||
t(`pool.pinDone.${u.switched}`, { user: u.username, account: editing.name })
|
||||
);
|
||||
}
|
||||
setEditing(null);
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One account's Edit: tick the users whose sessions run on it. A user already
|
||||
* on another account of the tool moves here when ticked; unticking one sends
|
||||
* them back to the least-used account. Saved in one request, password again.
|
||||
*/
|
||||
function EditAccountDialog({
|
||||
pool,
|
||||
row,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
pool: AccountPool;
|
||||
row: Row;
|
||||
onClose: () => void;
|
||||
onDone: (r: Awaited<ReturnType<typeof api.accountPool.setUsers>>) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const pins = pinsOf(pool, row.tool);
|
||||
const initial = pins.filter((p) => p.account === row.name).map((p) => p.userId);
|
||||
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
||||
const [chosen, setChosen] = useState<Set<number>>(() => new Set(initial));
|
||||
const [current, setCurrent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.users
|
||||
.list()
|
||||
.then((r) => setUsers(r.users))
|
||||
.catch(() => setUsers([]));
|
||||
}, []);
|
||||
|
||||
const changed = chosen.size !== initial.length || initial.some((id) => !chosen.has(id));
|
||||
const ready = changed && current.length > 0;
|
||||
const toggle = (id: number, on: boolean) =>
|
||||
setChosen((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (on) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
onDone(await api.accountPool.setUsers(row.tool, row.name, [...chosen], current));
|
||||
} catch (e) {
|
||||
setError(errorText(t, e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open
|
||||
title={t("pool.edit", { name: row.name })}
|
||||
message={t("pool.editHint")}
|
||||
confirmLabel={t("pool.save")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
destructive={false}
|
||||
busy={busy}
|
||||
disabled={!ready}
|
||||
onConfirm={() => void submit()}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div
|
||||
className="mt-3 space-y-3"
|
||||
onKeyDown={(e) => {
|
||||
if ((e.target as HTMLElement).tagName === "INPUT" && isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<span className="text-xs font-medium text-fg-secondary">{t("pool.users")}</span>
|
||||
<div className="mt-1 max-h-56 overflow-y-auto rounded-md border border-border divide-y divide-border">
|
||||
{users === null && (
|
||||
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.loadingUsers")}</p>
|
||||
)}
|
||||
{users?.length === 0 && (
|
||||
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.noUsers")}</p>
|
||||
)}
|
||||
{users?.map((u) => {
|
||||
const elsewhere = pins.find((p) => p.userId === u.id && p.account !== row.name);
|
||||
return (
|
||||
<div key={u.id} className="px-3 py-2">
|
||||
<Checkbox
|
||||
checked={chosen.has(u.id)}
|
||||
onChange={(on) => toggle(u.id, on)}
|
||||
label={
|
||||
<span>
|
||||
{u.username}
|
||||
{u.disabled && (
|
||||
<span className="ml-1 text-fg-muted">({t("disabled")})</span>
|
||||
)}
|
||||
{elsewhere && (
|
||||
<span className="ml-1.5 text-[11px] text-fg-muted">
|
||||
{t("pool.onAccount", { account: elsewhere.account })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
labelClassName="text-sm text-fg-primary"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full text-sm"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
</Field>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
);
|
||||
}
|
||||
|
||||
type ClaudeSource = "profile" | "credentials" | "token";
|
||||
|
||||
function AddAccountDialog({
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* admin adds an account only with their own password typed (the token goes
|
||||
* in once and is not shown back) — a saved login moved in (the server's own
|
||||
* included), or a setup-token — and removes one the same way, and an analyst
|
||||
* sees no add or remove control.
|
||||
* sees no add or remove control. An account's Edit ticks the users who run on
|
||||
* it, saved with the admin's own password; the users show on the account.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -19,10 +20,12 @@ const accountPool = vi.hoisted(() => ({
|
||||
add: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
profiles: vi.fn(),
|
||||
setUsers: vi.fn(),
|
||||
}));
|
||||
const users = vi.hoisted(() => ({ list: vi.fn() }));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, api: { accountPool } };
|
||||
return { ...actual, api: { accountPool, users } };
|
||||
});
|
||||
|
||||
import { AccountPoolCard } from "../AccountPoolCard";
|
||||
@@ -66,6 +69,12 @@ const POOL: AccountPool = {
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(accountPool)) fn.mockReset();
|
||||
accountPool.list.mockResolvedValue(POOL);
|
||||
users.list.mockResolvedValue({
|
||||
users: [
|
||||
{ id: 7, username: "mia", disabled: false },
|
||||
{ id: 8, username: "leo", disabled: false },
|
||||
],
|
||||
});
|
||||
accountPool.profiles.mockResolvedValue({
|
||||
profiles: [
|
||||
{
|
||||
@@ -158,4 +167,43 @@ describe("AccountPoolCard", () => {
|
||||
expect(accountPool.remove).toHaveBeenCalledWith("codex", "cx-1", "boss-pass")
|
||||
);
|
||||
});
|
||||
|
||||
it("sets who runs on an account from its Edit, with the admin's own password", async () => {
|
||||
accountPool.list.mockResolvedValue({
|
||||
...POOL,
|
||||
pins: {
|
||||
claude: [
|
||||
{ userId: 8, username: "leo", account: "team-1" },
|
||||
{ userId: 7, username: "mia", account: "team-0" },
|
||||
],
|
||||
codex: [],
|
||||
},
|
||||
});
|
||||
accountPool.setUsers.mockResolvedValue({
|
||||
added: [{ userId: 7, username: "mia", switched: "switched" }],
|
||||
removed: [{ userId: 8, username: "leo" }],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<AccountPoolCard isAdmin />);
|
||||
expect(await screen.findByText("Used by: leo")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /switch a user/i })).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "Edit team-1" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const leo = await screen.findByRole("checkbox", { name: /leo/ });
|
||||
const mia = screen.getByRole("checkbox", { name: /mia/ });
|
||||
expect(leo).toHaveAttribute("aria-checked", "true");
|
||||
expect(mia).toHaveAttribute("aria-checked", "false");
|
||||
expect(screen.getByText("now on team-0")).toBeInTheDocument();
|
||||
await user.click(mia);
|
||||
await user.click(leo);
|
||||
const save = Array.from(dialog.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Save"
|
||||
)!;
|
||||
expect(save).toBeDisabled();
|
||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
await user.click(save);
|
||||
await waitFor(() =>
|
||||
expect(accountPool.setUsers).toHaveBeenCalledWith("claude", "team-1", [7], "boss-pass")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* @file usePrefersReducedMotion.ts
|
||||
* @description The OS "reduce motion" preference, followed live. Its own
|
||||
* module so a settings screen can read it without importing the pet overlay
|
||||
* (`components/Sagi/Sagi.tsx`), whose chat and pane UI would otherwise be
|
||||
* pulled into every page's first bundle.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/** The OS "reduce motion" preference, followed live. */
|
||||
export function usePrefersReducedMotion(): boolean {
|
||||
const [reduced, setReduced] = useState(
|
||||
() =>
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
|
||||
);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia?.("(prefers-reduced-motion: reduce)");
|
||||
if (!mq) return;
|
||||
const onChange = () => setReduced(mq.matches);
|
||||
mq.addEventListener?.("change", onChange);
|
||||
return () => mq.removeEventListener?.("change", onChange);
|
||||
}, []);
|
||||
return reduced;
|
||||
}
|
||||
@@ -220,6 +220,15 @@
|
||||
"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",
|
||||
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" matches no node in \"{{pipeline}}\" — declare one of: {{nodes}}",
|
||||
@@ -298,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?",
|
||||
@@ -421,6 +431,9 @@
|
||||
"home.cloneRun": "Clone",
|
||||
"home.cloneRetry": "Retry the failed ones",
|
||||
"home.cloneDone": "Added",
|
||||
"home.cloneNeedSsh": "SSH links need an SSH key first — your account has none. Make one in Settings → Git and add its public key to {{hosts}} before cloning.",
|
||||
"home.cloneNeedLogin": "No saved login for {{hosts}}. A private repository needs your username and an access token (or save one in Settings → Git).",
|
||||
"home.cloneEnterLogin": "Enter a login",
|
||||
"home.suggested": "Untracked worktrees",
|
||||
"home.remove": "Remove",
|
||||
"home.missing": "missing",
|
||||
@@ -548,6 +561,9 @@
|
||||
"fx.refresh": "Refresh",
|
||||
"fx.newFolder": "New folder",
|
||||
"fx.newFile": "New file",
|
||||
"fx.cloneRepo": "Clone repository",
|
||||
"fx.cloneSubmit": "Clone",
|
||||
"fx.cloned": "Cloned into {{path}} — opened as workspace “{{workspace}}”.",
|
||||
"fx.uploadFiles": "Upload files",
|
||||
"fx.uploadFolder": "Upload a folder",
|
||||
"fx.download": "Download",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -37,18 +37,14 @@
|
||||
"chooseDesc": "Featured pets come from codex-pets.net and are downloaded into this install the first time you pick one. Your choice and size are saved to your account.",
|
||||
"uploadHint": "An image (PNG, JPEG, GIF, WebP — no SVG) is shown still; a .codex-pet.zip (pet.json + a 1536×1872 spritesheet.webp) is animated by mood.",
|
||||
"panel": {
|
||||
"label": "{{name}} companion",
|
||||
"live": "Live",
|
||||
"offline": "Offline",
|
||||
"close": "Close {{name}}",
|
||||
"statLive": "live",
|
||||
"statWaiting": "waiting",
|
||||
"statErrored": "errored",
|
||||
"mute": "Mute",
|
||||
"unmute": "Unmute",
|
||||
"clearAlerts": "Clear alerts",
|
||||
"askPlaceholder": "Ask {{name}}… (e.g. any errors?)",
|
||||
"ask": "Ask {{name}}",
|
||||
"send": "Send",
|
||||
"rename": "Click to rename {{name}}",
|
||||
"renameLabel": "Pet name",
|
||||
@@ -217,9 +213,12 @@
|
||||
},
|
||||
"chat": {
|
||||
"label": "Chat with {{name}}",
|
||||
"back": "Back",
|
||||
"close": "Close {{name}}",
|
||||
"new": "New chat",
|
||||
"status": "Status — click to show the sessions that need you",
|
||||
"endTitle": "End this chat?",
|
||||
"endBody": "It is still writing a reply. A new chat stops this session.",
|
||||
"endConfirm": "Stop and start new",
|
||||
"openWorkspace": "Open in workspace",
|
||||
"typing": "typing…",
|
||||
"placeholder": "Ask {{name}} anything…",
|
||||
@@ -1217,6 +1216,23 @@
|
||||
"ignoreHint": "One absolute path per line. Hooks from these folders, and anything under them, are dropped whole; history import and the Codex reader skip their sessions. AGENTHUB_IGNORE_CWD sets the same list.",
|
||||
"envOwned": "A value here is set by the environment the dashboard started in, and cannot be changed from this page."
|
||||
},
|
||||
"retention": {
|
||||
"title": "Automatic cleanup",
|
||||
"description": "Off by default. When on, the hourly maintenance deletes finished sessions (completed, error, abandoned) that started more than this many days ago — with their events, agents and token usage — a little at a time.",
|
||||
"days": "Delete sessions older than",
|
||||
"off": "Automatic cleanup is off.",
|
||||
"on": "Sessions older than {{days}} days are deleted every hour.",
|
||||
"invalid": "A whole number from 0 (off) to {{max}}",
|
||||
"saved": "Automatic cleanup saved",
|
||||
"lastRun": "Last run {{time}}: {{n}} session(s) deleted",
|
||||
"space": "Database {{size}} · {{free}} free inside it · auto-vacuum: {{mode}}",
|
||||
"compact": "Compact now",
|
||||
"compacting": "Compacting…",
|
||||
"compactConfirmTitle": "Compact the database?",
|
||||
"compactConfirmBody": "This rewrites the whole database file to give free space back to the disk and turns on incremental auto-vacuum. On a large database it takes a while, and new hook events wait until it finishes.",
|
||||
"compactDone": "Database compacted: {{before}} → {{after}}",
|
||||
"compactFailed": "Compact failed: {{error}}"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Daily backup",
|
||||
"description": "A full copy of the database once a day, into a folder you choose — best on another disk, so a failed drive does not take the backups with it.",
|
||||
@@ -1291,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.",
|
||||
|
||||
@@ -166,7 +166,8 @@
|
||||
"EACTIVE": "This server's Claude Code keeps that login in the macOS Keychain. Switch it to another profile first.",
|
||||
"ENOTFOUND": "That saved login no longer exists.",
|
||||
"ESTALE": "That login was already refreshed elsewhere. Log in again and save it anew.",
|
||||
"EUPSTREAM": "Anthropic could not be reached. Try again."
|
||||
"EUPSTREAM": "Anthropic could not be reached. Try again.",
|
||||
"ENOUSER": "That user no longer exists."
|
||||
},
|
||||
"source": "From",
|
||||
"sources": {
|
||||
@@ -196,7 +197,20 @@
|
||||
"token": "The sk-ant-… that `claude setup-token` printed.",
|
||||
"codex": "An OpenAI API key (sk-…), or the whole ~/.codex/auth.json after `codex login` with a ChatGPT account."
|
||||
},
|
||||
"refreshFailed": "refresh failed"
|
||||
"refreshFailed": "refresh failed",
|
||||
"pinned": "Used by: {{users}}",
|
||||
"loadingUsers": "Loading…",
|
||||
"pinDone": {
|
||||
"own-login": "{{user}} signed in with their own login, which was left alone",
|
||||
"expired": "{{account}} has expired — {{user}}'s new sessions fall back to the least-used account"
|
||||
},
|
||||
"edit": "Edit {{name}}",
|
||||
"editHint": "Tick the users whose sessions run on this account instead of the least-used one — even when it is full. A user on another account moves here. A Claude login is written into their home at once: sessions already running move to it at their next login check, with no restart. A setup-token or Codex account applies to new sessions. Untick to send a user back to the least-used account.",
|
||||
"save": "Save",
|
||||
"users": "Users on this account",
|
||||
"noUsers": "No users yet.",
|
||||
"onAccount": "now on {{account}}",
|
||||
"editDone": "{{name}} saved"
|
||||
},
|
||||
"delete": {
|
||||
"button": "Delete user",
|
||||
|
||||
@@ -220,6 +220,15 @@
|
||||
"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",
|
||||
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" không khớp node nào trong \"{{pipeline}}\" — khai báo một trong: {{nodes}}",
|
||||
@@ -298,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?",
|
||||
@@ -421,6 +431,9 @@
|
||||
"home.cloneRun": "Clone",
|
||||
"home.cloneRetry": "Thử lại các repo lỗi",
|
||||
"home.cloneDone": "Đã thêm",
|
||||
"home.cloneNeedSsh": "Link SSH cần SSH key trước — tài khoản của bạn chưa có. Hãy tạo key trong Cài đặt → Git và thêm public key vào {{hosts}} trước khi clone.",
|
||||
"home.cloneNeedLogin": "Chưa lưu login cho {{hosts}}. Repo private cần username và access token của bạn (hoặc lưu sẵn trong Cài đặt → Git).",
|
||||
"home.cloneEnterLogin": "Nhập login",
|
||||
"home.suggested": "Worktree chưa theo dõi",
|
||||
"home.remove": "Gỡ",
|
||||
"home.missing": "không còn",
|
||||
@@ -548,6 +561,9 @@
|
||||
"fx.refresh": "Làm mới",
|
||||
"fx.newFolder": "Thư mục mới",
|
||||
"fx.newFile": "File mới",
|
||||
"fx.cloneRepo": "Clone repository",
|
||||
"fx.cloneSubmit": "Clone",
|
||||
"fx.cloned": "Đã clone vào {{path}} — đã mở thành workspace “{{workspace}}”.",
|
||||
"fx.uploadFiles": "Tải file lên",
|
||||
"fx.uploadFolder": "Tải cả thư mục lên",
|
||||
"fx.download": "Tải về",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -37,18 +37,14 @@
|
||||
"chooseDesc": "Pet nổi bật lấy từ codex-pets.net và được tải về máy ở lần đầu bạn chọn. Lựa chọn và kích thước được lưu vào tài khoản của bạn.",
|
||||
"uploadHint": "Ảnh (PNG, JPEG, GIF, WebP — không nhận SVG) hiển thị tĩnh; file .codex-pet.zip (pet.json + spritesheet.webp 1536×1872) chuyển động theo tâm trạng.",
|
||||
"panel": {
|
||||
"label": "Trợ lý {{name}}",
|
||||
"live": "Trực tuyến",
|
||||
"offline": "Ngoại tuyến",
|
||||
"close": "Đóng {{name}}",
|
||||
"statLive": "đang chạy",
|
||||
"statWaiting": "đang chờ",
|
||||
"statErrored": "lỗi",
|
||||
"mute": "Tắt tiếng",
|
||||
"unmute": "Bật tiếng",
|
||||
"clearAlerts": "Xoá cảnh báo",
|
||||
"askPlaceholder": "Hỏi {{name}}… (vd: có lỗi nào không?)",
|
||||
"ask": "Hỏi {{name}}",
|
||||
"send": "Gửi",
|
||||
"rename": "Bấm để đổi tên {{name}}",
|
||||
"renameLabel": "Tên pet",
|
||||
@@ -217,9 +213,12 @@
|
||||
},
|
||||
"chat": {
|
||||
"label": "Trò chuyện với {{name}}",
|
||||
"back": "Quay lại",
|
||||
"close": "Đóng {{name}}",
|
||||
"new": "Cuộc trò chuyện mới",
|
||||
"status": "Trạng thái — bấm để xem các phiên cần bạn",
|
||||
"endTitle": "Kết thúc chat này?",
|
||||
"endBody": "Nó vẫn đang trả lời. Chat mới sẽ dừng phiên này.",
|
||||
"endConfirm": "Dừng và bắt đầu mới",
|
||||
"openWorkspace": "Mở trong workspace",
|
||||
"typing": "đang trả lời…",
|
||||
"placeholder": "Hỏi {{name}} bất cứ điều gì…",
|
||||
@@ -1217,6 +1216,23 @@
|
||||
"ignoreHint": "Mỗi dòng một đường dẫn tuyệt đối. Hook từ các thư mục này, và mọi thứ bên trong, bị bỏ hoàn toàn; việc nhập lịch sử và bộ đọc Codex cũng bỏ qua phiên của chúng. AGENTHUB_IGNORE_CWD đặt cùng danh sách này.",
|
||||
"envOwned": "Một giá trị ở đây do môi trường khởi động dashboard đặt, và không thể đổi từ trang này."
|
||||
},
|
||||
"retention": {
|
||||
"title": "Tự động dọn dẹp",
|
||||
"description": "Mặc định tắt. Khi bật, bảo trì hằng giờ xóa các phiên đã kết thúc (hoàn tất, lỗi, bỏ dở) bắt đầu cách đây hơn số ngày này — cùng sự kiện, agent và token của chúng — từng ít một.",
|
||||
"days": "Xóa phiên cũ hơn",
|
||||
"off": "Tự động dọn dẹp đang tắt.",
|
||||
"on": "Phiên cũ hơn {{days}} ngày được xóa mỗi giờ.",
|
||||
"invalid": "Số nguyên từ 0 (tắt) đến {{max}}",
|
||||
"saved": "Đã lưu tự động dọn dẹp",
|
||||
"lastRun": "Lần chạy gần nhất {{time}}: đã xóa {{n}} phiên",
|
||||
"space": "Cơ sở dữ liệu {{size}} · {{free}} trống bên trong · auto-vacuum: {{mode}}",
|
||||
"compact": "Nén ngay",
|
||||
"compacting": "Đang nén…",
|
||||
"compactConfirmTitle": "Nén cơ sở dữ liệu?",
|
||||
"compactConfirmBody": "Thao tác này ghi lại toàn bộ tệp cơ sở dữ liệu để trả dung lượng trống cho ổ đĩa và bật auto-vacuum tăng dần. Với cơ sở dữ liệu lớn sẽ mất một lúc, và sự kiện hook mới phải chờ đến khi xong.",
|
||||
"compactDone": "Đã nén cơ sở dữ liệu: {{before}} → {{after}}",
|
||||
"compactFailed": "Nén thất bại: {{error}}"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Backup hằng ngày",
|
||||
"description": "Mỗi ngày một bản sao đầy đủ của cơ sở dữ liệu vào thư mục bạn chọn — tốt nhất là trên một ổ đĩa khác, để hỏng một ổ không mất luôn bản backup.",
|
||||
@@ -1291,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.",
|
||||
|
||||
@@ -166,7 +166,8 @@
|
||||
"EACTIVE": "Claude Code của server giữ login này trong macOS Keychain. Hãy chuyển nó sang profile khác trước.",
|
||||
"ENOTFOUND": "Login đã lưu đó không còn nữa.",
|
||||
"ESTALE": "Login này đã được làm mới ở nơi khác. Đăng nhập lại và lưu lại.",
|
||||
"EUPSTREAM": "Không kết nối được Anthropic. Thử lại."
|
||||
"EUPSTREAM": "Không kết nối được Anthropic. Thử lại.",
|
||||
"ENOUSER": "User đó không còn tồn tại."
|
||||
},
|
||||
"source": "Nguồn",
|
||||
"sources": {
|
||||
@@ -196,7 +197,20 @@
|
||||
"token": "Chuỗi sk-ant-… mà `claude setup-token` in ra.",
|
||||
"codex": "OpenAI API key (sk-…), hoặc toàn bộ ~/.codex/auth.json sau khi `codex login` bằng tài khoản ChatGPT."
|
||||
},
|
||||
"refreshFailed": "làm mới lỗi"
|
||||
"refreshFailed": "làm mới lỗi",
|
||||
"pinned": "Đang dùng: {{users}}",
|
||||
"loadingUsers": "Đang tải…",
|
||||
"pinDone": {
|
||||
"own-login": "{{user}} đang dùng login riêng của họ, nên không bị ghi đè",
|
||||
"expired": "{{account}} đã hết hạn — phiên mới của {{user}} quay về tài khoản ít tải nhất"
|
||||
},
|
||||
"edit": "Sửa {{name}}",
|
||||
"editHint": "Tick các user mà phiên của họ chạy trên tài khoản này thay vì tài khoản ít tải nhất — kể cả khi nó đã đầy. User đang ở tài khoản khác sẽ chuyển sang đây. Login Claude được ghi ngay vào home của họ: phiên đang chạy chuyển sang ở lần kiểm tra đăng nhập kế tiếp, không cần khởi động lại. Setup-token hoặc tài khoản Codex áp dụng cho phiên mới. Bỏ tick để trả user về tài khoản ít tải nhất.",
|
||||
"save": "Lưu",
|
||||
"users": "User dùng tài khoản này",
|
||||
"noUsers": "Chưa có user nào.",
|
||||
"onAccount": "đang ở {{account}}",
|
||||
"editDone": "Đã lưu {{name}}"
|
||||
},
|
||||
"delete": {
|
||||
"button": "Xoá user",
|
||||
|
||||
@@ -207,6 +207,35 @@ describe("panePaste", () => {
|
||||
expect(m.options.map((o) => o.n)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("reads Claude's unnumbered trust prompt and picks by arrows", () => {
|
||||
vi.useFakeTimers();
|
||||
const m = parsePaneMenu(TRUST)!;
|
||||
expect(m.options.map((o) => o.label)).toEqual(["No, exit", "Yes, I trust this folder"]);
|
||||
expect(m.options.every((o) => o.n === null)).toBe(true);
|
||||
expect(m.cursor).toBe(0);
|
||||
expect(m.question.split("\n")[0]).toBe("Accessing workspace:");
|
||||
expect(m.question).toContain("Security guide");
|
||||
const keys: string[] = [];
|
||||
const off = registerPane(
|
||||
"agenthub-lane-12",
|
||||
() => {},
|
||||
() => true,
|
||||
(k) => keys.push(k)
|
||||
);
|
||||
expect(chooseRunOption("agenthub-lane-12", m, 1)).toBe(true);
|
||||
vi.runAllTimers();
|
||||
expect(keys).toEqual(["\x1b[B", "\r"]);
|
||||
off();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("finds no unnumbered menu in Claude's input box or a wrapped prompt above it", () => {
|
||||
expect(parsePaneMenu(CLAUDE)).toBeNull();
|
||||
expect(
|
||||
parsePaneMenu(["❯ a long prompt that", " wrapped onto two rows", "", RULE, "❯\u00a0", RULE])
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("finds no menu in a reply's numbered list or a typed `1.`", () => {
|
||||
// No cursor on the list.
|
||||
expect(parsePaneMenu(["● Steps:", " 1. one", " 2. two", "", RULE, "❯ ", RULE])).toBeNull();
|
||||
@@ -313,12 +342,35 @@ const CODEX_APPROVAL = [
|
||||
" enteresc",
|
||||
];
|
||||
|
||||
// Claude Code's folder-trust prompt: no numbers, its footer dim (left out).
|
||||
const TRUST = [
|
||||
RULE,
|
||||
" Accessing workspace:",
|
||||
"",
|
||||
" /home/me",
|
||||
"",
|
||||
" Quick safety check: Is this a project you created or one you trust?",
|
||||
"",
|
||||
" Security guide",
|
||||
"",
|
||||
" ❯ No, exit",
|
||||
" Yes, I trust this folder",
|
||||
"",
|
||||
"",
|
||||
];
|
||||
|
||||
describe("MENU_CURSOR_ROW", () => {
|
||||
// The chat's 700 ms poll reads the screen cell by cell only when a row
|
||||
// matches it, so every menu parsePaneMenu finds must have such a row.
|
||||
it("matches a row of every menu, and no row of an idle prompt", () => {
|
||||
const has = (rows: string[]) => rows.some((r) => MENU_CURSOR_ROW.test(r));
|
||||
for (const menu of [ASK, MULTI, PERMISSION, CODEX_APPROVAL]) {
|
||||
for (const menu of [
|
||||
ASK,
|
||||
MULTI,
|
||||
PERMISSION,
|
||||
CODEX_APPROVAL,
|
||||
[...TRUST, " Enter to confirm · Esc to cancel"],
|
||||
]) {
|
||||
expect(parsePaneMenu(menu)).not.toBeNull();
|
||||
expect(has(menu)).toBe(true);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* @file sessionChange.test.ts
|
||||
* @description Unit tests for `sessionChangeMatcher`: fires on a new session
|
||||
* and on a change of the keyed columns, never on a re-broadcast that moved none.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sessionChangeMatcher } from "../sessionChange";
|
||||
import type { WSMessage } from "../types";
|
||||
|
||||
const msg = (type: string, data: unknown) => ({ type, data, timestamp: "" }) as WSMessage;
|
||||
|
||||
describe("sessionChangeMatcher", () => {
|
||||
it("fires on a new session and on a state change, not on a same-state re-broadcast", () => {
|
||||
const m = sessionChangeMatcher();
|
||||
expect(m(msg("session_created", { id: "a", status: "active" }))).toBe(true);
|
||||
expect(m(msg("session_updated", { id: "a", status: "active", name: "x" }))).toBe(false);
|
||||
expect(
|
||||
m(msg("session_updated", { id: "a", status: "active", awaiting_input_since: "t" }))
|
||||
).toBe(true);
|
||||
expect(m(msg("session_updated", { id: "a", status: "completed" }))).toBe(true);
|
||||
expect(m(msg("session_updated", { id: "a", status: "completed", archived_at: "t" }))).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("takes an unseen or unkeyable session as a change, and ignores other types", () => {
|
||||
const m = sessionChangeMatcher();
|
||||
expect(m(msg("session_updated", { id: "b", status: "active" }))).toBe(true);
|
||||
expect(m(msg("session_updated", null))).toBe(true);
|
||||
expect(m(msg("new_event", { session_id: "b" }))).toBe(false);
|
||||
});
|
||||
});
|
||||
+107
-1
@@ -676,9 +676,18 @@ export type CodexPoolAccount = {
|
||||
quotaPercent: number | null;
|
||||
full: boolean;
|
||||
};
|
||||
/** A user an admin pinned to one pool account: their new sessions skip the balancer. */
|
||||
export type AccountPin = {
|
||||
userId: number;
|
||||
username: string;
|
||||
account: string;
|
||||
pinnedAt?: string;
|
||||
};
|
||||
export type AccountPool = {
|
||||
accounts: PoolAccount[];
|
||||
codex: CodexPoolAccount[];
|
||||
/** Per tool; absent from an older server, empty in local mode. */
|
||||
pins?: { claude: AccountPin[]; codex: AccountPin[] };
|
||||
blockPercent: number;
|
||||
};
|
||||
export type PoolTool = "claude" | "codex";
|
||||
@@ -857,6 +866,9 @@ export interface FsHome {
|
||||
id: string;
|
||||
label: string;
|
||||
readOnly: boolean;
|
||||
/** The caller's own home only (`me`): its absolute folder — where New →
|
||||
* Clone repository clones to. Absent from an older server. */
|
||||
dir?: string;
|
||||
}
|
||||
|
||||
/** One entry of a File Explorer folder listing (`GET /api/lanes/:id/fs/list`). */
|
||||
@@ -1052,6 +1064,20 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...body, currentPassword }),
|
||||
}),
|
||||
/** PUT /api/accounts/:name/users — admin: exactly these users run on this account. */
|
||||
setUsers: (tool: PoolTool, name: string, userIds: number[], currentPassword: string) =>
|
||||
request<{
|
||||
/** Users moved onto it; `switched`: what happened now (a Claude login goes into their home). */
|
||||
added: {
|
||||
userId: number;
|
||||
username: string;
|
||||
switched: "switched" | "kept" | "own-login" | "expired" | "next-session";
|
||||
}[];
|
||||
removed: { userId: number; username: string }[];
|
||||
}>(`/accounts/${encodeURIComponent(name)}/users`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ tool, userIds, currentPassword }),
|
||||
}),
|
||||
/** DELETE /api/accounts/:name — admin, with their OWN password again. */
|
||||
remove: (tool: PoolTool, name: string, currentPassword: string) =>
|
||||
request<{ ok: true }>(`/accounts/${encodeURIComponent(name)}`, {
|
||||
@@ -1649,6 +1675,23 @@ export const api = {
|
||||
/** POST /api/settings/backup/run — starts one; poll `get` while `running` */
|
||||
run: () => request<BackupState & { ok: boolean }>("/settings/backup/run", { method: "POST" }),
|
||||
},
|
||||
/** Opt-in automatic retention and Compact (`server/lib/db-retention.js`). */
|
||||
retention: {
|
||||
/** GET /api/settings/retention */
|
||||
get: () => request<RetentionState>("/settings/retention"),
|
||||
/** PUT /api/settings/retention — `days` 0 turns it off */
|
||||
save: (days: number) =>
|
||||
request<RetentionState & { ok: boolean }>("/settings/retention", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ days }),
|
||||
}),
|
||||
/** POST /api/settings/compact — a full VACUUM; poll `get` while `compacting` */
|
||||
compact: () =>
|
||||
request<RetentionState & { ok: boolean }>("/settings/compact", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ confirm: true }),
|
||||
}),
|
||||
},
|
||||
/** The bearer token for `POST /api/hooks/ingest-batch`. Only its hash is
|
||||
* stored: `rotate` returns the new token this once, `get` never does. */
|
||||
pushToken: {
|
||||
@@ -3533,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<CloneProgressView>(`/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.
|
||||
@@ -3934,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<LaneSourcesView & { path: string }>(`/lanes/${id}/sources/clone`, {
|
||||
@@ -4389,6 +4446,8 @@ export interface AiTool {
|
||||
prompt: { mode: "argv" | "flag" | "after-start" } | null;
|
||||
/** Offers "Resume last session". */
|
||||
resume: boolean;
|
||||
/** Can resume a NAMED session (Claude, Codex, a catalog `resumeById`). */
|
||||
resumeById?: boolean;
|
||||
/** Offers the per-run "Bypass permissions" switch. */
|
||||
bypass: boolean;
|
||||
/** Can use an AI Proxy profile (Claude, Codex); others are native only. */
|
||||
@@ -5172,7 +5231,7 @@ export interface RunStartArgs {
|
||||
cwd?: string;
|
||||
model?: string;
|
||||
permissionMode?: PermissionMode;
|
||||
/** Resume an existing Claude Code session id (`--resume`) instead of starting fresh. */
|
||||
/** Resume an existing session id (Claude/Codex, or a catalog `resumeById` tool) instead of starting fresh. */
|
||||
resumeSessionId?: string;
|
||||
effort?: EffortLevel;
|
||||
/** Sent as `claude`'s first positional message once the pane boots; omit
|
||||
@@ -5245,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). */
|
||||
@@ -5461,6 +5537,36 @@ export interface BackupState {
|
||||
env_owned: { dir: boolean; hour: boolean; keep: boolean };
|
||||
}
|
||||
|
||||
/** GET /api/settings/retention. `days` 0 = automatic retention off;
|
||||
* `last_run`/`last_compact` are this server process's own. */
|
||||
export interface RetentionState {
|
||||
days: number;
|
||||
max_days: number;
|
||||
env_owned: { days: boolean };
|
||||
last_run: {
|
||||
at: string;
|
||||
retention?: { purged_sessions: number; purged_events: number; error?: string };
|
||||
reclaim?: { reclaimed_pages: number; error?: string };
|
||||
} | null;
|
||||
space: {
|
||||
auto_vacuum: "none" | "full" | "incremental";
|
||||
page_size: number;
|
||||
page_count: number;
|
||||
freelist_pages: number;
|
||||
free_bytes: number;
|
||||
} | null;
|
||||
db_size: number;
|
||||
compacting: boolean;
|
||||
last_compact: {
|
||||
at: string;
|
||||
ok: boolean;
|
||||
size_before?: number;
|
||||
size_after?: number;
|
||||
ms?: number;
|
||||
error?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/** GET /api/settings/push-token — whether push ingest is on; never the token. */
|
||||
export interface PushTokenStatus {
|
||||
enabled: boolean;
|
||||
|
||||
@@ -14,6 +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 — 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).
|
||||
@@ -24,7 +28,8 @@
|
||||
export type PaneScreen = { rows: string[]; cols: number };
|
||||
|
||||
interface Pane {
|
||||
paste: (text: string, submit?: boolean) => void;
|
||||
/** `files`: image paths pasted after `text`, one paste each. */
|
||||
paste: (text: string, submit?: boolean, files?: string[]) => void;
|
||||
visible: () => boolean;
|
||||
key?: (data: string) => void;
|
||||
/** `menuOnly`: null at once unless a row could be a menu's cursor row. */
|
||||
@@ -62,19 +67,19 @@ function ofLane(runId: string, laneId: number): boolean {
|
||||
|
||||
/** Pastes `text` into the pane of `runId` when it is mounted and on screen;
|
||||
* false otherwise (not mounted yet, or hidden). */
|
||||
export function pasteToRun(runId: string, text: string): boolean {
|
||||
export function pasteToRun(runId: string, text: string, files: string[] = []): boolean {
|
||||
const p = panes.get(runId);
|
||||
if (!p?.visible()) return false;
|
||||
p.paste(text);
|
||||
p.paste(text, false, files);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Sends `text` as a message to the pane of `runId`: a paste, then Enter.
|
||||
* False when the pane is not mounted or not attached. */
|
||||
export function submitToRun(runId: string, text: string): boolean {
|
||||
export function submitToRun(runId: string, text: string, files: string[] = []): boolean {
|
||||
const p = panes.get(runId);
|
||||
if (!p?.visible()) return false;
|
||||
p.paste(text, true);
|
||||
p.paste(text, true, files);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -176,8 +181,9 @@ export type PaneMenu = {
|
||||
};
|
||||
|
||||
const OPTION = /^ {0,4}([❯›][ \u00a0]?)? *(\d{1,2})\. +(.*)$/;
|
||||
/** A row a menu's cursor sits on (a numbered option or Submit): a menu has one. */
|
||||
export const MENU_CURSOR_ROW = /^ *[❯›][ \u00a0]* *(?:\d{1,2}\. |Submit\s*$)/;
|
||||
/** A row only a menu shows: its cursor on a numbered option or Submit, or the
|
||||
* "Enter to confirm" footer of Claude's unnumbered menus (the trust prompt). */
|
||||
export const MENU_CURSOR_ROW = /^ *(?:[❯›][ \u00a0]* *(?:\d{1,2}\. |Submit\s*$)|Enter to confirm)/;
|
||||
const SUBMIT = /^ *([❯›])?[ \u00a0]*Submit$/;
|
||||
const RULE_ROW = /^ *[─╌]{3,} *$/;
|
||||
const CHECK = /^\[([ ✔])\] *(.*)$/;
|
||||
@@ -224,7 +230,8 @@ export function parsePaneMenu(rows: string[]): PaneMenu | null {
|
||||
}
|
||||
}
|
||||
const numbered = options.filter((o) => o.n !== null);
|
||||
if (top < 0 || cursor < 0 || numbered.length < 2 || RULE_ROW.test(r[top - 1] ?? "")) return null;
|
||||
if (top < 0 || cursor < 0 || numbered.length < 2 || RULE_ROW.test(r[top - 1] ?? ""))
|
||||
return parseBareMenu(r.slice(0, end + 1));
|
||||
if (numbered.some((o, i) => o.n !== i + 1)) return null;
|
||||
// The question: the block of text right above the first option.
|
||||
let q = top - 1;
|
||||
@@ -241,6 +248,39 @@ export function parsePaneMenu(rows: string[]): PaneMenu | null {
|
||||
};
|
||||
}
|
||||
|
||||
const BARE_CURSOR = /^( {0,4})❯[ \u00a0]+(\S.*)$/;
|
||||
|
||||
/** Claude's UNNUMBERED menu at the bottom of `r` (the folder-trust prompt:
|
||||
* `❯ No, exit` over ` Yes, I trust this folder`), or null. Its rows share the
|
||||
* cursor row's text column, and nothing under them is a rule — Claude's input
|
||||
* box draws one above and below its `❯` row, so neither the box nor a prompt
|
||||
* in the history above it reads as a menu. Picked by arrows and Enter. */
|
||||
function parseBareMenu(r: string[]): PaneMenu | null {
|
||||
let at = -1;
|
||||
for (let i = r.length - 1; i >= 0 && at < 0; i--) if (BARE_CURSOR.test(r[i] ?? "")) at = i;
|
||||
if (at < 0 || RULE_ROW.test(r[at - 1] ?? "") || r.slice(at + 1).some((l) => RULE_ROW.test(l)))
|
||||
return null;
|
||||
const m = BARE_CURSOR.exec(r[at]!)!;
|
||||
const indent = " ".repeat((m[1] ?? "").length + 2);
|
||||
const sibling = (l = "") => l.startsWith(indent) && /\S/.test(l[indent.length] ?? " ");
|
||||
let top = at;
|
||||
while (top > 0 && sibling(r[top - 1])) top--;
|
||||
let last = at;
|
||||
while (last < r.length - 1 && sibling(r[last + 1])) last++;
|
||||
if (last === top) return null;
|
||||
const options = r.slice(top, last + 1).map((l, i) => ({
|
||||
n: null,
|
||||
label: top + i === at ? (m[2] ?? "").trim() : l.trim(),
|
||||
hint: "",
|
||||
checked: null,
|
||||
}));
|
||||
// The question: the paragraphs above, up to the dialog's top rule.
|
||||
const lines: string[] = [];
|
||||
for (let q = top - 1; q >= 0 && !RULE_ROW.test(r[q]!) && lines.length < 8; q--)
|
||||
if (r[q]) lines.unshift(r[q]!.trim());
|
||||
return { question: lines.join("\n"), options, cursor: at - top, multi: false, typeAt: -1 };
|
||||
}
|
||||
|
||||
/** The "Type something" row. Text typed into it replaces its label, and it is
|
||||
* still the last numbered row above "Chat about this". */
|
||||
function typeRow(options: PaneMenuOption[]): number {
|
||||
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -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.-]+)?$/;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @file sessionChange.ts
|
||||
* @description `session_created` / `session_updated` matcher for live-refresh
|
||||
* callers that care about only a few session columns. A session row is
|
||||
* re-broadcast on nearly every hook (last activity, name, tokens…), so
|
||||
* matching the bare message types made every such frame refetch; this one
|
||||
* fires on a new session, or when the chosen key of a session actually
|
||||
* changed. Each call keeps its own memory of the keys it has seen.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import type { Session, WSMessage } from "./types";
|
||||
|
||||
/** The columns that move a session between the active / waiting / archived counts. */
|
||||
export const sessionStateKeyOf = (s: Partial<Session>): string =>
|
||||
`${s.status}:${s.awaiting_input_since ? 1 : 0}:${s.archived_at ? 1 : 0}`;
|
||||
|
||||
export function sessionChangeMatcher(
|
||||
keyOf: (s: Partial<Session>) => string = sessionStateKeyOf
|
||||
): (msg: WSMessage) => boolean {
|
||||
const seen = new Map<string, string>();
|
||||
return (msg) => {
|
||||
if (msg.type !== "session_created" && msg.type !== "session_updated") return false;
|
||||
const s = msg.data as Partial<Session> | undefined;
|
||||
// A frame we cannot key is taken as a change: refetching is the safe side.
|
||||
if (!s || typeof s.id !== "string") return true;
|
||||
const key = keyOf(s);
|
||||
if (msg.type === "session_updated" && seen.get(s.id) === key) return false;
|
||||
seen.set(s.id, key);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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<CodexDefaults>): 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));
|
||||
}
|
||||
|
||||
@@ -732,7 +732,10 @@ export interface Session {
|
||||
/** The AI coding CLI that produced the session: `"claude"` (the default —
|
||||
* Claude Code's hooks and imported transcripts), `"codex"` (Codex's rollout
|
||||
* journals and hooks), `"antigravity"` (agy's conversation files and
|
||||
* hooks) or `"cursor"` (Cursor's agent transcripts, no tokens). Attach/resume offer `claude` rows only. Maps to `sessions.tool`. */
|
||||
* hooks), `"cursor"` (Cursor's agent transcripts, no tokens), `"omp"`,
|
||||
* `"opencode"` (their own session stores, with tokens and subagents) or
|
||||
* `"grok"` (Grok running the Claude hooks, no tokens). Maps to
|
||||
* `sessions.tool`. */
|
||||
tool?: string;
|
||||
/** The workspace (lane id) that owns the session — the one whose pane held
|
||||
* it; null for a session no workspace started.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2790,9 +2790,12 @@ function RecentActivityPanel({ sessionsById }: { sessionsById: Map<string, Sessi
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
useLiveRefresh(load, { intervalMs: 10000 });
|
||||
// New events arrive over the WebSocket (prepended below); a reload is only
|
||||
// for what the socket cannot carry: a remote source's import, a gap while it
|
||||
// was down (reconnect) or the tab hidden (shown again), and a slow safety poll.
|
||||
useLiveRefresh(load, { intervalMs: 60000, match: isRemoteDataRefreshMessage });
|
||||
|
||||
// A new event is prepended at once (no reload); the poll reconciles.
|
||||
// A new event is prepended at once (no reload); the reloads reconcile.
|
||||
useEffect(
|
||||
() =>
|
||||
eventBus.subscribe((msg: WSMessage) => {
|
||||
|
||||
@@ -1129,7 +1129,11 @@ export function SessionDetail() {
|
||||
|
||||
{visitedTabs.has("conversation") && (
|
||||
<div hidden={activeTab !== "conversation"}>
|
||||
<ConversationView sessionId={session.id} initialTranscriptId={pendingTranscriptId} />
|
||||
<ConversationView
|
||||
sessionId={session.id}
|
||||
initialTranscriptId={pendingTranscriptId}
|
||||
ended={session.status !== "active"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file Settings.tsx
|
||||
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, push ingest (`components/settings/PushIngestSettings.tsx`), privacy (`components/settings/PrivacySettings.tsx`), the daily database backup (`components/settings/BackupSettings.tsx`), export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` (the System page) — the start-at-login switch and Quit (`AutostartSettings`), then the server process (release, uptime, platform, CPU, memory, live connections), how AgentHub is installed (the update check's cached status) and the database file — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
|
||||
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, push ingest (`components/settings/PushIngestSettings.tsx`), privacy (`components/settings/PrivacySettings.tsx`), the daily database backup (`components/settings/BackupSettings.tsx`), export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm; under it the opt-in automatic cleanup and Compact, `components/settings/RetentionSettings.tsx`) and the danger zone (typed confirmation) —, `section="about"` (the System page) — the start-at-login switch and Quit (`AutostartSettings`), then the server process (release, uptime, platform, CPU, memory, live connections), how AgentHub is installed (the update check's cached status) and the database file — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -103,6 +103,7 @@ import { RemoteSources } from "../components/RemoteSources";
|
||||
import { PushIngestSettings } from "../components/settings/PushIngestSettings";
|
||||
import { PrivacySettings } from "../components/settings/PrivacySettings";
|
||||
import { BackupSettings } from "../components/settings/BackupSettings";
|
||||
import { RetentionSettings } from "../components/settings/RetentionSettings";
|
||||
import { AutostartSettings } from "../components/settings/AutostartSettings";
|
||||
import { usePermissions } from "../components/AuthGate";
|
||||
import { Skeleton } from "../components/Skeleton";
|
||||
@@ -1054,6 +1055,8 @@ export function Settings({
|
||||
</button>
|
||||
|
||||
{actionBanner(["cleanup"])}
|
||||
|
||||
<RetentionSettings />
|
||||
</section>
|
||||
|
||||
{/* ─── THIS BROWSER'S PREFERENCES ─── */}
|
||||
|
||||
@@ -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: () => (
|
||||
<div className="space-y-6">
|
||||
<AgentInstructionsSettings />
|
||||
<SessionDefaultsSettings />
|
||||
<AiToolsSettings settingsHref={toolHref} />
|
||||
{/* The Hub's install switch: server mode, an admin's (it hides itself). */}
|
||||
<ArtifactsHubSettings />
|
||||
|
||||
@@ -85,6 +85,9 @@ import { endRuns } from "../lib/stopSession";
|
||||
import { ContextMenu, MenuItem, useContextMenu } from "../components/ContextMenu";
|
||||
import { confirmDiscard } from "../lib/confirm";
|
||||
import { keepIfSame } from "../lib/keepIfSame";
|
||||
import { laneChangeMatcher, runKeyOf } from "../lib/laneChange";
|
||||
import { sessionChangeMatcher } from "../lib/sessionChange";
|
||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||
import { useWorkLanes } from "../hooks/useLanes";
|
||||
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
||||
import {
|
||||
@@ -1044,9 +1047,10 @@ export function Workspace() {
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
// Goes through `keepIfSame`: this fires every 5 s below, and a
|
||||
// fresh-but-identical payload would otherwise re-render the lane strip, the
|
||||
// lane card and the whole console area on every tick for nothing.
|
||||
// Goes through `keepIfSame`: this re-reads on run-moving messages and a
|
||||
// fallback poll below, and a fresh-but-identical payload would otherwise
|
||||
// re-render the lane strip, the lane card and the whole console area for
|
||||
// nothing.
|
||||
const refreshList = useCallback(() => {
|
||||
api.run
|
||||
.list()
|
||||
@@ -1061,33 +1065,30 @@ export function Workspace() {
|
||||
window.dispatchEvent(new CustomEvent(LIVE_RUNS_EVENT, { detail: activeRuns.items }));
|
||||
}, [activeRuns]);
|
||||
|
||||
// Background poll so the run list and history reflect external changes
|
||||
// (server-boot reconciliation, sibling tabs, direct DB edits) even when
|
||||
// no WS event fires. Lighter than typical WS gaps; aggressive enough that
|
||||
// status flips appear within seconds without needing a manual refresh.
|
||||
// The run list follows the WebSocket instead of a blind 5 s poll: a pane
|
||||
// starting, ending or learning its session in slot 0 moves the lane's run
|
||||
// columns (`lane_update`); an agent starting in a free tab (slots 1–9)
|
||||
// shows as a new session, one ending as a session status change, and a pane
|
||||
// dying under its agent as `runs_interrupted`. This page's own starts, stops
|
||||
// and a shown terminal's exit call `refreshList` directly
|
||||
// (`onRunsChanged`). What none of those cover — a shell tab opened or closed
|
||||
// from another browser, server-boot reconciliation — is caught by the
|
||||
// 30 s fallback poll, on reconnect, and when the tab is shown or focused.
|
||||
const [laneRunChanged] = useState(() => laneChangeMatcher(runKeyOf));
|
||||
const [sessionRunChanged] = useState(() => sessionChangeMatcher((s) => `${s.status}`));
|
||||
useEffect(() => {
|
||||
refreshList();
|
||||
const tick = setInterval(() => {
|
||||
// Hidden tab: skip; the visibility handler below re-reads on show.
|
||||
if (document.visibilityState === "visible") refreshList();
|
||||
}, 5000);
|
||||
return () => clearInterval(tick);
|
||||
}, [refreshList]);
|
||||
|
||||
// Refresh whenever the tab regains focus / visibility - typical when the
|
||||
// user comes back from running `claude` in a terminal and wants to see the
|
||||
// current state of every run without waiting for the next poll.
|
||||
useLiveRefresh(refreshList, {
|
||||
intervalMs: 30_000,
|
||||
throttleMs: 1500,
|
||||
match: (msg) =>
|
||||
msg.type === "runs_interrupted" || laneRunChanged(msg) || sessionRunChanged(msg),
|
||||
});
|
||||
useEffect(() => {
|
||||
const onFocus = () => refreshList();
|
||||
const onVis = () => {
|
||||
if (document.visibilityState === "visible") refreshList();
|
||||
};
|
||||
window.addEventListener("focus", onFocus);
|
||||
document.addEventListener("visibilitychange", onVis);
|
||||
return () => {
|
||||
window.removeEventListener("focus", onFocus);
|
||||
document.removeEventListener("visibilitychange", onVis);
|
||||
};
|
||||
return () => window.removeEventListener("focus", onFocus);
|
||||
}, [refreshList]);
|
||||
|
||||
const currentLane = selectedLaneId !== null ? lanes.find((l) => l.id === selectedLaneId) : null;
|
||||
|
||||
@@ -77,6 +77,18 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
env_owned: { dir: false, hour: false, keep: false },
|
||||
})),
|
||||
},
|
||||
retention: {
|
||||
get: vi.fn(async () => ({
|
||||
days: 0,
|
||||
max_days: 3650,
|
||||
env_owned: { days: false },
|
||||
last_run: null,
|
||||
space: null,
|
||||
db_size: 0,
|
||||
compacting: false,
|
||||
last_compact: null,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7022,7 +7022,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
class="mt-2"
|
||||
>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
@@ -7033,7 +7033,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
Start AgentHub automatically
|
||||
</p>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
class="mt-0.5 text-xs leading-relaxed text-fg-muted"
|
||||
>
|
||||
Run the dashboard as a systemd --user service, so it starts at boot without Claude Code being opened. Turning it off keeps the running server up.
|
||||
</p>
|
||||
@@ -7044,19 +7044,19 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<button
|
||||
aria-checked="false"
|
||||
aria-label="Start AgentHub automatically"
|
||||
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 bg-border-light ring-1 ring-inset ring-fg-muted/80"
|
||||
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-40 bg-border-light ring-1 ring-inset ring-fg-muted/80 enabled:hover:ring-fg-secondary"
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-0.5"
|
||||
class="inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-1 ring-black/10 transition-transform duration-200 ease-out translate-x-0.5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
@@ -7067,7 +7067,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
Show AgentHub icon in the status area
|
||||
</p>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
class="mt-0.5 text-xs leading-relaxed text-fg-muted"
|
||||
>
|
||||
An AgentHub icon beside the clock (top bar, menu bar or notification area) to open the dashboard, quit it, and start it again after a quit.
|
||||
</p>
|
||||
@@ -7078,19 +7078,19 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<button
|
||||
aria-checked="true"
|
||||
aria-label="Show AgentHub icon in the status area"
|
||||
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 bg-accent-solid"
|
||||
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-40 bg-accent-solid enabled:hover:bg-accent-solid-hover"
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-[18px]"
|
||||
class="inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-1 ring-black/10 transition-transform duration-200 ease-out translate-x-[18px]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
@@ -7101,25 +7101,51 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
Quit AgentHub
|
||||
</p>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
class="mt-0.5 text-xs leading-relaxed text-fg-muted"
|
||||
>
|
||||
Stop the dashboard so nothing runs in the background. Opening Claude Code no longer starts it; run \`agenthub start\` to bring it back.
|
||||
Stop the dashboard so nothing runs in the background. Opening Claude Code no longer starts it; run
|
||||
<code
|
||||
class="whitespace-nowrap rounded border border-border bg-surface-2 px-1 py-px font-mono text-[11px] text-fg-secondary"
|
||||
>
|
||||
agenthub start
|
||||
</code>
|
||||
to bring it back.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0"
|
||||
>
|
||||
<button
|
||||
class="btn-ghost text-xs text-status-danger disabled:opacity-40"
|
||||
aria-busy="false"
|
||||
class="btn-secondary min-w-[6.5rem] justify-center text-xs"
|
||||
data-testid="quit-agenthub"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-power h-3.5 w-3.5 shrink-0"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 2v10"
|
||||
/>
|
||||
<path
|
||||
d="M18.4 6.6a9 9 0 1 1-12.77.04"
|
||||
/>
|
||||
</svg>
|
||||
Quit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
@@ -7130,7 +7156,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
Uninstall AgentHub
|
||||
</p>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
class="mt-0.5 text-xs leading-relaxed text-fg-muted"
|
||||
>
|
||||
Remove AgentHub from this computer: the dashboard, its database and settings, its hooks in Claude Code, Codex and Antigravity, and the plugin. Your conversations, workspace folders and repositories are kept.
|
||||
</p>
|
||||
@@ -7139,10 +7165,45 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
class="shrink-0"
|
||||
>
|
||||
<button
|
||||
class="btn-ghost text-xs text-status-danger disabled:opacity-40"
|
||||
aria-busy="false"
|
||||
class="btn-danger min-w-[6.5rem] justify-center text-xs"
|
||||
data-testid="uninstall-agenthub"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-trash2 h-3.5 w-3.5 shrink-0"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M3 6h18"
|
||||
/>
|
||||
<path
|
||||
d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"
|
||||
/>
|
||||
<path
|
||||
d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"
|
||||
/>
|
||||
<line
|
||||
x1="10"
|
||||
x2="10"
|
||||
y1="11"
|
||||
y2="17"
|
||||
/>
|
||||
<line
|
||||
x1="14"
|
||||
x2="14"
|
||||
y1="11"
|
||||
y2="17"
|
||||
/>
|
||||
</svg>
|
||||
Uninstall
|
||||
</button>
|
||||
</div>
|
||||
@@ -7162,7 +7223,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
class="mt-2"
|
||||
>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
@@ -7173,12 +7234,12 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
Release
|
||||
</p>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
class="mt-0.5 text-xs leading-relaxed text-fg-muted"
|
||||
>
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.8.7 (rebuild client to match)
|
||||
UI build v2.8.12 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -7193,7 +7254,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
@@ -7215,7 +7276,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
@@ -7240,7 +7301,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
@@ -7262,7 +7323,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
@@ -7284,7 +7345,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
@@ -7295,7 +7356,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
Live connections
|
||||
</p>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
class="mt-0.5 text-xs leading-relaxed text-fg-muted"
|
||||
>
|
||||
Browser tabs and tools receiving live updates
|
||||
</p>
|
||||
@@ -7325,7 +7386,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
class="mt-2"
|
||||
>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
@@ -7355,7 +7416,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
class="mt-2"
|
||||
>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
@@ -7366,7 +7427,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
Database file
|
||||
</p>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
class="mt-0.5 text-xs leading-relaxed text-fg-muted"
|
||||
>
|
||||
<span
|
||||
class="font-mono break-all"
|
||||
@@ -7386,7 +7447,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
@@ -7408,7 +7469,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
@@ -8960,7 +9021,7 @@ exit 0
|
||||
class="mt-2"
|
||||
>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
@@ -8971,7 +9032,7 @@ exit 0
|
||||
Redact secrets before storing
|
||||
</p>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
class="mt-0.5 text-xs leading-relaxed text-fg-muted"
|
||||
>
|
||||
API keys and tokens of known shapes, bearer headers, private keys and password=/TOKEN= values in event payloads and summaries become [redacted]. The transcripts on disk are the CLIs' own files and are not changed.
|
||||
</p>
|
||||
@@ -8982,13 +9043,13 @@ exit 0
|
||||
<button
|
||||
aria-checked="false"
|
||||
aria-label="Redact secrets before storing"
|
||||
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 bg-border-light ring-1 ring-inset ring-fg-muted/80"
|
||||
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-40 bg-border-light ring-1 ring-inset ring-fg-muted/80 enabled:hover:ring-fg-secondary"
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-0.5"
|
||||
class="inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-1 ring-black/10 transition-transform duration-200 ease-out translate-x-0.5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
@@ -9357,6 +9418,108 @@ exit 0
|
||||
</svg>
|
||||
Run Cleanup
|
||||
</button>
|
||||
<div
|
||||
class="border-t border-border pt-4 space-y-3"
|
||||
>
|
||||
<div>
|
||||
<h4
|
||||
class="text-xs font-medium text-fg-secondary"
|
||||
>
|
||||
Automatic cleanup
|
||||
</h4>
|
||||
<p
|
||||
class="text-xs text-fg-muted"
|
||||
>
|
||||
Off by default. When on, the hourly maintenance deletes finished sessions (completed, error, abandoned) that started more than this many days ago — with their events, agents and token usage — a little at a time.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-wrap items-end gap-3"
|
||||
>
|
||||
<div
|
||||
class="bg-surface-2 rounded-lg px-4 py-3"
|
||||
>
|
||||
<label
|
||||
class="text-xs text-fg-secondary block mb-2"
|
||||
for="retention-days"
|
||||
>
|
||||
Delete sessions older than
|
||||
</label>
|
||||
<div
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
class="input w-20 text-sm text-right font-mono"
|
||||
id="retention-days"
|
||||
inputmode="numeric"
|
||||
max="3650"
|
||||
min="0"
|
||||
step="1"
|
||||
type="number"
|
||||
value="0"
|
||||
/>
|
||||
<span
|
||||
class="text-xs text-fg-muted"
|
||||
>
|
||||
days
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled=""
|
||||
type="button"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
class="text-xs text-fg-secondary"
|
||||
>
|
||||
Automatic cleanup is off.
|
||||
</p>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-3"
|
||||
>
|
||||
<button
|
||||
class="text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 text-fg-secondary hover:text-fg-primary hover:bg-surface-4 border border-border"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-minimize2 w-3 h-3 inline mr-1"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<polyline
|
||||
points="4 14 10 14 10 20"
|
||||
/>
|
||||
<polyline
|
||||
points="20 10 14 10 14 4"
|
||||
/>
|
||||
<line
|
||||
x1="14"
|
||||
x2="21"
|
||||
y1="10"
|
||||
y2="3"
|
||||
/>
|
||||
<line
|
||||
x1="3"
|
||||
x2="10"
|
||||
y1="21"
|
||||
y2="14"
|
||||
/>
|
||||
</svg>
|
||||
Compact now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class="scroll-mt-24"
|
||||
@@ -9637,7 +9800,7 @@ exports[`screen snapshots > Settings — notifications 1`] = `
|
||||
class="min-w-0 flex-1"
|
||||
>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
@@ -9654,13 +9817,13 @@ exports[`screen snapshots > Settings — notifications 1`] = `
|
||||
<button
|
||||
aria-checked="false"
|
||||
aria-label="Enable Browser Notifications"
|
||||
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 bg-border-light ring-1 ring-inset ring-fg-muted/80"
|
||||
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-40 bg-border-light ring-1 ring-inset ring-fg-muted/80 enabled:hover:ring-fg-secondary"
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-0.5"
|
||||
class="inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-1 ring-black/10 transition-transform duration-200 ease-out translate-x-0.5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -362,6 +362,18 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
env_owned: { dir: false, hour: false, keep: false },
|
||||
}),
|
||||
},
|
||||
retention: {
|
||||
get: r({
|
||||
days: 0,
|
||||
max_days: 3650,
|
||||
env_owned: { days: false },
|
||||
last_run: null,
|
||||
space: null,
|
||||
db_size: 0,
|
||||
compacting: false,
|
||||
last_compact: null,
|
||||
}),
|
||||
},
|
||||
exportData: () => "/api/settings/export",
|
||||
cleanup: r({
|
||||
ok: true,
|
||||
|
||||
+49
-9
@@ -89,9 +89,9 @@ In server mode a **developer** sees only rows of the workspaces they own (`lanes
|
||||
|
||||
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from), `POST /api/users/:id/password` (`{password}`; that user's logins end) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
|
||||
|
||||
`GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], codex: [{name, kind: "apikey"|"chatgpt", addedAt, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {windows: [{label, usedPercent, resetsAt}], asOf} | null, quotaPercent, full}], blockPercent}` — `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool). `spend*`/`tokens*` = the sessions the account's runs held, over the hours they held them (a Codex session counts whole once its run started in the window). It never includes a key or a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login).
|
||||
`GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], codex: [{name, kind: "apikey"|"chatgpt", addedAt, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {windows: [{label, usedPercent, resetsAt}], asOf} | null, quotaPercent, full}], pins: {claude: [{userId, username, account, pinnedAt}], codex: […]}, blockPercent}` — `pins` are the users an admin pinned to one account (empty in local mode); `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool). `spend*`/`tokens*` = the sessions the account's runs held, over the hours they held them (a Codex session counts whole once its run started in the window). It never includes a key or a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login).
|
||||
|
||||
An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude"|"codex", name, currentPassword, …}` → `201 {account}`. For Claude the body carries one of: `profile` (a saved switch-user profile, MOVED in: the profile is deleted; the one the host's CLI is logged into moves in with its live login and the host's home becomes one more placement the server refreshes — `409 EACTIVE` only for a macOS Keychain login; `name` defaults to it), `credentials` (the text of a `.credentials.json`; Anthropic is asked whose account it is, `502 EUPSTREAM` when it cannot be reached), or `token` (a `claude setup-token` token). For Codex, `token` is an OpenAI API key or the text of an `auth.json`. `GET /api/accounts/profiles` lists the saved profiles `{profiles: [{name, label, email, subscriptionType, active}]}` (no token). Claude rows in `GET /api/accounts` carry `kind` (`token`|`login`), `email` and `refreshError`; a login's `expiresAt` is null and removes one with `DELETE /api/accounts/:name` `{tool, currentPassword}` → `{ok: true}`. No answer carries the token. `403 EREAUTH` wrong password (`429 ETOOMANY` after 10), `403 EADMIN` for a non-admin, `403 ELOCALMODE` in local mode, `400 EBADTOOL`/`EBADNAME`/`EBADTOKEN`, `409 EEXISTS`, `404 ENOACCOUNT`; each change is audited by account name.
|
||||
An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude"|"codex", name, currentPassword, …}` → `201 {account}`. For Claude the body carries one of: `profile` (a saved switch-user profile, MOVED in: the profile is deleted; the one the host's CLI is logged into moves in with its live login and the host's home becomes one more placement the server refreshes — `409 EACTIVE` only for a macOS Keychain login; `name` defaults to it), `credentials` (the text of a `.credentials.json`; Anthropic is asked whose account it is, `502 EUPSTREAM` when it cannot be reached), or `token` (a `claude setup-token` token). For Codex, `token` is an OpenAI API key or the text of an `auth.json`. `GET /api/accounts/profiles` lists the saved profiles `{profiles: [{name, label, email, subscriptionType, active}]}` (no token). Claude rows in `GET /api/accounts` carry `kind` (`token`|`login`), `email` and `refreshError`; a login's `expiresAt` is null and removes one with `DELETE /api/accounts/:name` `{tool, currentPassword}` → `{ok: true}`. No answer carries the token. `403 EREAUTH` wrong password (`429 ETOOMANY` after 10), `403 EADMIN` for a non-admin, `403 ELOCALMODE` in local mode, `400 EBADTOOL`/`EBADNAME`/`EBADTOKEN`, `409 EEXISTS`, `404 ENOACCOUNT`; each change is audited by account name. `PUT /api/accounts/pins` `{tool, userId, account, currentPassword}` → `{pin: {userId, username, account}}` switches that user's sessions of `tool` to one pool account (`account: null` = automatic again; `404 ENOUSER`/`ENOACCOUNT`); `switched` says what happened now — `switched` (a Claude login was written into their home: running panes take it at their next credentials read, no restart), `kept`, `own-login` (their own login, left alone), `expired`, `next-session` (a token, Codex, or no member home), with the same password step-up and audit (the user as `owner_id`). `PUT /api/accounts/:name/users` `{tool, userIds, currentPassword}` → `{added: [{userId, username, switched}], removed: [{userId, username}]}` is the Users page's Edit of one account: exactly those users run on it afterwards (a user on another account moves here, one left out goes back to the balancer; one transaction, each change audited; `400 EBADUSER`, `404 ENOUSER`/`ENOACCOUNT`). Removing an account drops its pins; deleting a user drops theirs.
|
||||
|
||||
**The account's own preferences and first-run setup** (`server/routes/me.js`; local mode is the one account `local`, server mode one row per account in `user_prefs`):
|
||||
|
||||
@@ -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 `<data dir>/avatars/u<id>.<ext>`; 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.
|
||||
|
||||
@@ -298,7 +298,7 @@ re-run.
|
||||
#### File Explorer on a home
|
||||
|
||||
```
|
||||
GET /api/homes { homes: [{ id: "me" | <username>, label, readOnly }] }
|
||||
GET /api/homes { homes: [{ id: "me" | <username>, label, readOnly, dir? }] }
|
||||
GET /api/homes/:who/fs/list|trash|zip|download as the workspace routes above
|
||||
POST /api/homes/:who/fs/mkdir|newfile|rename|move|copy|delete|upload|trash/restore|trash/purge
|
||||
```
|
||||
@@ -341,14 +341,41 @@ A copy goes home ↔ workspace
|
||||
one Linux account; each side is checked under its own rule. A home is never a
|
||||
workspace. The **Files** page opens on `?home=me` by default.
|
||||
|
||||
`me` alone carries `dir`, its absolute folder: the File Explorer's **New →
|
||||
Clone repository** (`client/src/components/lanes/CloneHereDialog.tsx`) clones
|
||||
into the folder it has open — of the user's own home or of a workspace's own
|
||||
folder, never a session worktree, an extra folder or another user's home —
|
||||
through `POST /api/lanes/clone` below with that folder as `parent`, so every
|
||||
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`);
|
||||
@@ -1006,6 +1033,9 @@ cannot collide on `index.lock`.
|
||||
the file resolves by removing it. `merge-commit` is `409 ECONFLICTS` while
|
||||
anything is unmerged, and it and `merge-abort` are `409 ENOMERGE` with no
|
||||
merge in progress. `merge-abort` needs `confirm: true` (`400 ECONFIRM`).
|
||||
- Any action on a host with no `git` binary (or none on the dashboard's PATH)
|
||||
is `503 EGITMISSING`: a missing dependency, not a bad request; the message
|
||||
carries the install command (like `ETMUXMISSING` for tmux).
|
||||
- `push` pushes `HEAD`, never a branch name from the request, so a workspace can
|
||||
only push the branch it is on.
|
||||
- A **path** in the body is never trusted as a path: `stage`, `unstage` and
|
||||
@@ -1590,12 +1620,12 @@ 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.
|
||||
- **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 `<data dir>/workspace-context/<lane id>/` 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 `<CLAUDE_HOME>/projects/<encoded cwd>/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`.
|
||||
|
||||
At spawn (`spawnRun`, both `POST /api/lanes/:id/start` and `POST /api/run`) the pane gets, from these rows: Claude — `--add-dir <folders…> [<context dir>]` first, then `--append-system-prompt-file=<data dir>/workspace-prompts/<lane id>.md`, a file rewritten at every spawn holding the workspace owner's own instructions (`PUT /api/me/instructions`) + the workspace's instructions + the context file list + the extra folders; Codex — one `--add-dir <dir>` per folder (and the context dir) and `-c developer_instructions=<same text as a TOML string>` (above 6 KB as the escaped TOML string, one line pointing at that file instead; nothing when that file could not be written). Any other tool gets none of it (`supported: false`). A folder or file that vanished is skipped.
|
||||
At spawn (`spawnRun`, both `POST /api/lanes/:id/start` and `POST /api/run`) the pane gets, from these rows: Claude — `--add-dir <folders…> [<context dir>]` first, then that workspace's instructions folder `<data dir>/workspace-prompts/<lane id>/` (a member's: `prompt-<lane id>/` in their handoff dir) as one more `--add-dir`, with `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1` in the pane's env so Claude reads the `CLAUDE.md` there as memory — never `--append-system-prompt-file`: Claude cannot carry a custom system prompt over a restart and refused `/tui` in such a pane. That `CLAUDE.md` is rewritten at every spawn and holds the workspace owner's own instructions (`PUT /api/me/instructions`) + the workspace's instructions + the context file list + the extra folders; Codex — one `--add-dir <dir>` per folder (and the context dir) and `-c developer_instructions=<same text as a TOML string>` (above 6 KB as the escaped TOML string, one line pointing at that file instead; nothing when that file could not be written). Any other tool gets none of it (`supported: false`). A folder or file that vanished is skipped.
|
||||
|
||||
#### Projects (workspaces grouped by repository)
|
||||
|
||||
@@ -2954,7 +2984,7 @@ POST /api/pets/featured/:id Download a FEATURED pet from codex-pets.ne
|
||||
DELETE /api/pets/:id Delete a pet (its folder under the data dir)
|
||||
```
|
||||
|
||||
The mascot (Sagi) can wear a pet: right-click it for **Hide pet**, **Size** (48–250 px), **Choose pet** (Sagi, the featured pets, your own — deletable), **Upload your own…**, **Import .codex-pet bundle…**, **Find more on codex-pets.net** and **Pet settings…**, which opens the pet block of **Settings → Appearance** (`/settings/appearance#pet`, `client/src/components/settings/PetSettings.tsx`; the old `/settings/pet` redirects there): show/hide, speech bubbles, size with a live preview, and the same choose / download / upload / import / delete as a grid. The menu and the page share `components/Sagi/usePets.ts`. Which pet is worn (with its display name, which the mascot goes by) and its size are viewer preferences in `localStorage` (`agenthub.pet`, `agenthub.pet.size`); a name the user types by clicking it in the mascot's panel is `agenthub.pet.names` (`{ [petId]: name }`, empty = the pet's own); the pets live on the server, one folder each under `<data dir>/pets/<id>/` with a `meta.json` AgentHub writes.
|
||||
The mascot (Sagi) can wear a pet: right-click it for **Hide pet**, **Size** (48–250 px), **Choose pet** (Sagi, the featured pets, your own — deletable), **Upload your own…**, **Import .codex-pet bundle…**, **Find more on codex-pets.net** and **Pet settings…**, which opens the pet block of **Settings → Appearance** (`/settings/appearance#pet`, `client/src/components/settings/PetSettings.tsx`; the old `/settings/pet` redirects there): show/hide, speech bubbles, size with a live preview, and the same choose / download / upload / import / delete as a grid. The menu and the page share `components/Sagi/usePets.ts`. Which pet is worn (with its display name, which the mascot goes by) and its size are viewer preferences in `localStorage` (`agenthub.pet`, `agenthub.pet.size`); a name the user types by clicking it in the mascot's chat window header is `agenthub.pet.names` (`{ [petId]: name }`, empty = the pet's own); the pets live on the server, one folder each under `<data dir>/pets/<id>/` with a `meta.json` AgentHub writes.
|
||||
|
||||
A **Codex pet** is the `.codex-pet.zip` format Codex Desktop uses (the bundles shared on codex-pets.net and in `mileson/codex-pets`): `pet.json` (`id`, `displayName`, `description`, `spritesheetPath`) and `spritesheet.webp`, a 1536×1872 atlas of 8 columns × 9 rows of 192×208 frames — idle, run right, run left, waving, jumping, failed, waiting, running, review, with 6/8/8/4/5/8/6/6/6 frames — or a v2 atlas (`spriteVersionNumber: 2`, 1536×2288, 11 rows) whose rows 9–10 add 16 gaze poses, 22.5° apart clockwise from straight up. The mascot plays the row of its mood (an agent working → running, waiting for you → waiting, an error → failed, a finished turn → jumping, a live session at rest → review, idle/asleep → idle), and on an event it plays a moment over the mood (a new session → waving, a finish or a pat → jumping, a new subagent → review, a session waiting on you → waiting, an error → failed); dragged it runs left or right with the drag (and along the glide to its edge), hovered it waves, and a v2 pet at idle turns toward the pointer (back to idle 2 s after the pointer rests). Frames play at Codex's own timings (`codex-rs/tui/src/pets/model.rs`: e.g. running 120 ms a frame, its last 220 ms); idle plays at the drawn pace (280/110/110/140/140/320 ms), a sleeping pet at Codex's 6× slower one. Waving, jumping, failed and review are moments — they play three times and settle into idle; running, waiting and the run rows loop. Only the two files are read, at the archive root or one folder deep; an entry's size is checked from the zip's directory before it is inflated; the sheet must be a WebP of exactly one of those sizes (`400 EBADPET` otherwise). An uploaded image is shown still and bobs; SVG is refused because an SVG served from the dashboard's origin runs script.
|
||||
|
||||
@@ -3057,6 +3087,16 @@ POST /api/settings/backup/run 202 — started (or already running); poll GE
|
||||
Settings → Data → **Daily backup** (`server/lib/db-backup.js`). Once a day, at `hour` (0–23, the server's local time, default 2), the database is copied to `dir` as `agenthub-YYYYMMDD-HHMMSS.db`: `VACUUM INTO` on a read-only connection in a worker thread, so the copy is a consistent snapshot (writes from another dashboard on the same database never restart it), compacted, and the event loop keeps serving while it runs (≈30 s for a 1.25 GB database). It is written as `<name>.partial` and renamed when complete. Beyond `keep` (1–365, default 7) the oldest are deleted — only regular files matching that exact name; nothing else in the folder, and no symlink, is ever touched. A day missed while the server was down runs at the next start; a failed run is retried the next day or by **Back up now**, never every minute. The three values persist to the settings file as `AGENTHUB_BACKUP_DIR` / `AGENTHUB_BACKUP_HOUR` / `AGENTHUB_BACKUP_KEEP`; one the environment set is `env_owned` (`409 EENVOWNED`). Writes are same-origin, and admin-only in server mode. `400 INVALID_PATH` for a relative, missing, non-directory or unwritable folder, `400 INVALID_INPUT` for an hour or count out of range, `400 ENOBACKUPDIR` for a run with no folder. The scheduler beat is `DASHBOARD_BACKUP_TICK_MS` (default 60000, `0` = off).
|
||||
|
||||
Restoring is manual, on purpose: `agenthub stop`, delete `dashboard.db-wal` and `dashboard.db-shm`, copy a backup over `dashboard.db` (its path is on Settings → System), `agenthub start`.
|
||||
|
||||
### Retention and Compact
|
||||
|
||||
```
|
||||
GET /api/settings/retention { days, max_days, env_owned: { days }, last_run, space: { auto_vacuum, page_size, page_count, freelist_pages, free_bytes }, db_size, compacting, last_compact }
|
||||
PUT /api/settings/retention { days } → the same state; 0 turns it off (the default)
|
||||
POST /api/settings/compact { confirm: true } → 202 — started (or already running); poll GET while `compacting`
|
||||
```
|
||||
|
||||
Settings → Data → **Session cleanup** (`server/lib/db-retention.js`). Automatic retention is OFF unless `days` (1–3650, `AGENTHUB_RETENTION_DAYS` in the settings file) is set; nothing is deleted by default. When set, the hourly maintenance (`DASHBOARD_DB_MAINTAIN_MS`) purges finished (completed/error/abandoned) sessions that started more than `days` ago — the same rule and the same deletion (`purgeSessionIds`: events, agents, token usage, the session) as `POST /api/settings/cleanup` with `purge_days` — 25 sessions per transaction with a yield between, at most 5000 per run. A new database is created with `auto_vacuum = INCREMENTAL`, an existing one up to 32 MB is converted by a VACUUM at startup; the same hourly run then returns free pages to the OS (`incremental_vacuum`, in 1024-page slices, at most 65536 pages per run) once more than 1024 are free. A larger, older database is converted only by **Compact**: a full VACUUM in a worker thread followed by a TRUNCATE checkpoint — it holds the write lock while it runs (tens of seconds on a GB-size file), so hooks wait meanwhile; it runs only on a click. In server mode both writes are an admin's (`/settings` is in `ADMIN_WRITE_PREFIXES`).
|
||||
---
|
||||
|
||||
## WebSocket API
|
||||
|
||||
+1
-1
@@ -108,7 +108,7 @@ The CLI talks to the local dashboard server — **API-backed commands require it
|
||||
| `agenthub server install-system [--dry-run\|--uninstall]` | **One Linux user per account** (S3). Run as the account that runs the dashboard, never as root: it runs `scripts/server/install-system.sh` through one `sudo`. That script creates the groups, `/srv/agenthub/users`, `/opt/agenthub`, the sudoers file, the member hooks and `hidepid`, and installs the system unit `agenthub.service`. The command then points the unit at this install, writes `AGENTHUB_USERS_ROOT`, and moves the dashboard from the `systemd --user` unit to the system one (panes stay). `--dry-run` prints the sudo command; `--uninstall` goes back to the user unit. See [SERVER-MODE.md](SERVER-MODE.md#one-linux-user-per-account) |
|
||||
| `agenthub server tools` | One Linux user per account: publish this install's scripts and every installed AI tool under `/opt/agenthub` (`tools/bin/<command>`), where every account's pane runs them. The server also does this at start and after each install or update. Prints each tool's shared copy, or why it could not be shared |
|
||||
| `agenthub server off` / `agenthub server status` | Back to local mode (accounts kept) / show the mode and account count |
|
||||
| `agenthub accounts list\|add <name>\|remove <name>` | The **Claude account pool**: new Claude sessions spread over these accounts (each a `claude setup-token` token, typed hidden or read from stdin). `list` shows live panes, $ and tokens in the last 5 h / 7 d, and quota. With `--tool codex` the same for the **Codex account pool**: `add <name> --tool codex --api-key` (key typed hidden or on stdin) or `--from <dir>` (a `codex login` home or its `auth.json`). See [SERVER-MODE.md](SERVER-MODE.md#claude-account-pool) |
|
||||
| `agenthub accounts list\|add <name>\|remove <name>` | The **Claude account pool**: new Claude sessions spread over these accounts (each a `claude setup-token` token, typed hidden or read from stdin). `list` shows live panes, $ and tokens in the last 5 h / 7 d, and quota. With `--tool codex` the same for the **Codex account pool**: `add <name> --tool codex --api-key` (key typed hidden or on stdin) or `--from <dir>` (a `codex login` home or its `auth.json`). `pin <account> <user>` / `unpin <user>` (add `--tool codex` for Codex) switches one user's sessions to that account (a Claude login goes into their home at once, running sessions follow without a restart) (server mode; `list` shows a `pinned` column). See [SERVER-MODE.md](SERVER-MODE.md#claude-account-pool) |
|
||||
| `agenthub users list\|add <name> [--role admin\|analyst\|member]\|passwd <name>\|role <name> <role>\|disable <name>\|enable <name>\|provision <name>\|--all\|assign <workspace id> <name>\|audit [--limit N]` | Server mode's accounts, written straight to the database. Once `install-system` ran, `add` first creates the Linux user `ah-<name>`; a name must then be lower case: a letter, then up to 28 letters, digits, `_` or `-`. `disable`/`enable` lock or unlock that Linux user too, and `provision` gives users made earlier their Linux user (idempotent); `assign` gives a workspace made before server mode an owner; `audit` lists admin/analyst reads of members' sessions. Inside a dashboard pane in server mode, every command acts as the workspace's owner through the pane's `AGENTHUB_PANE_TOKEN`. A password is typed hidden twice, or read from the first line of stdin |
|
||||
|
||||
### Interactive REPL
|
||||
|
||||
+2
-1
@@ -621,6 +621,7 @@ Server mode's accounts ([SERVER-MODE.md](SERVER-MODE.md), `server/lib/auth.js`).
|
||||
| --- | --- |
|
||||
| `users` | `id`, `username` (unique, case-insensitive), `role` (`admin`\|`analyst`\|`member`), `password_hash` (`scrypt$<salt>$<hash>`), `disabled`, `created_at` |
|
||||
| `auth_sessions` | `token_hash` (SHA-256 of the cookie token, PK), `user_id` → `users.id` (cascade), `created_at`, `expires_at` (30 days) |
|
||||
| `account_pins` | `user_id` → `users.id` (cascade), `tool` (`claude`\|`codex`), `account` (a pool account's name), `pinned_at`; PK (`user_id`, `tool`) — an admin's pin of a user to one pool account (`server/lib/account-pins.js`) |
|
||||
|
||||
`dashboard_runs.account` names the Claude pool account a run's pane was started on (`server/lib/claude-pool.js`; `NULL` = the machine's own login). The balancer sums each account's spend from the `token_usage_hourly` rows of those runs' sessions. The pool's tokens live in files under `<CLAUDE_HOME>/agenthub-accounts/`, never in the database.
|
||||
|
||||
@@ -915,7 +916,7 @@ worker's own writable connection. SQLite's automatic checkpoint runs inside whic
|
||||
per commit under load. A failed checkpoint switches the automatic one back on. A second
|
||||
process on the same file (another dashboard, the CLI) keeps its own setting.
|
||||
|
||||
**Upkeep.** `maintainDatabase()` (exported by `server/db.js`) runs hourly from `server/index.js` (`DASHBOARD_DB_MAINTAIN_MS`, `0` = off): a `wal_checkpoint(PASSIVE)` — it never waits on a reader, such as the other dashboard — after which the next write shrinks the WAL to `journal_size_limit`, and `PRAGMA optimize`. Nothing runs `VACUUM`: it rewrites the whole file under an exclusive lock, so space freed by a cleanup is reused for new rows instead of returned to the disk.
|
||||
**Upkeep.** `maintainDatabase()` (exported by `server/db.js`) runs hourly from `server/index.js` (`DASHBOARD_DB_MAINTAIN_MS`, `0` = off): a `wal_checkpoint(PASSIVE)` — it never waits on a reader, such as the other dashboard — after which the next write shrinks the WAL to `journal_size_limit`, and `PRAGMA optimize`. Then `runScheduledMaintenance` (`server/lib/db-retention.js`) applies the OPT-IN retention (`AGENTHUB_RETENTION_DAYS`, off by default: finished sessions older than N days, purged 25 per transaction like the manual cleanup) and, when the database is in `auto_vacuum = INCREMENTAL` and more than 1024 pages are free, a bounded `incremental_vacuum` (1024-page slices, ≤ 65536 pages per run) that returns them to the disk. A new database is created INCREMENTAL; an existing one up to 32 MB is converted by one `VACUUM` at startup (`convertAutoVacuum`), a larger one only by Settings → Data → **Compact** (`POST /api/settings/compact`: a full `VACUUM` in a worker thread, write lock held while it runs). No automatic path runs a full `VACUUM` on a large file. See docs/API.md § Retention and Compact.
|
||||
|
||||
**Growth.** `events.data` is the hook payload TRIMMED by `trim_event_data(?)` (`server/lib/event-payload.js`, a SQL function registered on the connection in `server/db.js`, used by every event `INSERT` and by the duplicate guard): Edit/MultiEdit/NotebookEdit/Write drop `tool_response.originalFile`/`oldString`/`newString` (Write also `content`), Read drops `tool_response.file.content`/`base64`, and any string inside `tool_input`/`tool_response` over 8 KB keeps its first 6 KB and last 2 KB; `structuredPatch` and every field outside those two are kept, and `_trimmed` lists what was dropped. The full text stays in the session transcript and AgentHub's snapshot of it under `<data dir>/transcripts/`, which the Conversation tab reads. Rows stored before this are trimmed once in the background (`sweepEventPayloads`, 200 rows per transaction, `user_version` 4 after marker 3); measured on a 1.2 GB install it took `events.data` from 913 MB to ~400 MB. Freed pages are reused, not returned to the disk (no `VACUUM`). No ROW is pruned automatically. The only deletions are the user's: Settings → clear data / cleanup, a workspace's purge, `scripts/clear-data.js`. Clear data runs in ONE transaction (foreign keys off around it, so workflows are deleted explicitly), cleanup deletes in 500-id chunks inside one transaction. `GET /api/settings/export` streams `events` in keyset pages of 500 instead of loading the table.
|
||||
|
||||
|
||||
+4
-2
@@ -256,9 +256,11 @@ DASHBOARD_DB_PATH=/var/lib/agenthub/dashboard.db
|
||||
DASHBOARD_WORKFLOW_POLL_MS=12000 # Workflow-tool run journals
|
||||
DASHBOARD_CODEX_POLL_MS=15000 # Codex rollout journals → codex lane running/idle + Codex sessions/tokens
|
||||
CODEX_HOME= # where Codex keeps sessions/ (default ~/.codex)
|
||||
DASHBOARD_ANTIGRAVITY_POLL_MS=15000 # agy conversation files → Antigravity sessions/tokens
|
||||
DASHBOARD_ANTIGRAVITY_POLL_MS=15000 # agy conversation files → Antigravity sessions/tokens (60 s while idle)
|
||||
AGENTHUB_GEMINI_DIR= # where agy keeps its data (default ~/.gemini)
|
||||
DASHBOARD_CURSOR_POLL_MS=15000 # Cursor agent transcripts → Cursor sessions (no tokens)
|
||||
DASHBOARD_CURSOR_POLL_MS=15000 # Cursor agent transcripts → Cursor sessions (no tokens; 60 s while idle)
|
||||
DASHBOARD_OMP_POLL_MS=15000 # omp session journals → Oh My Pi sessions/tokens/subagents (60 s while idle)
|
||||
DASHBOARD_OPENCODE_POLL_MS=15000 # opencode.db → OpenCode sessions/tokens/subagents (60 s while idle)
|
||||
AGENTHUB_CURSOR_DIR= # where Cursor keeps its data (default ~/.cursor)
|
||||
AGENTHUB_AGY_USAGE= # 0 never runs `agy --print /usage` for the Usage panel's Antigravity quota (default on; running agy may update it)
|
||||
|
||||
|
||||
+3
-1
@@ -491,7 +491,8 @@ graph TB
|
||||
> entry whose PID is no longer alive** and POSTs the hook payload to one
|
||||
> port per **unique SQLite data directory** (lowest port wins when two
|
||||
> instances share `~/.claude/agenthub`). The other instance re-broadcasts
|
||||
> the workspaces those hooks moved within `DASHBOARD_LANE_SYNC_MS` (2 s), so its
|
||||
> the workspaces those hooks moved within `DASHBOARD_LANE_SYNC_MS` (2 s; a server
|
||||
> alone on its data dir only looks for such a peer every 30 s), so its
|
||||
> browsers see a workspace's status without a refresh.
|
||||
> 3. If neither yields a target, the handler falls back to `4820`.
|
||||
>
|
||||
@@ -589,6 +590,7 @@ Claude Code waits only for the handler process: Node start-up, reading stdin, se
|
||||
|
||||
- **Fire-and-forget POST** — no wait for the dashboard's reply.
|
||||
- **Incremental transcript reads** — `TranscriptCache` reads only the bytes appended since the last hook.
|
||||
- **Only new transcript entries are written** — a per-transcript mark (`transcriptMarks` in `server/routes/hooks.js`, set once the hook's transaction commits) skips the token, compaction, API-error and turn-duration writes when the transcript did not change, and walks only the entries after the last one handled when it grew.
|
||||
- **One POST per database, not per server** — servers sharing a database get each event once (`resolveHookIngestPorts`).
|
||||
- **Heavy reads off the event loop** — aggregates run in a worker (`server/lib/read-pool.js`), so a slow page never delays ingest.
|
||||
|
||||
|
||||
+3
-3
File diff suppressed because one or more lines are too long
+17
-1
@@ -115,7 +115,7 @@ Internal build — all rights reserved.
|
||||
|
||||
| Need | Version | Required for |
|
||||
|---|---|---|
|
||||
| **Node.js** | >= 20 (>= 22.5 for a plugin install) | Everything. Both suites are verified green on **24** and **26** |
|
||||
| **Node.js** | >= 22.5 (`node:sqlite`; Node 20 is end-of-life) | Everything. Both suites are verified green on **24** and **26** |
|
||||
| **npm** | 9+ | Ships with Node |
|
||||
| **git** | any | Cloning, and every workspace operation (worktrees, branch/status reads) |
|
||||
| **tmux** | 3.x | The browser run console, `agenthub workspace shell`, typing `/reload-plugins` into a live pane |
|
||||
@@ -302,6 +302,22 @@ default 15000) and appear in Sessions with a **Cursor CLI** badge. Cursor writes
|
||||
no token counts or model ids there, so a Cursor session shows no tokens and no
|
||||
cost.
|
||||
|
||||
**Oh My Pi (`omp`)** sessions are read from omp's session journals
|
||||
(`~/.omp/agent/sessions/*/<timestamp>_<id>.jsonl`, every `DASHBOARD_OMP_POLL_MS`
|
||||
ms, default 15000) — each member's own `~/.omp` too on a multi-user server — and
|
||||
appear in Sessions with an **Oh My Pi (OMP)** badge, with their tokens and cost
|
||||
per model and one agent per subagent the `task` tool spawned (its journal sits in
|
||||
the folder named like the session's). An omp session can be resumed by id from
|
||||
its workspace (`omp --resume <id>`), like a Claude or Codex one.
|
||||
|
||||
**OpenCode** sessions are read from its SQLite store
|
||||
(`~/.local/share/opencode/opencode.db`, every `DASHBOARD_OPENCODE_POLL_MS` ms),
|
||||
with their tokens and cost per model and one agent per subagent, and are
|
||||
resumable by id (`opencode --session <id>`). **Grok Build** runs the Claude hooks in
|
||||
`~/.claude/settings.json` itself, so its sessions arrive through AgentHub's own
|
||||
hooks, recorded as Grok sessions (live status, no tokens); they resume with
|
||||
`grok --resume <id>`. Cursor sessions resume by id too (`--resume <id>`).
|
||||
|
||||
**Pipelines are dormant.** AgentHub ships no pipeline template, so a workspace has no
|
||||
stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see
|
||||
[`docs/LANES.md`](LANES.md#custom-pipeline-templates)); until then
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@ Internal build — all rights reserved.
|
||||
|
||||
| Cần | Phiên bản | Dùng cho |
|
||||
|---|---|---|
|
||||
| **Node.js** | >= 20 (>= 22.5 nếu cài qua plugin) | Tất cả. Cả hai test suite verify xanh trên Node **24** và **26** |
|
||||
| **Node.js** | >= 22.5 (`node:sqlite`; Node 20 đã hết hạn hỗ trợ) | Tất cả. Cả hai test suite verify xanh trên Node **24** và **26** |
|
||||
| **npm** | 9+ | Đi kèm Node |
|
||||
| **git** | bất kỳ | Clone repo, và mọi thao tác workspace (worktree, đọc branch/status) |
|
||||
| **tmux** | 3.x | Run console trên trình duyệt, `agenthub workspace shell`, gõ `/reload-plugins` vào pane đang sống |
|
||||
|
||||
+21
-10
@@ -61,13 +61,13 @@ claude --plugin-dir plugins/agenthub-analytics
|
||||
## Prerequisites
|
||||
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated
|
||||
- Node **>= 22.5** when installing via the `agenthub` plugin. A plugin install has no
|
||||
native `better-sqlite3`, so the server stores data through `node:sqlite`, which
|
||||
landed in 22.5. The bootstrap refuses with one line on anything older instead
|
||||
of letting the server crash. (A checkout install still works on Node >= 20.)
|
||||
- Node **>= 22.5**. A plugin install has no native `better-sqlite3`, so the
|
||||
server stores data through `node:sqlite`, which landed in 22.5. The bootstrap
|
||||
refuses on anything older instead of letting the server crash, and says so in
|
||||
the session (below). A checkout needs the same (`engines` in `package.json`).
|
||||
- The Agent Monitor dashboard reachable at `http://localhost:4820` — the `agenthub`
|
||||
plugin starts it for you; a checkout starts it with `npm start` (see
|
||||
[SETUP.md](../SETUP.md))
|
||||
[INSTALL.md](../INSTALL.md))
|
||||
|
||||
Skills and commands are invoked as `/agenthub-<plugin>:<name>`. Agents are dispatched automatically by Claude Code (or named explicitly).
|
||||
|
||||
@@ -124,12 +124,12 @@ waits on an install:
|
||||
|
||||
1. **Fast path** — recorded state matches this plugin build and a server is live → exit. "Live" means a discovery entry whose pid answers AND whose `startedAt` is after this boot (60 s margin): an entry left over from before a reboot names a pid the OS may have handed to an unrelated process, and without systemd (macOS) nothing else clears it.
|
||||
2. **Superseded check** — if Claude Code records a DIFFERENT build of this plugin as installed, this one logs a line and does nothing else (see [A retired build never acts](#a-retired-build-never-acts)).
|
||||
3. **Node gate** — refuse below 22.5 with one line (see Prerequisites).
|
||||
3. **Node gate** — refuse below 22.5 with one line (see Prerequisites). Also checked by the foreground pass itself (it costs nothing), which then spawns no worker and tells the session at once.
|
||||
4. **Lock** — atomic `mkdir` lock holding the PID; reclaimed when the owner is dead or the lock is older than 10 minutes, so two sessions cannot race the install or spawn two servers.
|
||||
5. **Install** — `npm install --omit=dev --ignore-scripts` into the runtime dir, run as the npm bundled with the node running the bootstrap (`npmCommand`: its `npm-cli.js` beside `process.execPath`, no shell, that node's directory first on PATH; `npm` on PATH only when that node ships none) — the update chain runs with the dashboard server's environment, whose PATH need not reach `npm.cmd` on Windows. The UI build below and the doctor's npm row use the same npm (never into the plugin cache, which is garbage-collected and replaced on every update). `--ignore-scripts` keeps the root `postinstall` from pulling the whole Vite client toolchain.
|
||||
5. **Install** — only when the dependencies changed (`depsHash` covers what npm installs — the dependency lists and the lockfile's package tree — never the release's own `version`, so a plain version bump reinstalls nothing): `npm install --omit=dev --ignore-scripts` into the runtime dir, output in `runtime/npm-install.log`, run as the npm bundled with the node running the bootstrap (`npmCommand`: its `npm-cli.js` beside `process.execPath`, no shell, that node's directory first on PATH; `npm` on PATH only when that node ships none) — the update chain runs with the dashboard server's environment, whose PATH need not reach `npm.cmd` on Windows. The UI build below and the doctor's npm row use the same npm (never into the plugin cache, which is garbage-collected and replaced on every update). `--ignore-scripts` keeps the root `postinstall` from pulling the whole Vite client toolchain.
|
||||
6. **Legacy hook cleanup** — see above.
|
||||
7. **CLI** — write the `~/.local/bin/agenthub` launcher, never clobbering a `agenthub` the bootstrap did not write. It pins the node that ran the bootstrap and falls back to `node` on `PATH` when that binary is gone (a Homebrew Cellar or nvm path deleted by an upgrade).
|
||||
8. **UI build** — `client/` is copied into the runtime dir and built there (`npm ci --include=dev --ignore-scripts`, falling back to `npm install` with the same flags, then `tsc -b` and `vite build` run by the bootstrap's own `node` — the client's `build` script without `npm run`), landing in `runtime/client-dist` — the same directory the server serves from. Nothing in it looks `node` up on PATH: on Windows an install script or a `.bin/*.cmd` shim runs through cmd.exe, and the server's PATH may have no `node` (esbuild's postinstall failed there with "'node' is not recognized" and the update kept the old UI); esbuild, the only dependency with an install script, finds its binary without it. `--include=dev` is required, not tidy: the entire build toolchain (`vite`, `typescript`, `@vitejs/plugin-react`, `tailwindcss`, `@types/*`) is a devDependency, and npm reads an inherited `NODE_ENV=production` as `--omit=dev`. Every bootstrap descended from the running server carries that env — the self-update chain, and any `SessionStart` fired by a `claude` inside a tmux pane the server started — so without the flag the install drops `vite` and the build dies on missing types. Skipped when a bundle for the current version already exists; a failure here does not fail the bootstrap (the API still works, and `/agenthub-open` can retry) but does mean `/run` 404s until it's fixed — and when a bundle from the PREVIOUS version is still on disk the failure is quieter still: the dashboard keeps serving the old UI against the new API, visible only as the Settings page's `UI build vX (rebuild client to match)` line.
|
||||
8. **UI build** — `client/` is copied into the runtime dir and built there (`npm ci --include=dev --ignore-scripts`, falling back to `npm install` with the same flags — skipped while `client/package.json` and its lockfile are unchanged, because the installed tree in `runtime/client-src/node_modules` is kept with a `.agenthub-deps` stamp; a failed build drops the stamp so the next one installs from clean — then `vite build` run by the bootstrap's own `node`: the vite half of the client's `build` script without `npm run`. The `tsc -b` half is the release's job, not every user's: it emits nothing the bundle needs and cost ~25 s and ~900 MB per update), landing in `runtime/client-dist` — the same directory the server serves from. Nothing in it looks `node` up on PATH: on Windows an install script or a `.bin/*.cmd` shim runs through cmd.exe, and the server's PATH may have no `node` (esbuild's postinstall failed there with "'node' is not recognized" and the update kept the old UI); esbuild, the only dependency with an install script, finds its binary without it. `--include=dev` is required, not tidy: the entire build toolchain (`vite`, `typescript`, `@vitejs/plugin-react`, `tailwindcss`, `@types/*`) is a devDependency, and npm reads an inherited `NODE_ENV=production` as `--omit=dev`. Every bootstrap descended from the running server carries that env — the self-update chain, and any `SessionStart` fired by a `claude` inside a tmux pane the server started — so without the flag the install drops `vite` and the build dies on missing types. Skipped when a bundle for the current version already exists (`clientBuild` in `state.json`, recorded only after a build that did not fail, so the next session retries a failed one); a failure here does not fail the bootstrap (the API still works, and `/agenthub-open` can retry) but does mean `/run` 404s until it's fixed — and when a bundle from the PREVIOUS version is still on disk the failure is quieter still: the dashboard keeps serving the old UI against the new API, visible only as the Settings page's `UI build vX (rebuild client to match)` line.
|
||||
9. **Server** — when the user has turned **Start AgentHub automatically** on
|
||||
(Settings → System; OFF by default), install/refresh the
|
||||
`agenthub.service` unit on Linux — on macOS with a GUI login, the
|
||||
@@ -149,10 +149,21 @@ waits on an install:
|
||||
The first run takes a few minutes — most of it is the UI build, which is what
|
||||
makes client-only routes (`http://localhost:4820/run`, and every other page)
|
||||
work the moment `claude` starts, with no manual `/agenthub-open` step. Progress
|
||||
goes to `~/.claude/agenthub/runtime/bootstrap.log`; the UI build's own
|
||||
output goes to `client-build.log` next to it; the server's own output goes to
|
||||
goes to `~/.claude/agenthub/runtime/bootstrap.log`; npm's own output goes to
|
||||
`npm-install.log` and the UI build's to `client-build.log` next to it; the server's own output goes to
|
||||
`server.log`.
|
||||
|
||||
**A failure is reported once, in the session.** The worker's output is
|
||||
discarded, so a Node that is too old, a failed npm install or a failed UI build
|
||||
is recorded in `runtime/last-failure.json` (kind, message, log file). The NEXT
|
||||
foreground pass — its stdout is what Claude Code reads — prints it as a
|
||||
SessionStart `{"systemMessage": "AgentHub: … — details in <log>. Run
|
||||
/agenthub-doctor …"}` and marks it reported, so the same failure is not repeated
|
||||
every session; a different failure is reported again, and a bootstrap that
|
||||
succeeds removes the file. The steady state prints nothing, and the report is a
|
||||
single small read (plus one write, only when there is something to show) — the
|
||||
fast path still spawns nothing.
|
||||
|
||||
### A retired build never acts
|
||||
|
||||
`claude plugin update` unpacks the new version into a **new** cache directory
|
||||
|
||||
+6
-7
@@ -9,8 +9,7 @@ Comprehensive documentation for the Agent Dashboard project.
|
||||
- [Architecture Overview](../ARCHITECTURE.md) - System design and technical reference
|
||||
- [I18N Architecture](./I18N.md) - Internationalization architecture and usage guide
|
||||
- [CLI Reference](./CLI.md) - The `agenthub` terminal CLI: every command, discovery, safety model
|
||||
- [Setup Guide](../SETUP.md) - Installation and configuration
|
||||
- [Installation](../INSTALL.md) - Detailed installation instructions
|
||||
- [Installation & setup](../INSTALL.md) - Installation, configuration and troubleshooting
|
||||
|
||||
---
|
||||
|
||||
@@ -20,7 +19,7 @@ Comprehensive documentation for the Agent Dashboard project.
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
Start[Start Here] --> Setup[SETUP.md<br/>Installation & Config]
|
||||
Start[Start Here] --> Setup[INSTALL.md<br/>Installation & Config]
|
||||
Start --> Architecture[ARCHITECTURE.md<br/>System Design]
|
||||
|
||||
Setup --> Client[Client README<br/>React UI docs]
|
||||
@@ -62,7 +61,7 @@ graph TB
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[New to Project] --> B[Read SETUP.md]
|
||||
A[New to Project] --> B[Read INSTALL.md]
|
||||
B --> C[Install Dependencies]
|
||||
C --> D[Run npm run dev]
|
||||
D --> E[Open localhost:5173]
|
||||
@@ -75,7 +74,7 @@ graph LR
|
||||
|
||||
1. Install the system packages — `node` (20+), `git`, and `tmux` (the run
|
||||
console has no fallback without it): [INSTALL.md → Step 0](../INSTALL.md#step-0--system-packages)
|
||||
2. Read [SETUP.md](../SETUP.md)
|
||||
2. Read the rest of [INSTALL.md](../INSTALL.md)
|
||||
3. Run `npm run setup`
|
||||
4. Run `npm run dev`
|
||||
5. Open browser to `http://localhost:5173`
|
||||
@@ -287,7 +286,7 @@ graph LR
|
||||
**Documentation:**
|
||||
- [Hook System Guide](./HOOKS.md)
|
||||
- [Hook Processing](../server/README.md#hook-processing)
|
||||
- [Installation](../SETUP.md#install-hooks)
|
||||
- [Installation](../INSTALL.md#hook-auto-installation)
|
||||
|
||||
---
|
||||
|
||||
@@ -441,4 +440,4 @@ This documentation covers:
|
||||
- ✅ **Internationalization** - Language resources, switching flow, locale formatting, rollout checklist
|
||||
- ✅ **Development guides** - Setup, testing, contributing
|
||||
|
||||
**Start with:** [SETUP.md](../SETUP.md) for installation, then explore specific areas based on your role.
|
||||
**Start with:** [INSTALL.md](../INSTALL.md) for installation, then explore specific areas based on your role.
|
||||
|
||||
@@ -355,6 +355,7 @@ claude setup-token
|
||||
agenthub accounts add team-1
|
||||
agenthub accounts list # live panes, $ and tokens in the last 5 h / 7 d, quota, state
|
||||
agenthub accounts remove team-1
|
||||
agenthub accounts pin team-1 mia # switch mia to team-1 (unpin mia = automatic again)
|
||||
```
|
||||
|
||||
In server mode an admin can do the same on the **Users** page, in the
|
||||
@@ -423,6 +424,28 @@ How a new session picks its account (`server/lib/claude-pool.js`):
|
||||
pane still needs that member's own `/login`.
|
||||
- A token more than a year old is skipped.
|
||||
- A resumed session picks an account again, like a new one.
|
||||
- **Switching a user to one account.** An admin can take one user off the
|
||||
ranking: the pencil (**Edit**) on that account's row in the Account pool
|
||||
card — tick the users who run on it, untick to send one back to automatic (or
|
||||
`agenthub accounts pin <account> <user> [--tool codex]`, `unpin <user>`; their
|
||||
own password again, each change audited). A user ticked on one account
|
||||
moves off any other. Every session in a
|
||||
workspace that user OWNS then runs on that account, even when it is full;
|
||||
other users are not affected, and the account is not reserved for them.
|
||||
- **A Claude login switches at once, without a restart.** The server first
|
||||
copies every home's newer pair back into the pool (so the login leaving
|
||||
the home keeps its only live refresh token), then writes the new login
|
||||
into the user's `~/.claude/.credentials.json` as them. New sessions start
|
||||
on it; sessions already running keep their process and move to it the
|
||||
next time Claude Code re-reads its credentials (it does so before it
|
||||
refreshes its access token, so at the latest when that token expires).
|
||||
A login the user made themselves (`/login`) is never written over — the
|
||||
card says so. Their sessions are booked to the account they started on.
|
||||
- A setup-token, a Codex account, or a host pane gets the account at its
|
||||
next spawn; running ones keep theirs.
|
||||
- An expired account falls back to the ranking. Removing the account drops
|
||||
the switch; deleting the user drops theirs. Server mode only
|
||||
(`server/lib/account-pins.js`).
|
||||
- Quota is read with each account's own token, in the background, at most every
|
||||
5 minutes. If the usage endpoint refuses a setup-token, the state column shows
|
||||
`no quota (…)` and ranking uses spend alone.
|
||||
@@ -447,6 +470,7 @@ CODEX_HOME=$(mktemp -d) codex login
|
||||
agenthub accounts add team-chatgpt --tool codex --from /tmp/tmp.XXXX
|
||||
agenthub accounts list --tool codex
|
||||
agenthub accounts remove team-key --tool codex
|
||||
agenthub accounts pin team-key mia --tool codex # pins work as for Claude
|
||||
```
|
||||
|
||||
- **API key**: any number of panes at once. The pane gets the key through
|
||||
|
||||
@@ -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.
|
||||
|
||||
+993
-2
File diff suppressed because it is too large
Load Diff
Generated
+20
-984
File diff suppressed because it is too large
Load Diff
+3
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.7",
|
||||
"version": "2.8.12",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
@@ -75,8 +75,8 @@
|
||||
"adm-zip": "^0.6.1",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.2",
|
||||
"js-yaml": "^4.1.0",
|
||||
"multer": "^2.0.0",
|
||||
"redoc": "^2.5.3",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"systray2": "^2.1.4",
|
||||
"tar": "^7.4.3",
|
||||
@@ -89,10 +89,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.2",
|
||||
"js-yaml": "^4.1.0",
|
||||
"prettier": "^3.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"node": ">=22.5.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1937,6 +1937,47 @@ async function ingestWorkflowsForBatch(dbModule, sessions) {
|
||||
}
|
||||
}
|
||||
|
||||
// Session sync: a session counts as fed LIVE by hooks while it is active, its
|
||||
// newest event came from a hook (not this importer), and a hook touched it
|
||||
// within this window. Such a transcript is left to the hooks until it goes
|
||||
// quiet. DASHBOARD_SYNC_LIVE_HOOK_MS=0 turns the deferral off.
|
||||
const LIVE_HOOK_MS = (() => {
|
||||
const n = Number(process.env.DASHBOARD_SYNC_LIVE_HOOK_MS ?? 5 * 60_000);
|
||||
return Number.isFinite(n) && n >= 0 ? n : 5 * 60_000;
|
||||
})();
|
||||
// mtimeCache value for a deferred file: present (so the fs watcher ignores its
|
||||
// appends) yet never equal to a real mtime (so every sweep re-checks it).
|
||||
const DEFERRED_MTIME = -1;
|
||||
const IMPORTED_DATA_RE = /"imported"\s*:\s*true/;
|
||||
const latestEventStmts = new WeakMap();
|
||||
|
||||
function rowIsImported(row) {
|
||||
try {
|
||||
return !!(row.metadata && JSON.parse(row.metadata).imported);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isHookLive(dbModule, row) {
|
||||
if (!(LIVE_HOOK_MS > 0) || row.status !== "active") return false;
|
||||
const touched = Date.parse(row.updated_at);
|
||||
if (!Number.isFinite(touched) || Date.now() - touched > LIVE_HOOK_MS) return false;
|
||||
try {
|
||||
let stmt = latestEventStmts.get(dbModule.db);
|
||||
if (!stmt) {
|
||||
stmt = dbModule.db.prepare(
|
||||
"SELECT data FROM events WHERE session_id = ? ORDER BY id DESC LIMIT 1"
|
||||
);
|
||||
latestEventStmts.set(dbModule.db, stmt);
|
||||
}
|
||||
const ev = stmt.get(row.id);
|
||||
return !!ev && !IMPORTED_DATA_RE.test(ev.data || "");
|
||||
} catch {
|
||||
return false; // when in doubt, sync as before
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremental, change-driven sync of the default `~/.claude/projects` directory.
|
||||
*
|
||||
@@ -2024,6 +2065,25 @@ async function syncDefaultProjects(dbModule, options = {}) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// A session hooks are feeding RIGHT NOW: its transcript keeps growing
|
||||
// between hooks, so `mtime > updated_at` on nearly every sweep, and a
|
||||
// full re-parse + snapshot copy here bought nothing the hooks do not
|
||||
// already record. Leave it until it goes quiet (or ends): the sentinel
|
||||
// keeps the watcher ignoring its appends, and never equals a real
|
||||
// mtime, so the first sweep after the hooks stop catches it up.
|
||||
if (existingRow && isHookLive(dbModule, existingRow)) {
|
||||
mtimeCache.set(sourcePath, DEFERRED_MTIME);
|
||||
continue;
|
||||
}
|
||||
// A session a hook created (no `imported` flag): importSession() skips
|
||||
// it outright, so parsing the whole transcript + subagents was pure
|
||||
// waste — only the durable snapshot copy still matters.
|
||||
if (existingRow && !rowIsImported(existingRow)) {
|
||||
mtimeCache.set(sourcePath, mtime);
|
||||
if (require("../server/lib/run-as").sameOwner(sourcePath, existingRow.cwd))
|
||||
await snapshotTranscript(sourcePath, sessionId);
|
||||
continue;
|
||||
}
|
||||
const existed = !!existingRow;
|
||||
const session = await parseSessionForImport(projPath, sourcePath);
|
||||
// Record the mtime even when the file yields no session, so we don't
|
||||
|
||||
+171
-25
@@ -138,14 +138,38 @@ function serverIsLive() {
|
||||
return livePids().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* What npm installs from a manifest: its dependency lists, or the lockfile's
|
||||
* package tree — never the release's own `version`, which both files carry.
|
||||
* Hashing the whole files reinstalled every dependency on each update (minutes
|
||||
* on a slow link) when the bump changed nothing npm would fetch.
|
||||
*/
|
||||
function installedPart(file, text) {
|
||||
const json = JSON.parse(text);
|
||||
if (file === "package.json") {
|
||||
const { dependencies, optionalDependencies, overrides } = json;
|
||||
return JSON.stringify({ dependencies, optionalDependencies, overrides });
|
||||
}
|
||||
const { "": own, ...packages } = json.packages || {};
|
||||
const { version: _version, ...rest } = own || {};
|
||||
return JSON.stringify({ own: rest, packages });
|
||||
}
|
||||
|
||||
/** Hash of the dependency manifest — changes mean the runtime tree must be reinstalled. */
|
||||
function depsHash(root = PLUGIN_ROOT) {
|
||||
const h = crypto.createHash("sha256");
|
||||
for (const f of ["package.json", "package-lock.json"]) {
|
||||
let text;
|
||||
try {
|
||||
h.update(fs.readFileSync(path.join(root, f)));
|
||||
text = fs.readFileSync(path.join(root, f), "utf8");
|
||||
} catch {
|
||||
h.update(f); // absent counts as its own state
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
h.update(installedPart(f, text));
|
||||
} catch {
|
||||
h.update(text); // not JSON npm could read either: the bytes decide
|
||||
}
|
||||
}
|
||||
return h.digest("hex").slice(0, 16);
|
||||
@@ -196,6 +220,81 @@ function nodeVersionOk(version = process.versions.node) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- last failure report */
|
||||
|
||||
/**
|
||||
* The worker runs detached with its output discarded, so a failed install, a
|
||||
* Node that is too old or a failed UI build used to live only in a log nobody
|
||||
* opens — and every session silently retried. The last failure is kept in one
|
||||
* small file; the NEXT foreground pass (which Claude Code reads) prints it once
|
||||
* as a SessionStart `systemMessage`, and a bootstrap that succeeds removes it.
|
||||
*/
|
||||
function failurePath(rt = runtimeDir()) {
|
||||
return path.join(rt, "last-failure.json");
|
||||
}
|
||||
|
||||
function readFailure(rt = runtimeDir()) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(failurePath(rt), "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Record a failure; the same kind again keeps its `reported` flag (once per failure). */
|
||||
function recordFailure(rt, kind, message, logFile) {
|
||||
const prev = readFailure(rt);
|
||||
const same = prev && prev.kind === kind && prev.message === message;
|
||||
const rec = {
|
||||
kind,
|
||||
message,
|
||||
log: logFile,
|
||||
at: new Date().toISOString(),
|
||||
reported: Boolean(same && prev.reported),
|
||||
};
|
||||
try {
|
||||
fs.mkdirSync(rt, { recursive: true });
|
||||
fs.writeFileSync(failurePath(rt), JSON.stringify(rec, null, 2) + "\n", "utf8");
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
|
||||
function clearFailure(rt = runtimeDir()) {
|
||||
try {
|
||||
fs.rmSync(failurePath(rt), { force: true });
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The SessionStart output for an unreported failure, or null. Marks it
|
||||
* reported, so a failure is shown once — not on every session until fixed.
|
||||
*/
|
||||
function takeFailureReport(rt = runtimeDir()) {
|
||||
const rec = readFailure(rt);
|
||||
if (!rec || rec.reported) return null;
|
||||
try {
|
||||
fs.writeFileSync(failurePath(rt), JSON.stringify({ ...rec, reported: true }, null, 2) + "\n");
|
||||
} catch {
|
||||
return null; // could not mark it: stay quiet rather than repeat forever
|
||||
}
|
||||
return {
|
||||
systemMessage:
|
||||
`AgentHub: ${rec.message}` +
|
||||
(rec.log ? ` — details in ${rec.log}` : "") +
|
||||
". Run /agenthub-doctor for a diagnosis.",
|
||||
};
|
||||
}
|
||||
|
||||
function nodeTooOldMessage(version = process.versions.node) {
|
||||
return (
|
||||
`Node ${version} is too old — AgentHub needs Node >= ${MIN_NODE.join(".")} ` +
|
||||
`(the server stores data through node:sqlite). Upgrade Node, then start a new session`
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- lock */
|
||||
|
||||
function lockPath(rt = runtimeDir()) {
|
||||
@@ -422,13 +521,22 @@ function installDeps(rt = runtimeDir(), root = PLUGIN_ROOT) {
|
||||
if (fs.existsSync(src)) fs.copyFileSync(src, path.join(rt, f));
|
||||
}
|
||||
const npm = npmCommand(["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"]);
|
||||
const res = spawnSync(npm.file, npm.args, {
|
||||
cwd: rt,
|
||||
stdio: "inherit",
|
||||
env: npm.env,
|
||||
shell: npm.shell,
|
||||
});
|
||||
return res.status === 0;
|
||||
// npm's output goes to a log: the worker's own stdout is discarded, so
|
||||
// inheriting it threw away the one explanation of a failed install.
|
||||
const logPath = path.join(rt, "npm-install.log");
|
||||
require("./log-rotate").rotateIfLarge(logPath);
|
||||
const logFd = fs.openSync(logPath, "a");
|
||||
try {
|
||||
const res = spawnSync(npm.file, npm.args, {
|
||||
cwd: rt,
|
||||
stdio: ["ignore", logFd, logFd],
|
||||
env: npm.env,
|
||||
shell: npm.shell,
|
||||
});
|
||||
return res.status === 0;
|
||||
} finally {
|
||||
fs.closeSync(logFd);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ server */
|
||||
@@ -671,11 +779,8 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
|
||||
}
|
||||
|
||||
if (!nodeVersionOk()) {
|
||||
log(
|
||||
rt,
|
||||
`Node ${process.versions.node} is too old — AgentHub needs Node >= ${MIN_NODE.join(".")} ` +
|
||||
`(the server stores data through node:sqlite). Upgrade Node, then start a new session.`
|
||||
);
|
||||
log(rt, nodeTooOldMessage());
|
||||
recordFailure(rt, "node-too-old", nodeTooOldMessage(), path.join(rt, "bootstrap.log"));
|
||||
return "node-too-old";
|
||||
}
|
||||
|
||||
@@ -695,7 +800,16 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
|
||||
if (force || state.depsHash !== wantDeps || !fs.existsSync(path.join(rt, "node_modules"))) {
|
||||
log(rt, "installing runtime dependencies (first run takes a few minutes)");
|
||||
if (!installDeps(rt)) {
|
||||
log(rt, "npm install failed — see the output above; run /agenthub-doctor after fixing it");
|
||||
log(
|
||||
rt,
|
||||
`npm install failed — see ${path.join(rt, "npm-install.log")}; run /agenthub-doctor after fixing it`
|
||||
);
|
||||
recordFailure(
|
||||
rt,
|
||||
"install-failed",
|
||||
"installing the dashboard's runtime dependencies failed",
|
||||
path.join(rt, "npm-install.log")
|
||||
);
|
||||
return "install-failed";
|
||||
}
|
||||
}
|
||||
@@ -713,17 +827,24 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
|
||||
|
||||
// Built eagerly (not lazily behind /agenthub-open) so the dashboard, including
|
||||
// client-only routes like /run, works the instant `claude` is started —
|
||||
// same trigger as the dependency install above: missing, or this plugin
|
||||
// version has not built one yet.
|
||||
// when missing, or when this plugin version has not built one yet
|
||||
// (`clientBuild` is recorded only after a build that did not fail, so the
|
||||
// next session retries a failed one instead of keeping the old UI for good).
|
||||
const { buildClient } = require("./plugin-open");
|
||||
const uiResult = buildClient({
|
||||
rt,
|
||||
root: PLUGIN_ROOT,
|
||||
force: force || state.depsHash !== wantDeps,
|
||||
force: force || state.clientBuild !== pluginVersion(),
|
||||
logPath: path.join(rt, "client-build.log"),
|
||||
});
|
||||
if (uiResult === "failed") {
|
||||
log(rt, "dashboard UI build failed — the API still works; run /agenthub-open to retry");
|
||||
recordFailure(
|
||||
rt,
|
||||
"ui-build-failed",
|
||||
"the dashboard UI build failed (the API still works; /agenthub-open retries it)",
|
||||
path.join(rt, "client-build.log")
|
||||
);
|
||||
} else if (uiResult === "built") {
|
||||
log(rt, "built the dashboard UI bundle");
|
||||
}
|
||||
@@ -829,6 +950,7 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
|
||||
{
|
||||
pluginVersion: pluginVersion(),
|
||||
depsHash: wantDeps,
|
||||
clientBuild: uiResult === "failed" ? state.clientBuild : pluginVersion(),
|
||||
pluginRoot: PLUGIN_ROOT,
|
||||
claudeHome: getClaudeHome(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
@@ -836,6 +958,8 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
|
||||
rt
|
||||
);
|
||||
|
||||
if (uiResult !== "failed") clearFailure(rt);
|
||||
|
||||
// After the state is durable, so a prune that goes wrong cannot also cost
|
||||
// us the record of a bootstrap that went right.
|
||||
pruneRetiredPluginRoots(rt);
|
||||
@@ -868,13 +992,26 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
|
||||
* milliseconds in the steady state, and otherwise hands off to a detached
|
||||
* worker so the session never waits on an install.
|
||||
*/
|
||||
function main() {
|
||||
if (notTheHost() || fastPathOk()) return;
|
||||
const child = spawn(process.execPath, [__filename, "--worker"], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
});
|
||||
child.unref();
|
||||
function main(rt = runtimeDir()) {
|
||||
if (notTheHost()) return null;
|
||||
// Checked here too, not only in the worker: it costs nothing, the worker
|
||||
// could only log it, and this pass can tell the user right now. A retired
|
||||
// build stays silent — its worker would do nothing either.
|
||||
if (!nodeVersionOk()) {
|
||||
if (supersededBy()) return null;
|
||||
recordFailure(rt, "node-too-old", nodeTooOldMessage(), null);
|
||||
return takeFailureReport(rt);
|
||||
}
|
||||
if (!fastPathOk(rt)) {
|
||||
const child = spawn(process.execPath, [__filename, "--worker"], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
});
|
||||
child.unref();
|
||||
}
|
||||
// A failure the previous worker recorded (it ran after that session's
|
||||
// foreground pass had already returned). Pure fs; writes only to mark it shown.
|
||||
return takeFailureReport(rt);
|
||||
}
|
||||
|
||||
// Exported before the entry block below, not after it: `bootstrap()` requires
|
||||
@@ -897,6 +1034,12 @@ module.exports = {
|
||||
depsHash,
|
||||
fastPathOk,
|
||||
nodeVersionOk,
|
||||
failurePath,
|
||||
readFailure,
|
||||
recordFailure,
|
||||
clearFailure,
|
||||
takeFailureReport,
|
||||
main,
|
||||
lockPath,
|
||||
lockIsStale,
|
||||
acquireLock,
|
||||
@@ -934,7 +1077,10 @@ if (require.main === module) {
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
main();
|
||||
const report = main();
|
||||
// SessionStart reads a JSON object on stdout; `systemMessage` is shown
|
||||
// to the user. Nothing at all is printed in the steady state.
|
||||
if (report) process.stdout.write(JSON.stringify(report) + "\n");
|
||||
} catch {
|
||||
// A bootstrap failure must never break a Claude Code session.
|
||||
}
|
||||
|
||||
+46
-12
@@ -68,7 +68,19 @@ function buildClient({
|
||||
const stdio = logFd === null ? "inherit" : ["ignore", logFd, logFd];
|
||||
try {
|
||||
const src = path.join(rt, "client-src");
|
||||
fs.rmSync(src, { recursive: true, force: true });
|
||||
// The installed tree is kept across updates while the client's manifests
|
||||
// are unchanged (`stamp`, written after a successful install): a release
|
||||
// rarely touches them, and the clean `npm ci` below fetched all of the
|
||||
// ~200 MB toolchain again on every update.
|
||||
const stamp = path.join(src, "node_modules", ".agenthub-deps");
|
||||
const want = clientDepsHash(root);
|
||||
const reuse = readText(stamp) === want;
|
||||
if (reuse) {
|
||||
for (const e of fs.readdirSync(src))
|
||||
if (e !== "node_modules") fs.rmSync(path.join(src, e), { recursive: true, force: true });
|
||||
} else {
|
||||
fs.rmSync(src, { recursive: true, force: true });
|
||||
}
|
||||
fs.cpSync(path.join(root, "client"), src, {
|
||||
recursive: true,
|
||||
filter: (p) => !/[\\/](node_modules|dist)$/.test(p),
|
||||
@@ -101,19 +113,25 @@ function buildClient({
|
||||
// esbuild is the only dependency with an install script, and it finds its
|
||||
// platform binary without it.
|
||||
const npmFlags = ["--no-audit", "--no-fund", "--include=dev", "--ignore-scripts"];
|
||||
if (
|
||||
!exec(NPM, ["ci", ...npmFlags], src, stdio) &&
|
||||
!exec(NPM, ["install", ...npmFlags], src, stdio)
|
||||
) {
|
||||
return "failed";
|
||||
if (!reuse) {
|
||||
if (
|
||||
!exec(NPM, ["ci", ...npmFlags], src, stdio) &&
|
||||
!exec(NPM, ["install", ...npmFlags], src, stdio)
|
||||
) {
|
||||
return "failed";
|
||||
}
|
||||
fs.mkdirSync(path.dirname(stamp), { recursive: true });
|
||||
fs.writeFileSync(stamp, want);
|
||||
}
|
||||
// = client/package.json's "build" script (`tsc -b && vite build`); a test
|
||||
// keeps the two in step.
|
||||
// client/package.json's "build" script is `tsc -b && vite build`; only the
|
||||
// vite half runs here. The type check emits nothing the bundle needs, and on
|
||||
// a user's machine it cost ~25 s and ~900 MB of memory per update — and a
|
||||
// type error in a release then kept the old UI everywhere (v2.7.0). The
|
||||
// release itself still runs `tsc -b`. A test keeps the two in step.
|
||||
const bin = (...p) => path.join(src, "node_modules", ...p);
|
||||
if (
|
||||
!exec(NODE, [bin("typescript", "bin", "tsc"), "-b"], src, stdio) ||
|
||||
!exec(NODE, [bin("vite", "bin", "vite.js"), "build"], src, stdio)
|
||||
) {
|
||||
if (!exec(NODE, [bin("vite", "bin", "vite.js"), "build"], src, stdio)) {
|
||||
// Maybe a damaged kept tree: the next build starts from a clean install.
|
||||
fs.rmSync(stamp, { force: true });
|
||||
return "failed";
|
||||
}
|
||||
|
||||
@@ -125,6 +143,22 @@ function buildClient({
|
||||
}
|
||||
}
|
||||
|
||||
function readText(file) {
|
||||
try {
|
||||
return fs.readFileSync(file, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The client's manifests, which decide whether its installed tree still fits. */
|
||||
function clientDepsHash(root) {
|
||||
const h = require("crypto").createHash("sha256");
|
||||
for (const f of ["package.json", "package-lock.json"])
|
||||
h.update(readText(path.join(root, "client", f)) ?? `no ${f}`);
|
||||
return h.digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
function dashboardUrl() {
|
||||
try {
|
||||
return `http://localhost:${resolveDashboardPort()}`;
|
||||
|
||||
@@ -3,14 +3,20 @@
|
||||
* @file Detached helper spawned by `server/lib/self-restart.js`. Polls the
|
||||
* old dashboard PID until it exits (server/index.js's own SIGTERM handler
|
||||
* has a 5s force-exit backstop, so this never waits forever), then starts a
|
||||
* fresh server from the same entry point and exits itself.
|
||||
* fresh server from the same entry point and exits itself. The new server's
|
||||
* stdout/stderr are appended to the log file it is given (the checkout's
|
||||
* `data/agenthub-server.log`, the one `agenthub start` writes), so a crash
|
||||
* after an update still leaves Node's stack trace behind.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
require("../server/lib/windows-hide").install(); // before child_process is read
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { spawn } = require("child_process");
|
||||
const { rotateIfLarge } = require("./log-rotate");
|
||||
|
||||
const [, , pidArg, entry] = process.argv;
|
||||
const [, , pidArg, entry, logArg] = process.argv;
|
||||
const pid = parseInt(pidArg, 10);
|
||||
|
||||
// ponytail: fixed poll cadence/cap, not configurable — this script has one caller.
|
||||
@@ -26,10 +32,25 @@ function isAlive(p) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Append fd for the new server's output, or "ignore" when there is none. */
|
||||
function openLog(file) {
|
||||
if (!file) return "ignore";
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
rotateIfLarge(file);
|
||||
const fd = fs.openSync(file, "a");
|
||||
fs.writeSync(fd, `[${new Date().toISOString()}] restart-helper: old server ${pid} gone, starting ${entry}\n`);
|
||||
return fd;
|
||||
} catch {
|
||||
return "ignore"; // a log must never stop the restart
|
||||
}
|
||||
}
|
||||
|
||||
function restart() {
|
||||
const out = openLog(logArg);
|
||||
const child = spawn(process.execPath, [entry], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
stdio: ["ignore", out, out],
|
||||
env: process.env,
|
||||
});
|
||||
child.unref();
|
||||
|
||||
+30
-2
@@ -439,7 +439,7 @@ All endpoints return JSON unless noted. Error responses use:
|
||||
| `GET` | `/api/openapi.json` | Raw OpenAPI 3.0.3 spec |
|
||||
| `GET` | `/api/docs` | Interactive **Swagger UI** (try-it-out request execution) |
|
||||
| `GET` | `/api/redoc` | **ReDoc** reference — clean, read-optimized three-panel rendering of the same spec |
|
||||
| `GET` | `/api/redoc/redoc.standalone.js` | Self-hosted ReDoc bundle (via the `redoc` dependency, never a CDN — works offline) |
|
||||
| `GET` | `/api/redoc/redoc.standalone.js` | Self-hosted ReDoc bundle (vendored in `server/vendor/redoc/`, never a CDN — works offline) |
|
||||
|
||||
The OpenAPI spec is generated from `server/openapi.js` (`createOpenApiSpec()`), merged with supplementary fragments under `server/openapi-extra/`, and is the source of truth for request/response contracts. It now documents every backend route (75 path entries). Both Swagger UI and ReDoc (`server/lib/redoc.js`) render the same spec; the ReDoc bundle is served locally so the reference works offline / air-gapped. A committed `openapi.yaml` at the repo root mirrors the live spec — regenerate it after API changes with `npm run openapi:yaml` (never hand-edit it).
|
||||
|
||||
@@ -638,6 +638,33 @@ An `antigravity` workspace carries no pipeline either.
|
||||
`DASHBOARD_CURSOR_POLL_MS` ms (default 15000, `0` disables) into `sessions`
|
||||
rows (`tool = 'cursor'`) — no `token_usage`, the transcripts carry none —
|
||||
and `cursor_transcripts` holds the mtime read for each file.
|
||||
- **Oh My Pi sessions:** `server/lib/omp-usage.js` reads omp's session journals
|
||||
(`~/.omp/agent/sessions/<encoded cwd>/<timestamp>_<id>.jsonl`, plus every
|
||||
subagent journal under `<timestamp>_<id>/`) every `DASHBOARD_OMP_POLL_MS` ms
|
||||
(default 15000, `0` disables), each from the offset the last pass stopped at,
|
||||
into `sessions` (`tool = 'omp'`), `token_usage` (the session's own and its
|
||||
subagents' turns, per model) and `agents` (one `omp-<session>-<key>` subagent
|
||||
row per subagent journal). `omp_sessions` holds each journal's offset and
|
||||
what it said so far; `GET /api/sessions/:id/transcript(s)` reads an omp
|
||||
session's journals from there. Under S3 each member's `~/.omp` is read only
|
||||
through `openSafeAsync`.
|
||||
- **OpenCode sessions:** `server/lib/opencode-usage.js` reads OpenCode's
|
||||
SQLite store (`~/.local/share/opencode/opencode.db`, under `XDG_DATA_HOME`
|
||||
when set) every `DASHBOARD_OPENCODE_POLL_MS` ms (default 15000, `0`
|
||||
disables), when its file or WAL changed: each root `session` row becomes a
|
||||
`sessions` row (`tool = 'opencode'`), its tree's assistant `message` tokens
|
||||
per `modelID` become `token_usage` (reasoning counted as output), and each
|
||||
child session (the `task` tool's) an `opencode-<id>` subagent row.
|
||||
`opencode_stores` holds each store's mtime and `opencode_sessions` each root
|
||||
session's newest `time_updated`; the transcript routes read the conversation
|
||||
(messages + parts) from the store itself. A member's store and WAL are copied
|
||||
through the guard before they are opened.
|
||||
- **Grok sessions:** Grok runs the hooks in `~/.claude/settings.json` (its
|
||||
Claude compatibility), so AgentHub's own hook reaches `POST /api/hooks/event`
|
||||
with no `tool`; `routes/hooks.js` tells it apart by the camelCase keys only
|
||||
Grok sends (`hookEventName` + `workspaceRoot`), records it as `tool = 'grok'`
|
||||
and drops its `transcript_path` (Grok's file, never parsed as a Claude
|
||||
transcript). No tokens: Grok's session files are not read.
|
||||
- **Quiet sessions:** agy fires no exit hook, so a session quiet for ten
|
||||
minutes (with hook events) or two (without) is settled to `completed`, its
|
||||
agents too, and sets its workspace idle. That workspace must be one the hooks left
|
||||
@@ -1600,10 +1627,11 @@ DASHBOARD_DB_PATH=./data/dashboard.db # SQLite database path
|
||||
DASHBOARD_AI_TOOLS_DIR= # Extra AI Tools catalog JSON files; an id here replaces the built-in one
|
||||
AGENTHUB_SHELL_PATH=1 # 0 = never run the login shell to read its PATH (startup + Refresh)
|
||||
AGENTHUB_AI_TOOLS_SYNC=1 # 0 = never copy the newest release's AI Tools catalog (server/lib/ai-tools-sync.js, daily)
|
||||
DASHBOARD_LANE_SYNC_MS=2000 # re-broadcast lanes another dashboard on the same DB changed (PRAGMA data_version); 0 = off
|
||||
DASHBOARD_LANE_SYNC_MS=2000 # re-broadcast lanes another dashboard on the same DB changed (PRAGMA data_version), only while one runs (checked every 30 s); 0 = off
|
||||
|
||||
# Background services
|
||||
DASHBOARD_SESSION_SYNC_MS=30000 # Continuous project-sync poll interval (ms); 0 disables the poll (watcher stays)
|
||||
DASHBOARD_SYNC_LIVE_HOOK_MS=300000 # project sync leaves a transcript alone while hooks fed its session within this window; 0 = never defer
|
||||
DASHBOARD_LIVENESS_PROBE=1 # 0 disables the dead-session liveness reap (use when hooks arrive from another machine)
|
||||
DASHBOARD_LIVENESS_IDLE_SECONDS=60 # Idle gate before the liveness reap may complete a process-less session
|
||||
DASHBOARD_REPORTS_MS=60000 # Scheduled-reports beat (ms); 0 disables the scheduler
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user