diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bdf3c3a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: CI + +# Runs the verification commands from AGENTS.md. Keep the two jobs in sync with +# that file when the expected commands change. + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + flutter: + name: Flutter client + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: subosito/flutter-action@v2 + with: + # Pinned so analyzer output stays reproducible; bump deliberately. + flutter-version: 3.41.9 + channel: stable + cache: true + + - run: flutter pub get + + - run: flutter analyze --no-pub + + - run: flutter test --no-pub + + server: + name: Node backend + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: server/package-lock.json + + # node-pty builds a native addon; ubuntu-latest already ships Python 3, + # make, and a C++ compiler. + - run: npm ci + working-directory: server + + - name: Syntax-check every backend source file + run: git ls-files '*.js' | xargs -r node --check + working-directory: server + + - run: npm test + working-directory: server diff --git a/.gitignore b/.gitignore index 4156ddb..21619d8 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,14 @@ app.*.map.json /android/app/release /android/app/google-services.json +# Release artifacts built by scripts/build_flow.sh or prepared for a tag. +# They are published as release assets, never committed. +*.apk +*.aab +*.tar.gz +*.zip +/SHA256SUMS + # Relay backend local state and secrets /server/.env /server/tokens.json diff --git a/AGENTS.md b/AGENTS.md index fb7bc1f..b8cc9ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,7 @@ # Relay contributor guide Relay is a Flutter client plus a self-hosted Node.js backend for controlling -Claude Code, Codex, Antigravity (`agy`), OpenCode, and Hermes on the backend -machine. Keep public usage guidance in the root READMEs, operational detail in +Claude Code, Codex, OpenCode, and Hermes on the backend machine. Keep public usage guidance in the root READMEs, operational detail in `docs/handbook.md`, and security guarantees in `SECURITY.md`. ## Working safely @@ -26,7 +25,7 @@ flutter analyze --no-pub flutter test --no-pub flutter test --no-pub test/agent_controls_test.dart -node --check server/server.js +(cd server && git ls-files '*.js' | xargs -r node --check) npm --prefix server test npm --prefix server start @@ -43,11 +42,14 @@ clients and bundles CanvasKit locally instead of depending on gstatic. platform adapters. - `server/server.js`: server configuration, middleware, shared runtime state, scheduling, route context, and optional Web static hosting. -- `server/routes/`: API routers for metadata, push, files, chat, BTW, Swarms, - agent login, sessions, quota, and the SSH terminal ticket. +- `server/routes/`: API routers for metadata, push, files, chat, Swarms, + sessions, quota, and the SSH terminal ticket. - `server/lib/`: agent runners, settings/model discovery, persistence, auth, filesystem policy, history, quota, push, and orchestration helpers. -- `backends/`: Linux, macOS, and Windows install/service adapters. +- `backends/`: Linux, macOS, and Windows install/service adapters. Each OS has + `setup`, `start`, `stop`, `status`, and `uninstall` entry points. +- `.github/workflows/ci.yml`: runs the verification commands below on pull + requests. Update it when those commands change. - `scripts/`: development, deployment, and screenshot helpers. - `test/` and `server/test/`: Flutter and Node test suites. @@ -70,19 +72,65 @@ clients and bundles CanvasKit locally instead of depending on gstatic. - `server/lib/agents.js` is the process-runner boundary. Pass per-request state through `runAgent(..., { workdir, settings, sessionKey })`; do not add globals. -- `server/lib/agent-options.js` owns option validation and exact CLI argv. - `server/lib/agent-settings.js` persists normalized solo-chat settings. +- `server/lib/agent-options.js` owns option validation and the CLI, SDK, or + protocol representation of each setting. `server/lib/agent-settings.js` + persists normalized solo-chat settings. +- No agent runs one process per turn. All four keep a live session that turns + are fed into. Every pool is a *cache*: the stored session id stays + authoritative, so any scope without a live session cold-starts by resuming it + and degrades to exactly the old per-turn behaviour. Do not reintroduce a + per-turn `spawn` for an agent that has a pool. + - `server/lib/claude-session-pool.js` — one Agent SDK process per scope. + Settings resolve to SDK options (`claudeSdkOptions`) rather than argv, and + are fixed for the life of a process, so a change restarts it with `resume`. + - `server/lib/stdio-agent-pool.js` — the shared pool for the three CLIs that + speak line-delimited JSON-RPC on stdio. It owns the process, the wire, the + session cap, idle eviction and cancellation; a `driver` supplies the + protocol. One process per agent hosts *all* of that agent's scopes, since + each session carries its own `cwd`, so a large startup cost is paid once + instead of once per chat. + - `acp-session-pool.js` — the ACP driver (opencode, hermes). Settings apply + over the protocol (`acpSessionOptions`) with no restart. Capabilities from + `initialize` gate optional calls: hermes has no `session/close`, so an + evicted session is simply dropped. + - `codex-session-pool.js` — the codex app-server driver. `turn/start` + returns as soon as the turn is *accepted*; the turn is settled by the + later `turn/completed` notification. Everything except the sandbox applies + per turn (`codexSessionOptions`), and the sandbox is what the runner + passes as `fixedKey` so a change reopens the thread — still resuming the + same conversation, without respawning the process. + - Relay answers the agents' approval requests from the configured tier, + because there is no approval UI to route them to. The runner's policy + answers yes or no; translating that into each protocol's vocabulary is the + driver's job (`allow_once` vs `accept` vs `approved`). + - `runAcpAgent` in `agents.js` is the shared runner for opencode and hermes, + which differ only by their pool and their entries in the option tables. +- Deleting or clearing a conversation goes through `purgeSession`, not + `clearSession`: for a pooled agent it also requests CLI-side transcript + deletion on a best-effort basis. Use `clearSession` only for the internal + stale-session retry. +- Test files are `test/*.test.js`. Helper processes live in `test/fixtures/`, + which the runner would otherwise try to execute as tests. - Fast mode is supported only by Claude Code and Codex and defaults off. Claude receives a `fastMode` settings override; Codex receives an explicit - `service_tier="fast"` or `service_tier="default"` override. + `serviceTier` of `fast` or `default` on every turn. - Codex models and model-specific reasoning levels come from structured CLI metadata, with bundled/cache/static fallbacks. Do not reintroduce binary string scanning for Codex model ids. -- `GET /api/agents` returns all five known agents with install/auth/usability - state. Claude, Codex, and Agy require OAuth; OpenCode and Hermes credentials - are managed on the host and become selectable when installed. -- The in-app OAuth bridge uses the backend host's `script -qfec` PTY utility. - Keep the process output redacted and never return credential values. +- `describeAgent` and `getSettings` are hot paths for option refreshes and every + turn, so keep them free of subprocesses and per-call file reads. + `model-discovery.js` re-locates a CLI at most once a minute and caches the + result (including "not installed"); `agent-options.js` caches + `models-extra.json` by mtime. A CLI update calls `clearModelDiscoveryCache`, + which is what makes new models appear at once. +- `GET /api/agents` returns all four known agents with install/auth/usability + state. Claude and Codex require OAuth; OpenCode and Hermes credentials are + managed on the host and become selectable when installed. +- Every credential is created on the backend host by the CLI itself. Relay does + not log an agent in. `server/lib/agent-status.js` reads auth state and, for + Claude and Codex, a `credentialExpiresAt` timestamp. `server/lib/usage.js` + separately reads and may refresh their OAuth credentials for quota reporting + and keepalive. A token value must never reach Relay's API or app. ### Backend modules and persistence @@ -90,10 +138,14 @@ clients and bundles CanvasKit locally instead of depending on gstatic. destructures a new helper, add it to the context in `server/server.js`. - Use `server/lib/json-store.js` for JSON state: cached reads, atomic replace, and owner-only file permissions. Do not create ad hoc read/modify/write stores. +- A generated state file may accept a `RELAY_*_FILE` absolute-path override so + its module is testable without touching deployment state. When adding one to a + file that the file API denies, take the path from the owning module rather than + rebuilding it in `server/lib/filesystem.js`. - New notifications should go through `server/lib/notify.js`, which fans out to configured Web Push and FCM channels. -- Prompts are passed as one argv token and are capped by `PROMPT_MAX_BYTES`. - Preserve that validation in every chat path. +- Chat prompt payloads and generated Swarm prompts are capped by + `PROMPT_MAX_BYTES`. Preserve that validation in every chat path. ### Client boundaries @@ -113,13 +165,14 @@ clients and bundles CanvasKit locally instead of depending on gstatic. always applies the precise sensitive-path denylist and optional `RELAY_FS_ROOTS` allowlist in `server/lib/filesystem.js`. - A Swarm owns one canonical transcript and private resumable sessions per - member. One human message snapshots the transcript once, then mentioned - members run in parallel from their own delta prompts. + member. A round runs in waves: each wave snapshots the transcript once and + runs everyone summoned in it in parallel from their own delta prompts. The + human's `@mentions` open wave one; `@mentions` inside a member's reply summon + the next wave, bounded by `RELAY_SWARM_MAX_HOPS` (default 3, 0 disables) since + two members naming each other would otherwise never stop. A member never + summons itself, and a failed or cancelled turn summons no one. - Swarm configuration is stored under the workspace that lists it, while its chosen work tree is the directory members actually use. -- BTW is read-only and isolated from the main session. Claude forks natively; - Codex and Agy clone their native persisted conversations before resuming the - side scope. - The SSH terminal exchanges the bearer credential for a short-lived, single-use WebSocket ticket. Never put the bearer token in a socket URL. A token record owns one resumable PTY, which runs with the full permissions of @@ -131,9 +184,10 @@ clients and bundles CanvasKit locally instead of depending on gstatic. Generated files under `server/` include `.env`, `tokens.json`, credentials, agent/chat sessions, history, settings, groups, quota state/schedules, usage -cache, and push/FCM stores. They are deployment state, not fixtures. Keep them -out of patches and release archives. `server/models-extra.json` is also a local -override, not a shared catalog. +cache, and push/FCM stores. They are deployment state, not fixtures. Keep them, +along with any referenced FCM service-account JSON, out of patches and release +archives. `server/models-extra.json` is also a local override, not a shared +catalog. ## Verification expectations @@ -144,4 +198,9 @@ override, not a shared catalog. - Cross-stack API changes: verify both suites and keep old payload parsing safe when adding response fields. - Documentation changes: verify local Markdown links, commands, environment - names, and English/Chinese README parity against code rather than old docs. + names, English/Chinese README parity, and the embedded guides in + `getting_started_screen.dart` and `deploy_backend_screen.dart` against code + rather than old docs. +- Release bumps touch four places, which drift apart if any is missed: + `pubspec.yaml`, `server/package.json`, `_applicationVersion` in + `lib/features/settings/app_settings_screen.dart`, and a `CHANGELOG.md` entry. diff --git a/CHANGELOG.md b/CHANGELOG.md index 29520fe..eaef345 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,134 @@ # Changelog +## 0.1.5 - 2026-08-19 + +### Removed + +- Antigravity (`agy`) support. The CLI agent list is now Claude Code, Codex, + OpenCode, and Hermes, and the usage screen reports Claude Code and Codex only. + This drops the agy runner, BTW conversation cloning, model discovery, OAuth + login, and the local language-server quota probe, along with + `AGY_QUOTA_PROBE_TIMEOUT_MS`. +- The browser-only OAuth login mode (`authMode` / `requiresCode` on the login + SSE stream), which existed solely for Antigravity. Every remaining OAuth agent + now uses its own host-side login flow. +- BTW side conversations, for Claude Code and Codex alike. This drops the + `/api/btw` routes, the side-scope session keys and their transcripts, the + BTW button and dialog in the app, and the session-forking each agent needed + to support them. +- The in-app OAuth login bridge for Claude Code and Codex. This drops the + `/api/agent-auth/*` routes, the backend PTY that ran the CLI's own login + command through `script -qfec`, and the login dialog in the app. Log in on + the backend host, as OpenCode and Hermes already required. + +### Added + +- Swarm members can now summon each other. An `@mention` in a member's reply + hands the floor to that teammate, so a round continues in waves instead of + ending when the human's mentions are done; each wave snapshots the transcript + again, so the newly summoned members see what was just said. Every member's + prompt now lists its teammates and the `@name` that reaches each, because a + member that does not know summoning works will never use it. + `RELAY_SWARM_MAX_HOPS` bounds the agent-driven waves that follow one human + message (default 3; 0 keeps summoning human-only), a member cannot summon + itself, and a failed or cancelled turn summons no one. The transcript records + which member summoned each reply. +- Credential expiry for Claude Code and Codex on the **Manage credentials** + screen: the days left before the next login on the backend host, or the days + since the credential expired. `/api/agents` reports it as + `credentialExpiresAt`, read from the timestamps the two CLIs already store + next to their tokens. + +- The backend keeps Claude's five-hour quota window cycling with one minimal + request whenever the window is idle, so its reset time is no longer reported + as unknown after a lapse. The request can consume quota; set + `ENABLE_CLAUDE_KEEPALIVE=false` to opt out. +- Linux service scripts (`start.sh`, `stop.sh`, `status.sh`, `uninstall.sh`) + alongside the existing macOS and Windows sets. +- An MIT `LICENSE` and a GitHub Actions workflow running the analyzer and both + test suites. +- Test coverage for the file API access policy, the device-token store, and + quota schedules. + +### Changed + +- Reworked the English and Chinese READMEs into a visual product tour with + Chromium screenshots captured against isolated demo data, a clearer system + diagram, and a shorter path from project overview to backend setup. + +- The composer's Model / Effort / Permission controls and the quota screens now + open without waiting on the network. The option catalog describes the + installed CLI, not the current workdir, so it is cached and the buttons render + at their final size on the first frame instead of showing a spinner and then + growing; returning from an option page adopts the selection it saved instead + of refetching both the catalog and the settings. The usage and scheduler + screens paint the last report immediately and refresh behind it. +- Backend option lookups no longer spawn processes on the hot path. Every + `/api/agent-options`, `/api/agent-settings`, and agent turn re-located the CLI + binary with a synchronous `command -v` subprocess and re-read + `models-extra.json`, blocking the event loop (and so every SSE stream) for + about 6 ms each, 12 ms for a settings read. Discovery now re-checks the binary + at most once a minute, remembers hosts where a CLI is absent, and caches the + extra-models file by mtime; a CLI update still busts the cache immediately. + `describeAgent` went from 6.1 ms to 0.04 ms per call, `getSettings` from + ~12 ms to 0.03 ms. ` --version`, which ran on every model/effort page + open, is cached the same way. +- Claude Code now runs as a persistent session instead of one process per turn. + A chat keeps a single CLI process alive between messages, the way a terminal + session does, so follow-up turns skip the cold start (roughly 3.1s to 1.5s in + local measurement) and anything started in the background — watchers, servers, + long-running tasks — is still running on the next turn instead of being killed + the moment the turn ends. Cancelling a turn now interrupts it rather than + killing the process, so the conversation survives a cancel. + + Live processes cost about 300 MB each, so an idle chat's process is closed + after `RELAY_CLAUDE_IDLE_MS` (default 15 minutes) and at most + `RELAY_CLAUDE_MAX_LIVE` (default 3) exist at once; a chat whose process was + closed resumes into the same conversation on its next turn. `RELAY_CLAUDE_BIN` + overrides which `claude` binary is driven. +- OpenCode, Hermes and Codex now run as persistent sessions too, over their + stdio JSON-RPC servers — `acp` for the first two (Agent Client Protocol), + `app-server` for Codex — with the same gains: follow-up turns skip the cold + start (3.9s to 1.4s for opencode, 5.2s to 1.2s for hermes, 3.7s to 1.4s for + codex in local measurement), and cancelling interrupts the turn instead of + killing the conversation. Replies now stream token by token for opencode and + hermes as well — the old opencode path could only stream whole JSON lines and + hermes could not stream at all. OpenCode and Hermes apply settings over ACP; + Codex applies most settings per turn, while a sandbox change reopens and + resumes the thread without respawning the shared app-server process. No agent + runs one process per turn any more. + + Unlike Claude, one process per agent hosts *every* chat for it, because these + protocols give each session its own work tree. That pays the CLI's startup + cost (~360 MB for opencode, ~90 MB for hermes) once instead of once per chat. + Idle sessions are closed after `RELAY_AGENT_IDLE_MS` (default 15 minutes), at + most `RELAY_AGENT_MAX_SESSIONS` (default 4) are live per agent, and the process + exits with its last session; a chat whose session was closed reloads into the + same conversation on its next turn. + + Approval prompts now reach Relay directly. Until there is an approval UI, the + "Bypass" / "Auto-approve (yolo)" tiers approve them and the "Ask" / "Cautious" + tiers refuse — deterministic, where the old non-interactive runs could stall. + + Background work started by a turn now outlives it for Claude, OpenCode and + Hermes. Codex is the exception: its sandbox kills the process group of each + command as that command returns, so background work there survives only if it + detaches into its own session (`setsid`). +- Deleting or clearing a chat session now removes Relay history and its stored + resume id, then requests CLI-side transcript deletion for all four agents. + External CLI deletion remains best effort. +- `server/.env.example` documents the remaining supported settings, including + the state-file overrides and the keepalive retry interval. +- The denylist that protects `tokens.json` now follows `RELAY_TOKENS_FILE` + instead of assuming the default location. +- Documented that the credential generator also accepts a passphrase from + `--passphrase` or `RELAY_CREDENTIAL_PASSPHRASE`. + +### Fixed + +- Chat-history search now jumps to the matched message reliably and highlights + the search term after the destination conversation loads. + ## 0.1.4 - 2026-07-13 ### Added diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..161709e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 AgentDeck + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 81d5695..7c39add 100644 --- a/README.md +++ b/README.md @@ -2,122 +2,155 @@ # Relay -**A private remote control for AI coding agents running on your own machine.** +**Run AI coding agents on your machine. Control them from any screen.** -[中文](README.zh-CN.md) · [Backend setup](backends/README.md) · +A private, self-hosted remote cockpit for Claude Code, Codex, OpenCode, and Hermes. + +![Flutter client](https://img.shields.io/badge/client-Flutter-02569B?logo=flutter&logoColor=white) +![Node.js backend](https://img.shields.io/badge/backend-Node.js_18%2B-339933?logo=node.js&logoColor=white) +![Self-hosted](https://img.shields.io/badge/deployment-self--hosted-5B5BD6) +![MIT License](https://img.shields.io/badge/license-MIT-2F855A) + +[中文](README.zh-CN.md) · [Install a backend](backends/README.md) · [Security](SECURITY.md) · [Handbook](docs/handbook.md) -Relay keeps Claude Code, Codex, Antigravity, OpenCode, and Hermes on the machine -where your projects, shell, and credentials already live. It gives you one -Flutter app for phone, Web, and desktop so you can reconnect to those local CLI -agents without moving the projects to a hosted service. + + Relay home screen showing connected coding agents, recent sessions, and a multi-agent Swarm + -There is no Relay cloud account or default backend. You run the Node.js backend, -generate an encrypted credential, and import it into the clients you trust. +Relay leaves your source code, shell access, and CLI credentials on the computer +you control. Its Flutter client connects from phone, Web, or desktop to a small +Node.js backend running beside your projects—there is no Relay cloud account and +no hosted middleman. -```mermaid -flowchart LR - C["Phone · Web · Desktop"] -->|"encrypted device credential"| B["Your Relay backend"] - B --> A["Claude Code · Codex · Agy · OpenCode · Hermes"] - B --> F["Your projects and files"] -``` + + + + + + +
🖥️
Runs where your code lives
Your agents and projects stay on your backend machine.
📱
One client, every screen
Use the same interface on mobile, Web, and desktop.
🔐
Private by design
Import an encrypted, revocable credential for each device.
-## Current capabilities - -- **Live agent chat.** Stream replies, cancel turns, preserve multi-part agent - updates, and continue long work while switching between conversations. -- **Named conversations.** Each workdir and agent supports up to eight persistent - sessions with shared cross-device history and running-state indicators. -- **Agent status and login.** See installed/authenticated state for all five - agents. Relay can bridge Claude, Codex, and Agy OAuth on compatible backend - hosts; OpenCode and Hermes credentials stay host-managed. -- **Per-agent controls.** Select model, reasoning effort, and permissions in the - composer. Claude Code and Codex also have a Fast mode switch, off by default; - fast responses may consume more quota or cost more. -- **Live Codex catalog.** Relay reads structured model metadata and each model's - supported reasoning levels from the installed Codex CLI, with safe fallbacks. -- **Swarms.** Put several agents in one transcript, give each member a work tree, - model, effort, permission, nickname, and persona, then summon members with - `@mentions`. Multiple members run in parallel from one transcript snapshot. - Swarms can be saved and imported as JSON templates. -- **Read-only BTW side conversations.** Ask Claude, Codex, or Agy a side question - without changing the main task's native session. -- **Remote files.** Browse absolute paths allowed by the backend, change the - workdir, upload files, and download files or zipped folders. -- **SSH terminal.** Open **Manage credentials → Enter SSH** for one resumable - terminal on the current backend machine. It runs as the backend OS user and - follows the app's Light/Dark appearance. Web bundles a terminal monospace - font so Chromium keeps normal horizontal character spacing. -- **Quota workflows.** View Claude, Codex, and Agy usage. Claude and Codex can - queue one prompt for the next detected five-hour reset. -- **Notifications.** Live local/browser alerts plus optional Web Push and Android - FCM for configured deployments. +## See Relay in 60 seconds -## Quick start +### Keep real coding sessions within reach -### 1. Prepare a backend +Stream replies, cancel a turn, search history, export Markdown, and switch away +while work continues. Each `workdir + agent` context supports up to eight named, +resumable conversations. -You need a Linux, macOS, or Windows machine with Node.js 18+ and at least one -supported CLI installed. Claude, Codex, and Agy must be logged in; OpenCode and -Hermes provider setup is managed on that host. + + A persistent Claude Code conversation in the Relay Web client + -From the repository root, run the setup for the backend OS: +### Chat, coordinate, and manage files from mobile -```bash -./backends/linux/setup.sh -``` + + + + + + + + + + + +
Relay agent chat on mobileRelay multi-agent Swarm on mobileRelay remote file browser on mobile
Persistent chat
Follow a long-running agent session from anywhere.
Swarms
Let specialized agents work in one shared transcript.
Remote files
Browse, upload, download, and change the active work tree.
-```bash -./backends/macos/setup.sh -``` +These screenshots were captured in Chromium against an isolated demo backend; they contain no production credentials or project data. + +## How it fits together -```powershell -.\backends\windows\setup.ps1 +```mermaid +flowchart LR + C["Flutter client
Phone · Web · Desktop"] + R["Relay backend
Node.js on your machine"] + A["Persistent agent sessions
Claude · Codex · OpenCode · Hermes"] + F["Projects and files"] + T["Resumable PTY shell"] + + C -->|"authenticated HTTP + SSE"| R + R -->|"local CLI protocols"| A + R -->|"filesystem policy"| F + C -. "single-use WebSocket ticket" .-> T + R --> T ``` -The installer offers three network modes: +The active workdir belongs to each client and is sent on every request. A +conversation is scoped by `workdir + agent + session`, so unrelated sessions +can run concurrently without sharing a global backend directory. + +## What you can do -| Mode | Use case | Important detail | +| | Capability | What it gives you | |---|---|---| -| Direct | Your own public address or reverse proxy | Use HTTPS before public exposure. | -| Named Cloudflare Tunnel | Stable personal deployment | Requires a Cloudflare zone and `cloudflared`. | -| Cloudflare Quick Tunnel | Short trial | URL may rotate after restart. | +| 💬 | **Live, persistent chat** | Streaming replies, cancellation, named sessions, cross-device history, search, and Markdown export. | +| 🐝 | **Multi-agent Swarms** | Shared transcripts, per-member roles and controls, parallel waves, bounded `@mention` handoffs, and reusable JSON templates. | +| 🎛️ | **Agent controls** | Model, reasoning effort, permission tier, install/auth status, credential-expiry countdown, and Fast mode for Claude/Codex. | +| 📁 | **Files and terminal** | Allowed-path browsing, uploads, downloads, zipped folders, workdir switching, and one resumable PTY per device credential. | +| 📊 | **Quota workflows** | Claude/Codex usage views plus one queued prompt for the next detected five-hour reset. | +| 🔔 | **Notifications** | In-app/browser alerts, with optional Web Push and Android FCM for configured deployments. | + +Claude Code and Codex are the primary integrations. OpenCode and Hermes are +available as experimental, host-managed integrations. All four keep their +credentials on the backend host; Relay never logs an agent in for you. + +## Quick start + +### 1. Prepare the backend machine + +Install Node.js 18+ and at least one supported CLI on Linux, macOS, or Windows. +Claude and Codex must already be logged in on that host; OpenCode and Hermes use +the provider configuration managed there. -See [backends/README.md](backends/README.md) for service commands and platform -details. +Run the setup command for your backend OS from the repository root: -### 2. Import the device credential +| Backend OS | Setup command | +|---|---| +| Linux | `./backends/linux/setup.sh` | +| macOS | `./backends/macos/setup.sh` | +| Windows PowerShell | `.\backends\windows\setup.ps1` | -Setup prints an encrypted QR and saves `.relay.png` / `.relay.json` under -`server/credentials/`. Import it by camera scan, image/file selection, or pasted -JSON, then enter the passphrase chosen during generation. Generate a separate -credential for each device. +The installer walks through direct access, a named Cloudflare Tunnel, or a +temporary Quick Tunnel. Use HTTPS before exposing a direct deployment publicly. +Linux also needs PM2 and the native tools listed in the +[backend requirements](backends/README.md#requirements); Unix hosts need `zip` +for folder downloads. -The app's first connection screen also contains a **Deploy backend** walkthrough. +### 2. Import an encrypted device credential -### 3. Choose a workdir and agent +Setup prints an encrypted QR code and writes `.relay.png` / `.relay.json` files +under `server/credentials/`. Import one by camera, image/file, or pasted JSON, +then enter its passphrase. Camera scanning is mobile-only; every client supports +file or pasted-JSON import. Generate a separate revocable credential for each +device. -Select a machine, set the backend workdir, and open an agent conversation or -Swarm. The active workdir is stored per client and sent with every API request. +### 3. Pick a project and start working -## Security summary +Choose the backend, set the workdir, and open an agent conversation or Swarm. +For service commands, networking details, and platform notes, continue with the +[backend guide](backends/README.md). -- All HTTP API routes require a revocable bearer token. -- The SSH terminal uses a short-lived, single-use WebSocket ticket derived from - that token; the long-lived bearer token is never placed in the socket URL. -- Credential exports are encrypted with PBKDF2-HMAC-SHA256 and AES-256-GCM. -- The file API denies a specific set of Relay, SSH, Claude, and Codex secrets and - can be restricted further with `RELAY_FS_ROOTS`. -- Failed bearer-token attempts are rate-limited. -- Public deployments should terminate TLS and run Relay as a restricted non-root - user. +## Security boundary -Relay is not a sandbox: every CLI and SSH terminal process has the permissions -of the backend OS user. Read [SECURITY.md](SECURITY.md) and the -[production checklist](docs/handbook.md#production-deployment) before exposing -a backend outside a trusted network. +- Every HTTP API route requires a revocable bearer token; failed attempts are + rate-limited. +- Credential exports use PBKDF2-HMAC-SHA256 and AES-256-GCM. +- The terminal exchanges that bearer token for a short-lived, single-use + WebSocket ticket; the long-lived token never appears in the socket URL. +- The file API denies known Relay, SSH, Claude, and Codex secret paths and can + be restricted further with `RELAY_FS_ROOTS`. +- Quota reporting may read and refresh host OAuth files, but token values never + reach the Relay API or client. + +> [!IMPORTANT] +> Relay is not a sandbox. Agent and terminal processes have the permissions of +> the backend OS user. Run it as a restricted non-root user, terminate TLS for +> public deployments, and read [SECURITY.md](SECURITY.md) plus the +> [production checklist](docs/handbook.md#production-deployment) first. ## Development @@ -125,32 +158,32 @@ a backend outside a trusted network. flutter pub get flutter analyze --no-pub flutter test --no-pub +npm --prefix server install npm --prefix server test ``` -Run the client with `flutter run`. For a self-hosted Web build: +Run the client with `flutter run`. To serve a self-hosted Web build: ```bash flutter build web --no-pub --pwa-strategy=none --no-web-resources-cdn npm --prefix server start ``` -Desktop runner projects exist for Windows, macOS, and Linux. Windows release -builds have been exercised; macOS/Linux packaging and secure-storage validation -are still less mature. See [the handbook](docs/handbook.md#development-and-builds). - -## Project layout +The Web flags intentionally disable the service worker and bundle CanvasKit +locally. Windows release builds have been exercised; macOS/Linux desktop +packaging and secure-storage validation are less mature. See the +[development handbook](docs/handbook.md#development-and-builds). ```text Relay/ ├── lib/ shared Flutter client ├── server/ Node.js backend and tests ├── backends/ OS-specific install/service adapters -├── assets/ icons and UI assets -├── docs/ durable operations and architecture handbook -├── scripts/ development and deployment helpers +├── docs/ operations and architecture handbook +├── scripts/ development, deployment, and screenshot helpers └── test/ Flutter tests ``` Contributors and coding agents should read [AGENTS.md](AGENTS.md). Release -history is in [CHANGELOG.md](CHANGELOG.md). +history is in [CHANGELOG.md](CHANGELOG.md), and Relay is released under the +[MIT License](LICENSE). diff --git a/README.zh-CN.md b/README.zh-CN.md index 22130a8..3207feb 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -2,108 +2,145 @@ # Relay -**一个连接并控制自有机器上 AI 编程智能体的私有远程控制台。** +**让 AI 编程智能体留在你的机器上,在任何屏幕上继续控制它们。** -[English](README.md) · [后端安装](backends/README.zh-CN.md) · +一个连接 Claude Code、Codex、OpenCode 与 Hermes 的私有、自托管远程工作台。 + +![Flutter 客户端](https://img.shields.io/badge/client-Flutter-02569B?logo=flutter&logoColor=white) +![Node.js 后端](https://img.shields.io/badge/backend-Node.js_18%2B-339933?logo=node.js&logoColor=white) +![自托管](https://img.shields.io/badge/deployment-self--hosted-5B5BD6) +![MIT License](https://img.shields.io/badge/license-MIT-2F855A) + +[English](README.md) · [安装后端](backends/README.zh-CN.md) · [安全模型](SECURITY.md) · [技术手册](docs/handbook.md) -Relay 让 Claude Code、Codex、Antigravity、OpenCode 和 Hermes 继续运行在已经准备好项目、 -shell 与登录态的机器上,再通过同一个 Flutter app 从手机、Web 或桌面重新连接这些本地 -CLI 智能体,不需要把项目搬到托管服务。 + + Relay 首页,显示已连接的编程智能体、最近会话与多智能体蜂群 + -Relay 没有云端账号,也没有内置的默认后端。你自己运行 Node.js 后端、生成加密凭证, -再把凭证导入信任的客户端。 +Relay 把源代码、shell 权限和 CLI 登录凭据留在你控制的电脑上。手机、Web 与桌面共用 +一个 Flutter 客户端,连接运行在项目旁边的小型 Node.js 后端——没有 Relay 云账号, +也没有托管中间层。 -```mermaid -flowchart LR - C["手机 · Web · 桌面"] -->|"加密的设备凭证"| B["你自己的 Relay 后端"] - B --> A["Claude Code · Codex · Agy · OpenCode · Hermes"] - B --> F["你的项目和文件"] -``` + + + + + + +
🖥️
代码在哪里,智能体就在哪里
项目和智能体始终留在你的后端主机上。
📱
一个客户端,覆盖所有屏幕
手机、Web 与桌面使用一致的操作界面。
🔐
从设计上保持私有
每台设备导入独立、加密且可撤销的凭证。
-## 当前能力 - -- **实时智能体聊天。** 流式显示回复、取消任务、保留多段 agent 更新;切换会话后长任务 - 仍可继续运行。 -- **命名会话。** 每个工作目录与 agent 最多有 8 个持久会话,聊天历史和运行状态可在 - 多设备间同步。 -- **Agent 状态与登录。** 查看五种 agent 的安装和认证状态。兼容的后端可为 Claude、 - Codex、Agy 中转 OAuth;OpenCode 与 Hermes 的密钥仍由后端主机管理。 -- **按 agent 配置。** 在输入区选择模型、思考深度和权限。Claude Code 与 Codex 还会 - 显示默认关闭的快速模式;快速响应可能消耗更多额度或产生更高费用。 -- **Codex 动态目录。** 从已安装 Codex CLI 的结构化元数据读取模型与每个模型支持的 - 思考档位,并提供安全的回退目录。 -- **蜂群。** 多个 agent 共享一份记录;每位成员可设置工作树、模型、思考深度、权限、 - 昵称和人设。用 `@` 召唤成员,同一条消息中的多个成员会基于同一快照并行运行。 - 蜂群还可保存和导入 JSON 模板。 -- **只读 BTW 旁路对话。** 向 Claude、Codex 或 Agy 提问而不改变主任务的原生会话。 -- **远程文件。** 浏览后端允许的绝对路径、切换工作目录、上传文件、下载文件或压缩文件夹。 -- **SSH 终端。** 从“管理凭证 → 进入SSH”打开当前后端机器上唯一且可恢复的终端;终端 - 使用后端系统用户运行,并跟随 app 的“白天/黑夜”外观。Web 端内置等宽终端字体, - 避免 Chromium 中的字符横向间距过大。 -- **额度工作流。** 查看 Claude、Codex 和 Agy 额度;只有 Claude 与 Codex 可以预约在 - 下一个检测到的 5 小时额度重置后自动发送一条消息。 -- **通知。** 在线时使用本地/浏览器通知;配置后还可使用 Web Push 和 Android FCM。 +## 60 秒看懂 Relay -## 快速开始 +### 随时接回真实的编程会话 -### 1. 准备后端 +流式查看回复、取消当前回合、搜索历史、导出 Markdown;切换页面后,任务仍可继续。 +每个 `工作目录 + agent` 最多支持 8 个可恢复的命名会话。 -准备一台安装了 Node.js 18+ 的 Linux、macOS 或 Windows 主机,并至少安装一个支持的 -CLI。Claude、Codex 和 Agy 需要登录;OpenCode 与 Hermes 的 provider 配置在主机完成。 + + Relay Web 客户端中的持久 Claude Code 会话 + -在仓库根目录运行后端系统对应的命令: +### 在手机上聊天、协作和管理文件 -```bash -./backends/linux/setup.sh -``` + + + + + + + + + + + +
Relay 移动端智能体会话Relay 移动端多智能体蜂群Relay 移动端远程文件浏览器
持久会话
从任意地点继续长时间运行的 agent 任务。
多智能体蜂群
让不同职责的 agent 在同一份记录中协作。
远程文件
浏览、上传、下载并切换当前工作树。
-```bash -./backends/macos/setup.sh -``` +这些图片由 Chromium 连接隔离的演示后端截取,不包含生产凭据或真实项目数据。 + +## 它是怎样连接起来的 -```powershell -.\backends\windows\setup.ps1 +```mermaid +flowchart LR + C["Flutter 客户端
手机 · Web · 桌面"] + R["Relay 后端
运行在你的机器上的 Node.js"] + A["持久 agent 会话
Claude · Codex · OpenCode · Hermes"] + F["项目与文件"] + T["可恢复 PTY shell"] + + C -->|"已认证 HTTP + SSE"| R + R -->|"本地 CLI 协议"| A + R -->|"文件系统策略"| F + C -. "一次性 WebSocket 票据" .-> T + R --> T ``` -安装器提供三种网络模式: +当前工作目录由每个客户端独立保存,并随每次请求发送。会话按 +`工作目录 + agent + session` 隔离,因此互不相关的会话可以并行运行,后端不依赖一个 +全局工作目录。 + +## 你可以做什么 -| 模式 | 适合场景 | 重要说明 | +| | 能力 | 带来的体验 | |---|---|---| -| 直连 | 自有公网地址或反向代理 | 公开暴露前必须使用 HTTPS。 | -| 正式 Cloudflare Tunnel | 稳定的个人部署 | 需要 Cloudflare zone 和 `cloudflared`。 | -| Cloudflare Quick Tunnel | 短期试用 | 重启后 URL 可能变化。 | +| 💬 | **实时、持久会话** | 流式回复、取消任务、命名会话、跨设备历史、搜索与 Markdown 导出。 | +| 🐝 | **多智能体蜂群** | 共享记录、独立角色和参数、并行波次、有限的 `@mention` 接力与可复用 JSON 模板。 | +| 🎛️ | **Agent 控制** | 模型、思考深度、权限、安装/认证状态、凭据到期倒计时,以及 Claude/Codex 快速模式。 | +| 📁 | **文件与终端** | 受策略约束的浏览、上传、下载、文件夹压缩、工作目录切换,以及每个设备凭证一条可恢复 PTY。 | +| 📊 | **额度工作流** | 查看 Claude/Codex 用量,并预约一条消息在下一个检测到的 5 小时额度重置后发送。 | +| 🔔 | **通知** | App/浏览器提醒;配置后还可使用 Web Push 与 Android FCM。 | + +Claude Code 与 Codex 是主要集成;OpenCode 与 Hermes 目前是由主机管理的实验性集成。 +四种 agent 的凭据都保留在后端主机上,Relay 不会代替你登录。 + +## 快速开始 + +### 1. 准备后端主机 + +在 Linux、macOS 或 Windows 主机安装 Node.js 18+ 和至少一个支持的 CLI。Claude 与 +Codex 需要事先在这台主机登录;OpenCode 与 Hermes 使用主机上管理的 provider 配置。 -服务命令和各平台细节见 [backends/README.zh-CN.md](backends/README.zh-CN.md)。 +在仓库根目录执行对应系统的安装命令: -### 2. 导入设备凭证 +| 后端系统 | 安装命令 | +|---|---| +| Linux | `./backends/linux/setup.sh` | +| macOS | `./backends/macos/setup.sh` | +| Windows PowerShell | `.\backends\windows\setup.ps1` | -安装完成后会打印一张加密二维码,并在 `server/credentials/` 下保存 `.relay.png` / -`.relay.json`。通过相机、图片/文件或粘贴 JSON 导入,再输入生成时设置的密码。每台设备 -应单独生成一份凭证。 +安装器会引导你选择直连、正式 Cloudflare Tunnel 或临时 Quick Tunnel。直连服务公开 +暴露前必须配置 HTTPS。Linux 还需要 PM2 和 +[后端前置要求](backends/README.zh-CN.md#前置要求)列出的本地工具;Unix 主机下载文件夹 +时需要 `zip`。 -app 的首次连接页也内置了“部署后端”向导。 +### 2. 导入加密设备凭证 -### 3. 选择工作目录与 agent +安装程序会打印加密二维码,并在 `server/credentials/` 下写入 `.relay.png` / +`.relay.json`。通过相机、图片/文件或粘贴 JSON 导入,再输入生成时设置的密码。相机 +扫描仅移动端支持;所有客户端都可以导入文件或粘贴 JSON。建议为每台设备生成一份可 +独立撤销的凭证。 -选择机器、设置后端工作目录,然后打开 agent 会话或蜂群。当前工作目录保存在每个客户端 -本地,并随每次 API 请求发送。 +### 3. 选择项目并开始工作 -## 安全摘要 +选择后端、设置工作目录,然后打开 agent 会话或蜂群。服务命令、网络配置与各平台说明 +见[后端安装指南](backends/README.zh-CN.md)。 -- 所有 HTTP API 都需要可撤销的 bearer token。 -- SSH 终端用该 token 换取短时、一次性的 WebSocket 票据,长期 bearer token 不会进入 - WebSocket 地址。 -- 凭证导出使用 PBKDF2-HMAC-SHA256 与 AES-256-GCM 加密。 -- 文件 API 会拒绝一组明确的 Relay、SSH、Claude 与 Codex 敏感路径,并可用 - `RELAY_FS_ROOTS` 进一步限制。 -- 错误 token 尝试会被限速。 -- 公网部署应终止 TLS,并使用权限受限的非 root 系统用户运行 Relay。 +## 安全边界 -Relay 不是沙箱:CLI 与 SSH 终端进程都拥有后端系统用户的权限。对外暴露前请阅读 -[SECURITY.md](SECURITY.md) 与[生产部署清单](docs/handbook.md#production-deployment)。 +- 所有 HTTP API 都需要可撤销的 bearer token;错误凭证尝试会被限速。 +- 凭证导出使用 PBKDF2-HMAC-SHA256 与 AES-256-GCM。 +- 终端先用 bearer token 换取短时、一次性的 WebSocket 票据,长期 token 不会进入 + socket 地址。 +- 文件 API 会拒绝已知的 Relay、SSH、Claude 与 Codex 敏感路径,还可用 + `RELAY_FS_ROOTS` 进一步收紧。 +- 额度查询可能读取并刷新主机 OAuth 文件,但 token 值绝不会进入 Relay API 或客户端。 + +> [!IMPORTANT] +> Relay 不是沙箱。Agent 和终端进程拥有后端系统用户的权限。请使用受限的非 root 用户 +> 运行,公网部署时终止 TLS,并先阅读 [SECURITY.md](SECURITY.md) 和 +> [生产部署清单](docs/handbook.md#production-deployment)。 ## 开发 @@ -111,6 +148,7 @@ Relay 不是沙箱:CLI 与 SSH 终端进程都拥有后端系统用户的权 flutter pub get flutter analyze --no-pub flutter test --no-pub +npm --prefix server install npm --prefix server test ``` @@ -121,21 +159,19 @@ flutter build web --no-pub --pwa-strategy=none --no-web-resources-cdn npm --prefix server start ``` -项目包含 Windows、macOS、Linux 桌面 runner。Windows release 已实际验证;macOS/Linux -打包和安全存储验证仍不如 Windows 成熟。详见[技术手册](docs/handbook.md#development-and-builds)。 - -## 项目结构 +Web 参数会有意禁用 service worker,并在本地打包 CanvasKit。Windows release 已实际 +验证;macOS/Linux 桌面打包和安全存储验证成熟度较低。详见 +[开发手册](docs/handbook.md#development-and-builds)。 ```text Relay/ ├── lib/ 共享 Flutter 客户端 ├── server/ Node.js 后端与测试 ├── backends/ 各系统安装和服务管理适配 -├── assets/ 图标与界面资源 -├── docs/ 长期运维和架构手册 -├── scripts/ 开发与部署脚本 +├── docs/ 运维与架构手册 +├── scripts/ 开发、部署与截图工具 └── test/ Flutter 测试 ``` 贡献者和编程 agent 请先阅读 [AGENTS.md](AGENTS.md),版本记录见 -[CHANGELOG.md](CHANGELOG.md)。 +[CHANGELOG.md](CHANGELOG.md)。Relay 使用 [MIT License](LICENSE) 发布。 diff --git a/SECURITY.md b/SECURITY.md index 7195467..8e9ea8f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -22,7 +22,11 @@ already use. The credential generator creates a `relay.credentials.v1` QR/JSON envelope with the machine id/name, backend URL, and one bearer token. The envelope uses PBKDF2-HMAC-SHA256 with 600,000 iterations plus AES-256-GCM with a random salt -and nonce. Its passphrase is entered interactively and is not written to disk. +and nonce. A passphrase entered at the interactive prompt is not saved by the +generator. `--passphrase` and `RELAY_CREDENTIAL_PASSPHRASE` exist for unattended +setup and should be avoided otherwise: the flag is visible in process listings +and can persist in shell history, while the environment variable is visible to +the process and can persist in `.env` or another launcher configuration. The backend stores bearer-token records and metadata in `server/tokens.json`. That file is a secret and is written owner-only. Native @@ -37,7 +41,12 @@ Recommended practice: - revoke and delete a token when a device is lost or retired; - regenerate credentials after changing `PUBLIC_BASE_URL`; - never commit `.env`, tokens, credential exports, push keys, history, sessions, - agent settings, groups, or CLI login state. + agent settings, groups, CLI login state, or FCM service-account files. + +Generating a new credential deletes old QR/JSON export files, but it does not +revoke previously issued device tokens. The backend status panel lists token +ids, device metadata, and last-use time so each old token can be revoked and +then deleted deliberately. ## API protections @@ -54,17 +63,26 @@ Implemented controls include: - token revocation and deletion; - a 600-request/minute/IP limit for ordinary API requests; - a separate 15-failed-auth-attempt/minute/IP limit; -- streaming chat/SSE/login and file-transfer routes excluded from the general +- streaming chat/SSE and file-transfer routes excluded from the general request counter while still requiring authentication; - `trust proxy` restricted to loopback so a direct client cannot spoof `X-Forwarded-For`; - a startup warning when a routable public URL uses plaintext HTTP. -The in-app Claude/Codex/Agy login bridge starts the real CLI in a backend PTY. -It returns authorization URLs and status only, redacts URLs from diagnostic -output, and never returns stored OAuth tokens. The bridge currently depends on -GNU-compatible `script -qfec`; log in directly on hosts without it. OpenCode and -Hermes keys are managed outside Relay on the backend host. +Relay never logs a CLI agent in. Every agent's credential is created on the +backend host with that CLI's own login flow or provider configuration. +`server/lib/agent-status.js` reads authentication state and, for Claude Code and +Codex, the stored OAuth expiry timestamp. `server/lib/usage.js` additionally +reads their OAuth tokens for quota queries and can refresh an expired access +token in the CLI's credential file. Token values may therefore be sent to the +provider's OAuth and API endpoints, but neither Relay's API nor the app ever +receives them. + +Quota reporting is not passive for every provider. Codex usage discovery sends +a minimal Responses request to obtain quota headers. The enabled-by-default +Claude keepalive sends a one-output-token request when its five-hour window is +idle. Either request can consume provider quota; disable the latter with +`ENABLE_CLAUDE_KEEPALIVE=false` if that tradeoff is unwanted. ## SSH terminal @@ -107,14 +125,15 @@ variants. A directory download is also rejected when its tree would contain a denied path. This list is intentionally precise, not a promise to detect every secret. It -does not automatically cover arbitrary Agy, OpenCode, Hermes, provider, or +does not automatically cover arbitrary OpenCode, Hermes, provider, or service-account files. Set `RELAY_FS_ROOTS` to a comma-separated allowlist of absolute directories and run Relay as a restricted OS user. The allowlist limits the file API only; it does not change what a launched CLI can access. Uploads stream to a temporary file and default to 100 MB. Downloads default to 300 MB. Configure smaller proxy and Relay limits when the deployment does not -need those sizes. +need those sizes. Unix directory downloads invoke the host's `zip` command; +Windows uses PowerShell `Compress-Archive`. ## Production requirements @@ -137,7 +156,7 @@ See the [production checklist](docs/handbook.md#production-deployment). ## What Relay does not do -- It does not sandbox Claude Code, Codex, Agy, OpenCode, or Hermes. +- It does not sandbox Claude Code, Codex, OpenCode, or Hermes. - It cannot stop an enabled fast mode or high-permission agent from consuming provider quota or changing files within its effective access. - It cannot protect an already compromised backend host or browser profile. diff --git a/android/gradle.properties b/android/gradle.properties index fbee1d8..d5da727 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,2 +1,6 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/assets/agent_icons/agy.png b/assets/agent_icons/agy.png deleted file mode 100644 index 7daa08d..0000000 Binary files a/assets/agent_icons/agy.png and /dev/null differ diff --git a/assets/screenshots/relay-chat-mobile.png b/assets/screenshots/relay-chat-mobile.png new file mode 100644 index 0000000..323285c Binary files /dev/null and b/assets/screenshots/relay-chat-mobile.png differ diff --git a/assets/screenshots/relay-chat-web.png b/assets/screenshots/relay-chat-web.png new file mode 100644 index 0000000..1ce7354 Binary files /dev/null and b/assets/screenshots/relay-chat-web.png differ diff --git a/assets/screenshots/relay-files-mobile.png b/assets/screenshots/relay-files-mobile.png new file mode 100644 index 0000000..1fcbad1 Binary files /dev/null and b/assets/screenshots/relay-files-mobile.png differ diff --git a/assets/screenshots/relay-overview-web.png b/assets/screenshots/relay-overview-web.png new file mode 100644 index 0000000..9e187bf Binary files /dev/null and b/assets/screenshots/relay-overview-web.png differ diff --git a/assets/screenshots/relay-swarm-mobile.png b/assets/screenshots/relay-swarm-mobile.png new file mode 100644 index 0000000..162a394 Binary files /dev/null and b/assets/screenshots/relay-swarm-mobile.png differ diff --git a/backends/README.md b/backends/README.md index e4e058b..e73d1b2 100644 --- a/backends/README.md +++ b/backends/README.md @@ -10,10 +10,12 @@ Cloudflare Tunnel startup to each operating system. - Node.js 18 or newer. - At least one supported CLI installed on the backend: Claude Code, Codex, - Antigravity (`agy`), OpenCode, or Hermes. -- The CLI must be authenticated on the host. Relay can bridge OAuth login for - Claude, Codex, and Agy when the host provides the compatible `script` PTY - utility; OpenCode and Hermes keys remain host-managed. + OpenCode, or Hermes. +- Every CLI must be authenticated or configured on the backend host itself. + Relay reports status but does not perform OAuth login or collect provider + keys. +- Unix hosts need `zip` for directory downloads. Linux setup also needs PM2, + Python 3, `make`, and a C++ compiler for the terminal PTY dependency. - `cloudflared` is required only for named or Quick Tunnel mode. ## Install @@ -39,12 +41,23 @@ app and enter the passphrase you chose. server stays on `127.0.0.1`. 3. **Cloudflare Quick Tunnel:** useful for a trial. The generated `trycloudflare.com` URL may change after restart, so regenerate and re-import - the credential when it rotates. + the credential when it rotates. Find the new URL in the service logs, then + run `npm --prefix server run credential -- --url https://NEW-URL` from the + repository root. ## Service management ### Linux +```bash +./backends/linux/status.sh +./backends/linux/start.sh +./backends/linux/stop.sh +./backends/linux/uninstall.sh +``` + +These wrap PM2, which remains available directly: + ```bash pm2 list pm2 logs relay-server @@ -53,9 +66,13 @@ pm2 logs relay-tunnel ``` Linux setup requires PM2 (`npm install -g pm2`). It creates `relay-server` and, -for tunnel modes, `relay-tunnel`. The interactive terminal's PTY dependency is +for tunnel modes, `relay-tunnel`. Logs are under `~/.pm2/logs/` as +`relay-server-*.log` and `relay-tunnel-*.log`. `uninstall.sh` removes the PM2 +processes and leaves backend data, tokens, and credentials in place. The +interactive terminal's PTY dependency is compiled on Linux, so first-time setup also needs Python 3, `make`, and a C++ -compiler (for example the Debian/Ubuntu `build-essential` package). +compiler (for example the Debian/Ubuntu `build-essential` package). Install +`zip` as well if clients will download directories. ### macOS @@ -87,6 +104,9 @@ for the current shell only: Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass ``` +All three uninstall adapters remove the managed service but deliberately leave +configuration, tokens, credentials, histories, and logs for manual cleanup. + ## Manual server start For development or troubleshooting, bypass the service adapters: @@ -98,6 +118,7 @@ cp .env.example .env npm start ``` -Generate a credential separately with `npm run credential`. See +Authenticate the chosen agent CLI on this host, then generate a credential +separately with `npm run credential`. See `server/.env.example` for configuration and the handbook for production hardening. diff --git a/backends/README.zh-CN.md b/backends/README.zh-CN.md index 5e0aa8c..8587d1b 100644 --- a/backends/README.zh-CN.md +++ b/backends/README.zh-CN.md @@ -8,10 +8,11 @@ Relay 在所有后端操作系统上使用同一个 Node.js 服务和同一套 H ## 前置要求 - Node.js 18 或更新版本。 -- 后端至少安装一个支持的 CLI:Claude Code、Codex、Antigravity(`agy`)、 - OpenCode 或 Hermes。 -- CLI 需要在后端主机上完成认证。当主机提供兼容的 `script` PTY 工具时,Relay 可以 - 为 Claude、Codex 和 Agy 中转 OAuth 登录;OpenCode 和 Hermes 的密钥仍在主机管理。 +- 后端至少安装一个支持的 CLI:Claude Code、Codex、OpenCode 或 Hermes。 +- 每个 CLI 都必须直接在后端主机上完成认证或 provider 配置。Relay 只报告状态, + 不执行 OAuth 登录,也不收集 provider 密钥。 +- Unix 主机下载文件夹时需要 `zip`。Linux 安装还需要 PM2、Python 3、`make` 和 + C++ 编译器,以编译终端的 PTY 依赖。 - 只有正式 Cloudflare Tunnel 或 Quick Tunnel 模式需要 `cloudflared`。 ## 安装 @@ -34,12 +35,22 @@ Relay 在所有后端操作系统上使用同一个 Node.js 服务和同一套 H 2. **正式 Cloudflare Tunnel:** 使用 Cloudflare zone 下的稳定域名,服务保持绑定 `127.0.0.1`。 3. **Cloudflare Quick Tunnel:** 适合试用。重启后 `trycloudflare.com` 地址可能变化, - 地址变化时需要重新生成并导入凭证。 + 地址变化时需要重新生成并导入凭证。先从服务日志找到新地址,再在仓库根目录运行 + `npm --prefix server run credential -- --url https://新地址`。 ## 服务管理 ### Linux +```bash +./backends/linux/status.sh +./backends/linux/start.sh +./backends/linux/stop.sh +./backends/linux/uninstall.sh +``` + +这些脚本封装 PM2,也可以继续直接使用 PM2 命令: + ```bash pm2 list pm2 logs relay-server @@ -48,8 +59,11 @@ pm2 logs relay-tunnel ``` Linux 安装需要 PM2(`npm install -g pm2`)。进程名为 `relay-server`;隧道模式还会创建 -`relay-tunnel`。交互终端的 PTY 依赖会在 Linux 上本地编译,因此首次安装还需要 Python 3、 -`make` 和 C++ 编译器(Debian/Ubuntu 可安装 `build-essential`)。 +`relay-tunnel`。日志位于 `~/.pm2/logs/`,文件名为 `relay-server-*.log` 和 +`relay-tunnel-*.log`。`uninstall.sh` 只删除 PM2 进程,保留后端数据、令牌和凭证。 +交互终端的 PTY 依赖会在 Linux 上本地编译,因此首次安装还需要 Python 3、 +`make` 和 C++ 编译器(Debian/Ubuntu 可安装 `build-essential`)。如果客户端需要下载 +文件夹,还要安装 `zip`。 ### macOS @@ -79,6 +93,9 @@ LaunchAgent 位于 `~/Library/LaunchAgents`。日志在 `~/Library/Logs/Relay/` Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass ``` +三个平台的卸载脚本都只移除受管理的服务,配置、token、凭证、历史和日志会保留,需按需 +手动清理。 + ## 手动启动 开发或排障时可以绕过平台服务脚本: @@ -90,4 +107,5 @@ cp .env.example .env npm start ``` -再用 `npm run credential` 单独生成凭证。配置项见 `server/.env.example`,生产加固见技术手册。 +在这台主机上完成所选 agent CLI 的认证,再用 `npm run credential` 单独生成凭证。 +配置项见 `server/.env.example`,生产加固见技术手册。 diff --git a/backends/linux/lib/common.sh b/backends/linux/lib/common.sh new file mode 100644 index 0000000..05be729 --- /dev/null +++ b/backends/linux/lib/common.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +LINUX_DIR="$(cd "$SCRIPT_DIR/.." && pwd -P)" +ROOT_DIR="$(cd "$LINUX_DIR/../.." && pwd -P)" +SERVER_DIR="$ROOT_DIR/server" +ENV_FILE="$SERVER_DIR/.env" + +# Process names created by scripts/setup.sh via server/ecosystem.config.js. +SERVER_PROC="relay-server" +TUNNEL_PROC="relay-tunnel" +PM2_LOG_DIR="$HOME/.pm2/logs" + +c_info() { printf '\n\033[1;34m==>\033[0m %s\n' "$*"; } +c_warn() { printf '\033[1;33m%s\033[0m\n' "$*"; } +c_err() { printf '\033[1;31mError:\033[0m %s\n' "$*" >&2; } + +need() { command -v "$1" >/dev/null 2>&1; } + +require_pm2() { + need pm2 || { + c_err "pm2 is required. Install it with: npm install -g pm2" + exit 1 + } +} + +require_setup() { + [ -f "$ENV_FILE" ] || { + c_err "server/.env is missing. Run ./backends/linux/setup.sh first." + exit 1 + } + [ -d "$SERVER_DIR/node_modules" ] || { + c_err "Backend dependencies are missing. Run ./backends/linux/setup.sh first." + exit 1 + } +} + +get_env() { + local key="$1" + [ -f "$ENV_FILE" ] || return 0 + grep -E "^${key}=" "$ENV_FILE" | tail -1 | cut -d= -f2- || true +} + +# Matches ecosystem.config.js: anything other than "none" also runs a tunnel. +tunnel_mode() { + local mode + mode="$(get_env RELAY_TUNNEL_MODE)" + printf '%s' "${mode:-quick}" +} + +tunnel_enabled() { + [ "$(tunnel_mode)" != "none" ] +} + +pm2_has() { pm2 describe "$1" >/dev/null 2>&1; } diff --git a/backends/linux/start.sh b/backends/linux/start.sh new file mode 100755 index 0000000..d17c6db --- /dev/null +++ b/backends/linux/start.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +# shellcheck source=lib/common.sh +source "$SCRIPT_DIR/lib/common.sh" + +require_pm2 +require_setup + +cd "$SERVER_DIR" +if tunnel_enabled; then + c_info "Starting Relay backend + Cloudflare Tunnel (PM2: $SERVER_PROC, $TUNNEL_PROC)" +else + c_info "Starting Relay backend (PM2: $SERVER_PROC)" +fi + +# ecosystem.config.js omits the tunnel app when RELAY_TUNNEL_MODE=none, and +# --update-env re-reads server/.env for processes that already exist. +pm2 start ecosystem.config.js --update-env +pm2 save >/dev/null 2>&1 || true + +c_info "Started. Check it with ./backends/linux/status.sh" diff --git a/backends/linux/status.sh b/backends/linux/status.sh new file mode 100755 index 0000000..a716574 --- /dev/null +++ b/backends/linux/status.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +# shellcheck source=lib/common.sh +source "$SCRIPT_DIR/lib/common.sh" + +require_pm2 + +c_info "PM2 processes" +pm2 list + +c_info "Relay processes" +for proc in "$SERVER_PROC" "$TUNNEL_PROC"; do + if pm2_has "$proc"; then + printf ' %-14s registered\n' "$proc" + elif [ "$proc" = "$TUNNEL_PROC" ] && ! tunnel_enabled; then + printf ' %-14s not used (RELAY_TUNNEL_MODE=none)\n' "$proc" + else + printf ' %-14s not registered\n' "$proc" + fi +done + +printf '\nBackend URL: %s\n' "$(get_env PUBLIC_BASE_URL)" +printf 'Tunnel mode: %s\n' "$(tunnel_mode)" + +printf '\nLogs:\n' +printf ' %s\n' \ + "$PM2_LOG_DIR/$SERVER_PROC-out.log" \ + "$PM2_LOG_DIR/$SERVER_PROC-error.log" \ + "$PM2_LOG_DIR/$TUNNEL_PROC-out.log" \ + "$PM2_LOG_DIR/$TUNNEL_PROC-error.log" +printf '\nFollow them with: pm2 logs %s\n' "$SERVER_PROC" diff --git a/backends/linux/stop.sh b/backends/linux/stop.sh new file mode 100755 index 0000000..db3fe90 --- /dev/null +++ b/backends/linux/stop.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +# shellcheck source=lib/common.sh +source "$SCRIPT_DIR/lib/common.sh" + +require_pm2 + +# Stop the tunnel first so it stops advertising a backend that is going away. +for proc in "$TUNNEL_PROC" "$SERVER_PROC"; do + if pm2_has "$proc"; then + c_info "Stopping $proc" + pm2 stop "$proc" >/dev/null + else + c_warn "$proc is not registered with PM2; nothing to stop." + fi +done + +pm2 save >/dev/null 2>&1 || true +c_info "Stopped. The processes stay registered, so ./backends/linux/start.sh resumes them." diff --git a/backends/linux/uninstall.sh b/backends/linux/uninstall.sh new file mode 100755 index 0000000..35e1730 --- /dev/null +++ b/backends/linux/uninstall.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +# shellcheck source=lib/common.sh +source "$SCRIPT_DIR/lib/common.sh" + +require_pm2 + +c_info "Removing Relay PM2 processes" +for proc in "$TUNNEL_PROC" "$SERVER_PROC"; do + if pm2_has "$proc"; then + pm2 delete "$proc" >/dev/null + printf ' deleted %s\n' "$proc" + else + printf ' %s was not registered\n' "$proc" + fi +done + +pm2 save >/dev/null 2>&1 || true + +c_info "Removed the PM2 processes." +cat < sendBtwMessage({ - required String agentKey, - required String sessionId, - required String prompt, - required String requestId, - required void Function(BackendEvent event) onEvent, - }) { - return _sendMessageStreamed( - agentKey: agentKey, - sessionId: sessionId, - prompt: prompt, - requestId: requestId, - onEvent: onEvent, - path: '/api/btw', - ); - } - Future _sendMessageStreamed({ required String agentKey, required String sessionId, @@ -1206,36 +1187,6 @@ class BackendClient { return _decodeHistoryMessages(response.body); } - /// Fetches the /btw side conversation tied to the given main session. - Future> fetchBtwHistory( - String agentKey, { - required String sessionId, - }) async { - final String query = 'agent=${Uri.encodeQueryComponent(agentKey)}' - '&sessionId=${Uri.encodeQueryComponent(sessionId)}'; - final Object? decoded = - await _requestJson('GET', '/api/btw/history?$query'); - if (decoded is! Map) { - throw BackendException('Invalid btw history response.'); - } - final List raw = decoded['messages'] is List - ? (decoded['messages'] as List).cast() - : const []; - return raw - .whereType() - .map((Map item) => ChatMessage.fromJson(item.cast())) - .toList(growable: false); - } - - /// Resets the /btw side conversation so the next question re-forks the main one. - Future clearBtw(String agentKey, String sessionId) async { - await _requestJson( - 'POST', - '/api/btw/clear', - body: {'agent': agentKey, 'sessionId': sessionId}, - ); - } - // --- Group chat (multi-agent) --------------------------------------------- List _groupsFrom(Object? decoded) { @@ -1400,7 +1351,7 @@ class BackendClient { /// Best-effort login state per agent so the app can warn before sending a /// message. Maps agentKey -> loggedIn, where the value is true/false when the - /// backend can read the CLI's credentials, or null when it cannot tell (agy). + /// backend can read the CLI's credentials, or null when it cannot tell. Future> fetchAuthStatus() async { final Object? decoded = await _requestJson('GET', '/api/auth/status'); final Map result = {}; @@ -1431,40 +1382,6 @@ class BackendClient { .toList(growable: false); } - Stream streamAgentLogin(String agentKey) async* { - final MachineCredential credential = await _requireCredential(); - final http.Request request = http.Request( - 'GET', - _uri( - credential, - '/api/agent-auth/login/start?agent=${Uri.encodeQueryComponent(agentKey)}', - ), - ); - request.headers.addAll( - await _headers(credential, accept: 'text/event-stream'), - ); - - final http.StreamedResponse response = await _httpClient.send(request); - if (response.statusCode < 200 || response.statusCode >= 300) { - final String text = await response.stream.bytesToString(); - throw _exceptionFor(response.statusCode, text); - } - - yield* decodeSse(response.stream); - } - - Future submitAgentLoginCode({ - required String sessionId, - required String code, - }) async { - await _requestJson( - 'POST', - '/api/agent-auth/login/code', - body: {'sessionId': sessionId, 'code': code}, - timeout: const Duration(seconds: 20), - ); - } - /// Catalog of selectable model/effort/permission/fast options for an agent. Future fetchAgentOptions(String agentKey) async { final Object? decoded = await _requestJson( diff --git a/lib/core/credentials/qr_image_decoder.dart b/lib/core/credentials/qr_image_decoder.dart index 5e302d6..7bcfc82 100644 --- a/lib/core/credentials/qr_image_decoder.dart +++ b/lib/core/credentials/qr_image_decoder.dart @@ -3,6 +3,11 @@ import 'dart:typed_data'; import 'package:image/image.dart' as img; import 'package:zxing2/qrcode.dart'; +import 'qr_pixels.dart'; + +/// Decode a credential QR from encoded image bytes using the pure-Dart image +/// pipeline. Expensive, so callers run it on a background isolate; platforms +/// with a native decoder should prefer [decodeQrFromRgba] instead. String decodeCredentialQrImage(Uint8List bytes) { final img.Image? image = img.decodeImage(bytes); if (image == null) { @@ -10,10 +15,19 @@ String decodeCredentialQrImage(Uint8List bytes) { } final img.Image scanImage = _resizeForScanning(image); final img.Image rgba = scanImage.convert(numChannels: 4); - final LuminanceSource source = RGBLuminanceSource( + return decodeQrFromRgba( rgba.width, rgba.height, - rgba.getBytes(order: img.ChannelOrder.abgr).buffer.asInt32List(), + rgba.getBytes(order: img.ChannelOrder.rgba), + ); +} + +/// Scan already-decoded, downscaled RGBA pixels for a QR code. +String decodeQrFromRgba(int width, int height, Uint8List rgba) { + final LuminanceSource source = RGBLuminanceSource( + width, + height, + _argbPixels(width * height, rgba), ); final BinaryBitmap bitmap = BinaryBitmap(HybridBinarizer(source)); try { @@ -23,12 +37,25 @@ String decodeCredentialQrImage(Uint8List bytes) { } } +/// Pack RGBA bytes into the 0xAARRGGBB words `RGBLuminanceSource` reads its +/// red/green/blue channels out of. +Int32List _argbPixels(int count, Uint8List rgba) { + final Int32List pixels = Int32List(count); + for (int i = 0, offset = 0; i < count; i++, offset += 4) { + pixels[i] = + (rgba[offset + 3] << 24) | + (rgba[offset] << 16) | + (rgba[offset + 1] << 8) | + rgba[offset + 2]; + } + return pixels; +} + img.Image _resizeForScanning(img.Image image) { - const int maxSide = 768; final int longestSide = image.width > image.height ? image.width : image.height; - if (longestSide <= maxSide) return image; - final double scale = maxSide / longestSide; + if (longestSide <= qrScanMaxSide) return image; + final double scale = qrScanMaxSide / longestSide; return img.copyResize( image, width: (image.width * scale).round(), diff --git a/lib/core/credentials/qr_image_pixels.dart b/lib/core/credentials/qr_image_pixels.dart new file mode 100644 index 0000000..d932943 --- /dev/null +++ b/lib/core/credentials/qr_image_pixels.dart @@ -0,0 +1,10 @@ +import 'qr_image_pixels_stub.dart' + if (dart.library.html) 'qr_image_pixels_web.dart' as platform; + +import 'qr_pixels.dart'; + +/// Decode and downscale an image with the host platform's own image pipeline, +/// or null when there is no such fast path and the caller should fall back to +/// decoding in Dart on a background isolate. +Future decodeImageToRgba(List bytes) => + platform.decodeImageToRgba(bytes); diff --git a/lib/core/credentials/qr_image_pixels_stub.dart b/lib/core/credentials/qr_image_pixels_stub.dart new file mode 100644 index 0000000..8b56b51 --- /dev/null +++ b/lib/core/credentials/qr_image_pixels_stub.dart @@ -0,0 +1,5 @@ +import 'qr_pixels.dart'; + +/// Native platforms run the pure-Dart decoder on a real background isolate, so +/// there is no platform fast path to take here. +Future decodeImageToRgba(List bytes) async => null; diff --git a/lib/core/credentials/qr_image_pixels_web.dart b/lib/core/credentials/qr_image_pixels_web.dart new file mode 100644 index 0000000..d2ab6bc --- /dev/null +++ b/lib/core/credentials/qr_image_pixels_web.dart @@ -0,0 +1,57 @@ +import 'dart:js_interop'; +import 'dart:typed_data'; + +import 'package:web/web.dart' as web; + +import 'qr_pixels.dart'; + +/// Decode and downscale the picked image with the browser instead of the +/// pure-Dart `image` package. +/// +/// This is not an optimisation detail: Flutter Web's `compute` has no isolate +/// to run on, so it invokes its callback on the main thread. Decoding a +/// multi-megapixel photo in Dart there freezes the tab outright, and the +/// caller's timeout cannot fire because the timer needs the blocked event +/// loop. The browser decodes off the main thread and scales in a single +/// `drawImage`, leaving Dart only the bounded scan of a small bitmap. +Future decodeImageToRgba(List bytes) async { + final Uint8List data = bytes is Uint8List ? bytes : Uint8List.fromList(bytes); + final web.Blob blob = web.Blob([data.toJS].toJS); + final String url = web.URL.createObjectURL(blob); + try { + final web.HTMLImageElement image = web.HTMLImageElement(); + image.src = url; + // decode() resolves once the bitmap is ready and reports a real error for + // a file that is not an image, unlike waiting on the load event alone. + await image.decode().toDart; + final int sourceWidth = image.naturalWidth; + final int sourceHeight = image.naturalHeight; + if (sourceWidth <= 0 || sourceHeight <= 0) return null; + + final int longest = + sourceWidth > sourceHeight ? sourceWidth : sourceHeight; + final double scale = + longest > qrScanMaxSide ? qrScanMaxSide / longest : 1.0; + final int width = (sourceWidth * scale).round().clamp(1, sourceWidth); + final int height = (sourceHeight * scale).round().clamp(1, sourceHeight); + + final web.HTMLCanvasElement canvas = web.HTMLCanvasElement() + ..width = width + ..height = height; + final web.CanvasRenderingContext2D context = + canvas.getContext('2d')! as web.CanvasRenderingContext2D; + context.drawImage(image, 0, 0, width, height); + final Uint8ClampedList pixels = + context.getImageData(0, 0, width, height).data.toDart; + return QrPixels( + width: width, + height: height, + rgba: pixels.buffer.asUint8List( + pixels.offsetInBytes, + pixels.lengthInBytes, + ), + ); + } finally { + web.URL.revokeObjectURL(url); + } +} diff --git a/lib/core/credentials/qr_pixels.dart b/lib/core/credentials/qr_pixels.dart new file mode 100644 index 0000000..db99503 --- /dev/null +++ b/lib/core/credentials/qr_pixels.dart @@ -0,0 +1,19 @@ +import 'dart:typed_data'; + +/// Longest side a credential QR image is downscaled to before scanning. Large +/// enough to keep a phone photo of a printed code readable, small enough that +/// the scan itself stays fast. +const int qrScanMaxSide = 768; + +/// Decoded, already downscaled image pixels in RGBA byte order. +class QrPixels { + const QrPixels({ + required this.width, + required this.height, + required this.rgba, + }); + + final int width; + final int height; + final Uint8List rgba; +} diff --git a/lib/core/i18n/app_strings.dart b/lib/core/i18n/app_strings.dart index ae3c97d..c585396 100644 --- a/lib/core/i18n/app_strings.dart +++ b/lib/core/i18n/app_strings.dart @@ -36,8 +36,8 @@ class AppStrings { String get notConnected => isZh ? '未连接机器' : 'No machine connected'; String get manageCredentials => isZh ? '管理凭证' : 'Manage credentials'; String get manageCredentialsHomeHint => isZh - ? '登录 CLI 智能体或配置 Hermes API key' - : 'Log in CLI agents or configure a Hermes API key'; + ? '查看 CLI 智能体状态与凭据有效期' + : 'Check CLI agent status and credential expiry'; String get cliAgents => isZh ? 'CLI 智能体' : 'CLI agents'; String get groupChat => isZh ? '蜂群' : 'Swarm'; String get groupChatSubtitle => isZh ? '多智能体蜂群协作' : 'Multi-agent swarm'; @@ -299,36 +299,23 @@ class AppStrings { } String get recheck => isZh ? '重新检查' : 'Recheck'; - String get login => isZh ? '登录' : 'Log in'; - String get loginAgain => isZh ? '重新登录' : 'Log in again'; String get optionalApiKey => isZh ? 'API key 可选' : 'API key optional'; String get keyManagedOnHost => isZh ? '在主机上配置' : 'Configured on host'; String get agentReady => isZh ? '已就绪' : 'Ready'; String get copy => isZh ? '复制' : 'Copy'; String get copied => isZh ? '已复制。' : 'Copied.'; - String agentLoginTitle(String agent) => - isZh ? '登录 $agent' : 'Log in to $agent'; - String get agentLoginStarting => - isZh ? '正在启动 CLI 登录...' : 'Starting CLI login...'; - String get agentLoginWaitingForUrl => isZh - ? '等待 CLI 输出授权链接。' - : 'Waiting for the CLI to print an authorization URL.'; - String get agentLoginOpenUrl => isZh - ? '在浏览器中打开此链接,完成授权后把代码粘贴回来。' - : 'Open this link in a browser, authorize, then paste the code here.'; - String get agentLoginBrowserOpenUrl => isZh - ? '在浏览器中打开此链接。完成授权后,状态会自动更新。' - : 'Open this link in a browser. Status will update after authorization finishes.'; - String get agentLoginCode => isZh ? '授权代码' : 'Authorization code'; - String get agentLoginCodeHint => - isZh ? '粘贴 CLI 要求的代码' : 'Paste the code requested by the CLI'; - String get agentLoginSubmit => isZh ? '提交代码' : 'Submit code'; - String get agentLoginSubmitting => isZh ? '正在提交代码...' : 'Submitting code...'; - String get agentLoginDone => - isZh ? '登录完成。状态会在刷新后更新。' : 'Login complete. Status will refresh.'; - String get agentLoginOutput => isZh ? 'CLI 输出' : 'CLI output'; - String agentLoginFailed(Object err) => - isZh ? '登录失败:$err' : 'Login failed: $err'; + String credentialExpiresInDays(int days) => isZh + ? '还有 $days 天需要重新登录' + : 'Log in again in $days ${days == 1 ? 'day' : 'days'}'; + String get credentialExpiresToday => + isZh ? '今天之内需要重新登录' : 'Log in again within a day'; + String credentialExpiredDays(int days) => isZh + ? '已过期 $days 天,请立即在后端主机重新登录' + : 'Expired $days ${days == 1 ? 'day' : 'days'} ago. ' + 'Log in again on the backend host.'; + String get credentialExpiredToday => isZh + ? '凭据已过期,请立即在后端主机重新登录' + : 'Credential expired. Log in again on the backend host.'; String agentStatusRefreshFailed(Object err) => isZh ? '刷新智能体状态失败:$err' : 'Agent status refresh failed: $err'; String get importCredential => isZh ? '导入凭证' : 'Import credential'; @@ -427,18 +414,6 @@ class AppStrings { String get agentThinking => isZh ? '思考过程' : 'Thinking'; String agentSteps(int count) => isZh ? '执行步骤 · $count 条' : '$count ${count == 1 ? 'step' : 'steps'}'; - String get btwTitle => isZh ? 'BTW 副手' : 'BTW sidekick'; - String get btwSubtitle => isZh - ? '基于当前对话记忆的只读旁支问答,不影响主任务' - : 'Read-only side questions with the current chat\'s memory'; - String get btwHint => isZh ? '问一个旁支问题…' : 'Ask a side question…'; - String get btwTooltip => isZh ? 'BTW 旁支提问' : 'Ask a side question (BTW)'; - String get btwNeedsConversation => - isZh ? '先发送一条消息再使用 BTW' : 'Send a message first to use BTW'; - String get btwClearTitle => isZh ? '清空 BTW' : 'Clear BTW'; - String get btwEmpty => isZh - ? '在这里向副手提问,它了解当前主对话的内容。' - : 'Ask the sidekick here — it knows the current conversation.'; String startChat(String agent) => isZh ? '与 $agent 开始对话' : 'Start chatting with $agent'; String get chooseConversationTarget => isZh @@ -510,8 +485,8 @@ class AppStrings { String get licenseText => isZh ? '私有本地工具。' : 'Private local tool.'; String get copyright => isZh ? '© 2026 Relay' : '© 2026 Relay'; String get aboutDescription => isZh - ? '用于连接本机 Claude Code、Codex、Antigravity、OpenCode 和 Hermes CLI 智能体的私有控制台。' - : 'Private control surface for local Claude Code, Codex, Antigravity, OpenCode, and Hermes CLI agents.'; + ? '用于连接本机 Claude Code、Codex、OpenCode 和 Hermes CLI 智能体的私有控制台。' + : 'Private control surface for local Claude Code, Codex, OpenCode, and Hermes CLI agents.'; String get language => isZh ? '语言' : 'Language'; String get appearance => isZh ? '外观' : 'Appearance'; String get online => isZh ? '在线' : 'Online'; diff --git a/lib/core/models/cli_agent.dart b/lib/core/models/cli_agent.dart index 2ec056d..706489b 100644 --- a/lib/core/models/cli_agent.dart +++ b/lib/core/models/cli_agent.dart @@ -7,24 +7,28 @@ class CliAgent { this.authed = true, bool? usable, String? authKind, - }) : usable = usable ?? + this.credentialExpiresAt, + }) : usable = usable ?? (installed && (authed || key == 'opencode' || key == 'hermes')), - authKind = authKind ?? 'unknown'; + authKind = authKind ?? 'unknown'; factory CliAgent.fromJson(Map json) { final String key = json['key'] as String? ?? 'claude'; final bool installed = json['installed'] as bool? ?? true; final bool authed = json['authed'] as bool? ?? true; + final Object? expiresAt = json['credentialExpiresAt']; return CliAgent( key: key, label: json['label'] as String? ?? 'Claude Code', description: json['description'] as String? ?? '', installed: installed, authed: authed, - usable: - json['usable'] as bool? ?? + usable: json['usable'] as bool? ?? (installed && (authed || key == 'opencode' || key == 'hermes')), authKind: json['authKind'] as String? ?? defaultAuthKindForAgent(key), + credentialExpiresAt: expiresAt is num + ? DateTime.fromMillisecondsSinceEpoch(expiresAt.toInt()) + : null, ); } @@ -36,6 +40,12 @@ class CliAgent { final bool usable; final String authKind; + /// When the OAuth credential stored on the backend host runs out, so the app + /// can say how long is left before logging in there again. Null for agents + /// whose credential carries no expiry (an older backend, or a host-managed + /// API key). + final DateTime? credentialExpiresAt; + bool get selectable => usable; Map toJson() { @@ -47,6 +57,7 @@ class CliAgent { 'authed': authed, 'usable': usable, 'authKind': authKind, + 'credentialExpiresAt': credentialExpiresAt?.millisecondsSinceEpoch, }; } @@ -59,19 +70,57 @@ class CliAgent { other.installed == installed && other.authed == authed && other.usable == usable && - other.authKind == authKind; + other.authKind == authKind && + other.credentialExpiresAt == credentialExpiresAt; } @override - int get hashCode => - Object.hash(key, label, description, installed, authed, usable, authKind); + int get hashCode => Object.hash( + key, + label, + description, + installed, + authed, + usable, + authKind, + credentialExpiresAt, + ); +} + +/// How the stored credential stands right now: whole days until it expires, or +/// whole days since it did. Both sides truncate, so "1 day left" covers 24-48h +/// of runway and "expired 1 day ago" is at least a full day stale. +class CredentialExpiry { + const CredentialExpiry({required this.expired, required this.days}); + + factory CredentialExpiry.at(DateTime expiresAt, {DateTime? now}) { + final Duration left = expiresAt.difference(now ?? DateTime.now()); + return CredentialExpiry( + expired: left.isNegative, + days: left.inDays.abs(), + ); + } + + /// True once the expiry timestamp is in the past. + final bool expired; + + /// Whole days of runway left, or whole days since expiry. Zero means the + /// change happens (or happened) within a day. + final int days; +} + +/// Expiry state of [agent]'s credential, or null when it has none to report. +/// Only the OAuth agents (Claude Code, Codex) ever do. +CredentialExpiry? cliAgentCredentialExpiry(CliAgent agent, {DateTime? now}) { + final DateTime? expiresAt = agent.credentialExpiresAt; + if (expiresAt == null) return null; + return CredentialExpiry.at(expiresAt, now: now); } String defaultAuthKindForAgent(String key) { switch (key) { case 'claude': case 'codex': - case 'agy': return 'oauth'; case 'hermes': return 'apiKey'; @@ -99,12 +148,6 @@ const List defaultCliAgents = [ description: 'OpenAI Codex CLI', authKind: 'oauth', ), - CliAgent( - key: 'agy', - label: 'Antigravity', - description: 'Antigravity CLI', - authKind: 'oauth', - ), ]; /// Every agent the app knows how to label, including experimental ones that may diff --git a/lib/core/notifications/browser_notifications.dart b/lib/core/notifications/browser_notifications.dart index 6db1a9c..1b8e444 100644 --- a/lib/core/notifications/browser_notifications.dart +++ b/lib/core/notifications/browser_notifications.dart @@ -4,8 +4,13 @@ import 'browser_notifications_stub.dart' Future requestBrowserNotificationPermission() => platform.requestBrowserNotificationPermission(); +/// [tag] groups repeats of the same alert. The browser replaces a notification +/// that carries a tag it is already showing, so an alert that also arrives via +/// the push service worker (which tags its own notifications) collapses into a +/// single visible notification instead of stacking. Future showBrowserNotification({ required String title, required String body, + String? tag, }) => - platform.showBrowserNotification(title: title, body: body); + platform.showBrowserNotification(title: title, body: body, tag: tag); diff --git a/lib/core/notifications/browser_notifications_stub.dart b/lib/core/notifications/browser_notifications_stub.dart index 93879af..1271760 100644 --- a/lib/core/notifications/browser_notifications_stub.dart +++ b/lib/core/notifications/browser_notifications_stub.dart @@ -3,5 +3,6 @@ Future requestBrowserNotificationPermission() async {} Future showBrowserNotification({ required String title, required String body, + String? tag, }) async => false; diff --git a/lib/core/notifications/browser_notifications_web.dart b/lib/core/notifications/browser_notifications_web.dart index a4c44a4..c64cc0d 100644 --- a/lib/core/notifications/browser_notifications_web.dart +++ b/lib/core/notifications/browser_notifications_web.dart @@ -15,13 +15,19 @@ Future requestBrowserNotificationPermission() async { Future showBrowserNotification({ required String title, required String body, + String? tag, }) async { try { if (web.Notification.permission == 'default') { await requestBrowserNotificationPermission(); } if (web.Notification.permission != 'granted') return false; - web.Notification(title, web.NotificationOptions(body: body)); + web.Notification( + title, + tag == null + ? web.NotificationOptions(body: body) + : web.NotificationOptions(body: body, tag: tag), + ); return true; } catch (_) { return false; diff --git a/lib/core/notifications/notification_service.dart b/lib/core/notifications/notification_service.dart index 652be9d..765cde8 100644 --- a/lib/core/notifications/notification_service.dart +++ b/lib/core/notifications/notification_service.dart @@ -120,9 +120,18 @@ class NotificationService { /// Show an immediate system notification. Returns false when unsupported or /// denied so callers can show an in-page fallback. - Future show({required String title, required String body}) async { + /// + /// [tag] identifies the alert rather than this particular showing of it. Two + /// notifications sharing a tag replace one another instead of stacking, so an + /// alert that reaches the device twice (say over the event stream and again + /// as a push) is only ever seen once. Untagged notifications always stack. + Future show({ + required String title, + required String body, + String? tag, + }) async { if (kIsWeb) { - return showBrowserNotification(title: title, body: body); + return showBrowserNotification(title: title, body: body, tag: tag); } if (!_supported) return false; await init(); @@ -140,7 +149,7 @@ class NotificationService { WindowsNotificationDetails(); try { await _plugin.show( - id: _nextId++, + id: tag == null ? _nextId++ : _idForTag(tag), title: title, body: body, notificationDetails: const NotificationDetails( @@ -163,4 +172,15 @@ class NotificationService { return false; } } + + /// A stable, non-negative 31-bit id for a tag. The platform plugins key + /// replacement off this id, so it has to depend only on the tag — a counter + /// would make every repeat a new notification. + int _idForTag(String tag) { + int hash = 0; + for (final int unit in tag.codeUnits) { + hash = (hash * 31 + unit) & 0x3fffffff; + } + return hash; + } } diff --git a/lib/core/widgets/agent_icon.dart b/lib/core/widgets/agent_icon.dart index bd383d1..119c711 100644 --- a/lib/core/widgets/agent_icon.dart +++ b/lib/core/widgets/agent_icon.dart @@ -11,7 +11,6 @@ String? agentIconAssetPath(String key, Brightness brightness) { 'codex' => dark ? 'assets/agent_icons/codex_inverse.png' : 'assets/agent_icons/codex.png', - 'agy' => 'assets/agent_icons/agy.png', 'opencode' => 'assets/agent_icons/opencode.png', 'hermes' => 'assets/agent_icons/hermes.png', _ => null, diff --git a/lib/features/chat/agent_controls.dart b/lib/features/chat/agent_controls.dart index 7181914..921563a 100644 --- a/lib/features/chat/agent_controls.dart +++ b/lib/features/chat/agent_controls.dart @@ -11,6 +11,18 @@ import '../../core/models/agent_options.dart'; const List _groupOrder = ['model', 'effort', 'permission']; +/// Option catalogs describe what the installed CLI ships, not what the current +/// scope selected, so one fetch serves every workdir on a machine. They are kept +/// across opens because the composer rebuilds this widget every time its "+" +/// drawer opens: without a cache each open showed a spinner and then jumped to +/// its real height while the panel was still animating. +final Map _catalogCache = + {}; + +/// Drop the cached catalogs. Call this when the app switches machines: another +/// backend host can have different CLIs, and different versions of them. +void clearAgentOptionsCache() => _catalogCache.clear(); + IconData _groupIcon(String group) { switch (group) { case 'model': @@ -91,6 +103,12 @@ class AgentControlsButtons extends StatefulWidget { class _AgentControlsButtonsState extends State { AgentOptionsCatalog? _catalog; AgentSettings _settings = AgentSettings.empty; + // Settings are per workdir+agent, so unlike the catalog they are always + // fetched. Held as a future too: the buttons now render from the cached + // catalog before this lands, so a quick tap must wait for the real selection + // instead of opening a page on the defaults. + Future _settingsReady = + Future.value(AgentSettings.empty); bool _loading = true; bool _failed = false; @@ -108,42 +126,65 @@ class _AgentControlsButtonsState extends State { } } + void _adoptSettings(AgentSettings settings) { + _settings = settings; + _settingsReady = Future.value(settings); + } + Future _load() async { final String agentKey = widget.agentKey; + final AgentOptionsCatalog? cached = _catalogCache[agentKey]; + final Future pending = + widget.backend.fetchAgentSettings(agentKey); setState(() { - _loading = true; + _catalog = cached; + _settingsReady = pending; + // A cached catalog already says which buttons exist, so the panel opens at + // its final size and this fetch only corrects it. + _loading = cached == null; _failed = false; }); try { final List results = await Future.wait(>[ widget.backend.fetchAgentOptions(agentKey), - widget.backend.fetchAgentSettings(agentKey), + pending, ]); if (!mounted || agentKey != widget.agentKey) return; + final AgentOptionsCatalog catalog = results[0] as AgentOptionsCatalog; + _catalogCache[agentKey] = catalog; setState(() { - _catalog = results[0] as AgentOptionsCatalog; - _settings = results[1] as AgentSettings; + _catalog = catalog; + _adoptSettings(results[1] as AgentSettings); _loading = false; }); } catch (_) { - if (!mounted) return; + if (!mounted || agentKey != widget.agentKey) return; setState(() { _loading = false; - _failed = true; + // A cached catalog is still worth showing; only a cold failure is fatal. + _failed = _catalog == null; }); } } + Future _resolvedSettings() async { + try { + return await _settingsReady; + } catch (_) { + return _settings; + } + } + Future _openGroup(String group) async { final AgentOptionsCatalog? catalog = _catalog; if (catalog == null) return; - final String? modelId = catalog.resolveSelection( - 'model', - _settings['model'], - ); + final AgentSettings settings = await _resolvedSettings(); + if (!mounted) return; + final String? modelId = + catalog.resolveSelection('model', settings['model']); final String current = catalog.resolveSelection( group, - _settings[group], + settings[group], modelId: modelId, ) ?? ''; @@ -158,15 +199,17 @@ class _AgentControlsButtonsState extends State { catalog: catalog, current: current, modelId: modelId, - fastEnabled: _settings['fast'] == 'on', + fastEnabled: settings['fast'] == 'on', ), ), ); - if (!mounted) return; - if (result != null) { - setState(() => _settings = result); - } - await _load(); + if (!mounted || result == null) return; + // The page saved the selection and, if it updated the CLI, refreshed the + // cached catalog. Adopting both is what a reload would have fetched. + setState(() { + _catalog = _catalogCache[widget.agentKey] ?? catalog; + _adoptSettings(result); + }); } @override @@ -428,6 +471,9 @@ class _AgentOptionPageState extends State<_AgentOptionPage> { if (!mounted) return; final AgentOptionsCatalog options = refreshed[0] as AgentOptionsCatalog; final AgentSettings settings = refreshed[1] as AgentSettings; + // The new binary can ship different models, so replace what the composer + // will draw from next time it opens. + _catalogCache[widget.agentKey] = options; final String? modelId = options.resolveSelection( 'model', settings['model'], diff --git a/lib/features/chat/bot_chat_controller.dart b/lib/features/chat/bot_chat_controller.dart index 99fe1e4..efc3825 100644 --- a/lib/features/chat/bot_chat_controller.dart +++ b/lib/features/chat/bot_chat_controller.dart @@ -14,6 +14,7 @@ import '../../core/notifications/notification_service.dart'; import '../../core/notifications/web_push.dart'; import '../../core/settings/app_settings_controller.dart'; import '../../core/util/error_text.dart'; +import 'agent_controls.dart'; import 'background_turn_registry.dart'; class BotChatController extends ChangeNotifier { @@ -158,7 +159,7 @@ class BotChatController extends ChangeNotifier { String _agentLabelFor(String agentKey) => cliAgentByKey(agentKey).label; /// Login state for an agent CLI on the backend host: true/false when known, - /// or null when unchecked or undeterminable (e.g. agy). Drives the + /// or null when unchecked or undeterminable. Drives the /// "not logged in" banner; it never blocks sending, since detection is /// best-effort and a real failure is still caught when the turn runs. bool? agentLoggedIn(String agentKey) => _authStatus[agentKey]; @@ -344,6 +345,9 @@ class BotChatController extends ChangeNotifier { _clearSessionLists(); _clearBackgroundTurns(); _pendingDrafts.clear(); + // Another host can have different CLIs, versions, and quota. + clearAgentOptionsCache(); + _lastUsageReport = null; } if (sameContext && activeSessionId != null) { notifyListeners(); @@ -525,13 +529,28 @@ class BotChatController extends ChangeNotifier { } } - Future usageReport() => _backendClient.usageReport(); + UsageReport? _lastUsageReport; + + /// The most recent quota report, kept so the usage and scheduler screens can + /// paint the previous numbers immediately instead of holding a spinner for a + /// round trip that reaches Anthropic and OpenAI. Cleared on a machine switch, + /// since quota belongs to the host's credentials. + UsageReport? get lastUsageReport => _lastUsageReport; + + Future usageReport() async { + final UsageReport report = await _backendClient.usageReport(); + _lastUsageReport = report; + return report; + } // Registers this browser for Web Push so quota/scheduled-message alerts arrive // even when the tab is closed. Web-only and best-effort: a no-op off the web, // when the backend has no VAPID keys, or until the user grants permission // (retried on the next app open). Runs at most once per session. bool _pushSynced = false; + // Set only once a subscription is registered with the backend, so it means + // "push will reach this browser" rather than "we finished trying". + bool _webPushActive = false; Future syncPushSubscription({bool force = false}) async { if ((!force && _pushSynced) || _machine == null || !webPushSupported()) { return; @@ -551,6 +570,7 @@ class BotChatController extends ChangeNotifier { taskPushEnabled: _taskPushEnabled, ); _pushSynced = true; + _webPushActive = true; } catch (_) { // Best-effort; the next app open retries. } @@ -1770,20 +1790,41 @@ class BotChatController extends ChangeNotifier { } Future _showQuotaNotification(String message) async { - await _showNotificationOrSystemMessage(message); + // The backend sends every quota alert twice on purpose: once down the event + // stream for open sessions, and once as a push for closed ones. On the web + // the push service worker shows its copy whether or not the tab is focused, + // so a browser this backend can actually push to must not also show the + // event-stream copy — that is the duplicate. + if (_webPushDelivers(quota: true)) return; + await _showNotificationOrSystemMessage(message, tag: 'quota'); } Future _showBackgroundTurnNotification(BackgroundTurn turn) async { + if (_webPushDelivers(quota: false)) return; await _showNotificationOrSystemMessage( _strings.backgroundSessionFinished(turn.agentLabel, turn.sessionName), + tag: 'task:${turn.agentKey}:${turn.sessionId}', ); } - Future _showNotificationOrSystemMessage(String message) async { + /// Whether this browser's push subscription will already deliver an alert of + /// this category, making an in-page notification a duplicate. False off the + /// web, and false until a subscription is actually registered — a backend + /// with no VAPID keys never pushes, so the in-page copy stays the only one. + bool _webPushDelivers({required bool quota}) { + if (!kIsWeb || !_webPushActive) return false; + return quota ? _quotaPushEnabled : _taskPushEnabled; + } + + Future _showNotificationOrSystemMessage( + String message, { + String? tag, + }) async { try { final bool shown = await NotificationService.instance.show( title: 'Relay', body: message, + tag: tag, ); if (!shown) _appendSystemMessage(message); } catch (_) { diff --git a/lib/features/chat/bot_chat_screen.dart b/lib/features/chat/bot_chat_screen.dart index 2a46eca..5dda9c8 100644 --- a/lib/features/chat/bot_chat_screen.dart +++ b/lib/features/chat/bot_chat_screen.dart @@ -25,7 +25,6 @@ import '../machines/machine_credentials_screen.dart'; import '../settings/getting_started_screen.dart'; import 'agent_controls.dart'; import 'bot_chat_controller.dart'; -import 'btw_dialog.dart'; import 'chat_content.dart'; import 'group_chat_screen.dart'; @@ -57,6 +56,15 @@ class _BotChatScreenState extends State bool _agentsSynced = false; bool _agentsRefreshing = false; + // "Search chats" jump: the picked hit's message is scrolled into view, then + // flashed with the matched term marked inside it for a moment. The anchor key + // is what the scroll targets once the row is actually built. + String? _highlightMessageId; + String? _highlightQuery; + GlobalKey? _highlightAnchor; + Timer? _highlightTimer; + bool _revealingMatch = false; + @override void initState() { super.initState(); @@ -89,6 +97,7 @@ class _BotChatScreenState extends State widget.agentsController.removeListener(_onContextChanged); widget.machinesController.removeListener(_onContextChanged); widget.settingsController.removeListener(_onSettingsChanged); + _highlightTimer?.cancel(); _input.dispose(); _scroll.dispose(); super.dispose(); @@ -215,6 +224,9 @@ class _BotChatScreenState extends State final int count = widget.chatController.messageCount; final bool messageAdded = count != _lastMessageCount; _lastMessageCount = count; + // A search jump is driving the scroll position; don't yank it back down + // to the newest message while it walks toward the match. + if (_revealingMatch) return; final bool nearBottom = pos.pixels - pos.minScrollExtent < 280; // Follow streaming text only while pinned to the bottom; always snap when // a new message (user send / new reply bubble) is appended. @@ -247,18 +259,30 @@ class _BotChatScreenState extends State } Future _showHistorySearch() async { - final ChatHistorySearchResult? result = - await showDialog( + final ({ChatHistorySearchResult hit, String query})? picked = + await showDialog<({ChatHistorySearchResult hit, String query})>( context: context, builder: (BuildContext dialogContext) => _HistorySearchDialog(chatController: widget.chatController), ); - if (result == null) return; + if (picked == null || !mounted) return; + final ChatHistorySearchResult result = picked.hit; + final MachineCredential? machine = widget.machinesController.activeMachine; + if (machine == null) return; + final CliAgent agent = cliAgentByKey(result.agentKey); try { - await widget.chatController.selectSession( - cliAgentByKey(result.agentKey), - result.sessionId, - ); + // A hit can live under another agent, so move the whole UI across, not + // just the chat controller: otherwise the next context sync sees the + // agents controller still pointing at the old agent and loads it back. + if (!await widget.agentsController.setActive(agent.key)) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(agentUnavailableMessage(context.l10n, agent))), + ); + return; + } + await widget.chatController.loadFor(agent, machine); + await widget.chatController.selectSession(agent, result.sessionId); } catch (err) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( @@ -267,29 +291,70 @@ class _BotChatScreenState extends State backgroundColor: Theme.of(context).colorScheme.error, ), ); + return; } + if (!mounted) return; + await _revealSearchMatch(result.messageId, picked.query); } - Future _showBtw() async { - final CliAgent agent = widget.agentsController.activeAgent; - const Set btwAgents = {'claude', 'codex', 'agy'}; - if (!btwAgents.contains(agent.key)) return; - final String? sessionId = widget.chatController.activeSessionId; - if (widget.chatController.messageCount == 0 || - sessionId == null || - sessionId.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.btwNeedsConversation)), + /// Scrolls the now-loaded conversation to the matched message and flashes it, + /// with the search term marked inside the bubble, for a couple of seconds. + Future _revealSearchMatch(String messageId, String query) async { + final List messages = widget.chatController.messages; + final int index = messages.indexWhere( + (ChatMessage message) => message.id == messageId, + ); + if (index < 0) return; + _highlightTimer?.cancel(); + final GlobalKey anchor = GlobalKey(); + setState(() { + _highlightMessageId = messageId; + _highlightQuery = query; + _highlightAnchor = anchor; + _revealingMatch = true; + }); + // The list is reverse:true, so row 0 is the newest message. + await _scrollToRow(messages.length - 1 - index, messages.length, anchor); + if (!mounted) return; + _revealingMatch = false; + _highlightTimer = Timer(const Duration(milliseconds: 2600), () { + if (!mounted) return; + setState(() { + _highlightMessageId = null; + _highlightQuery = null; + _highlightAnchor = null; + }); + }); + } + + // The message list builds lazily, so the target row is usually not mounted + // yet and there is nothing to ensureVisible on. Walk toward it using the + // position's own average-extent estimate, which sharpens as more rows are + // built, then hand off to ensureVisible once the row exists. + Future _scrollToRow(int row, int rowCount, GlobalKey anchor) async { + for (int attempt = 0; attempt < 24; attempt++) { + await WidgetsBinding.instance.endOfFrame; + if (!mounted || !_scroll.hasClients) return; + final BuildContext? anchored = anchor.currentContext; + if (anchored != null) { + await Scrollable.ensureVisible( + anchored, + alignment: 0.5, + duration: const Duration(milliseconds: 240), + curve: Curves.easeOutCubic, + ); + return; + } + final ScrollPosition pos = _scroll.position; + final double average = + (pos.maxScrollExtent + pos.viewportDimension) / rowCount; + final double target = (row * average).clamp( + pos.minScrollExtent, + pos.maxScrollExtent, ); - return; + if ((target - pos.pixels).abs() < 1) return; + _scroll.jumpTo(target); } - await BtwDialog.show( - context, - backend: widget.chatController.backend, - agentKey: agent.key, - sessionId: sessionId, - language: widget.settingsController.language, - ); } Future _exportMarkdown() async { @@ -354,11 +419,6 @@ class _BotChatScreenState extends State chatController: widget.chatController, ), actions: [ - _BtwButton( - agentsController: widget.agentsController, - chatController: widget.chatController, - onPressed: _showBtw, - ), _SearchButton( chatController: widget.chatController, onPressed: _showHistorySearch, @@ -389,7 +449,6 @@ class _BotChatScreenState extends State machinesController: widget.machinesController, chatController: widget.chatController, onSearch: _showHistorySearch, - onBtw: _showBtw, ), ListenableBuilder( listenable: Listenable.merge([ @@ -402,7 +461,7 @@ class _BotChatScreenState extends State } final CliAgent agent = widget.agentsController.activeAgent; - // Only OAuth agents (claude/codex/agy) prompt to log in. + // Only OAuth agents (claude/codex) prompt to log in. // hermes/opencode manage their key on the host, so they // never show the "not logged in" banner. if (agent.authKind != 'oauth' || @@ -465,39 +524,50 @@ class _BotChatScreenState extends State )) { return _ChatNotice(text: message.content); } + final bool highlighted = + message.id == _highlightMessageId; + final Widget bubble = _MessageBubble( + message: message, + highlightQuery: highlighted + ? _highlightQuery + : null, + retryable: widget.chatController.isRetryable( + message, + ), + streaming: widget.chatController.isStreaming( + message, + ), + awaitingFirstToken: widget.chatController + .isAwaitingFirstToken(message), + errorDetail: widget.chatController + .errorDetailFor(message), + system: widget.chatController.isSystemMessage( + message, + ), + cancelled: widget.chatController.isCancelled( + message, + ), + queued: widget.chatController.isQueued(message), + progressLines: widget.chatController + .progressLinesFor(message), + onRetry: () => + widget.chatController.retry(message), + onCancelQueued: () => + widget.chatController.cancelQueued(message), + onOptionSelected: (String option) => + widget.chatController.sendUserText(option), + ); // RepaintBoundary isolates each bubble's painting so // a streaming bubble does not repaint the visible // history every frame. return RepaintBoundary( key: ValueKey(message.id), - child: _MessageBubble( - message: message, - retryable: widget.chatController.isRetryable( - message, - ), - streaming: widget.chatController.isStreaming( - message, - ), - awaitingFirstToken: widget.chatController - .isAwaitingFirstToken(message), - errorDetail: widget.chatController - .errorDetailFor(message), - system: widget.chatController.isSystemMessage( - message, - ), - cancelled: widget.chatController.isCancelled( - message, - ), - queued: widget.chatController.isQueued(message), - progressLines: widget.chatController - .progressLinesFor(message), - onRetry: () => - widget.chatController.retry(message), - onCancelQueued: () => - widget.chatController.cancelQueued(message), - onOptionSelected: (String option) => - widget.chatController.sendUserText(option), - ), + child: highlighted + ? _SearchMatchFlash( + key: _highlightAnchor, + child: bubble, + ) + : bubble, ); }, ), @@ -544,14 +614,12 @@ class _DesktopChatHeader extends StatelessWidget { required this.machinesController, required this.chatController, required this.onSearch, - required this.onBtw, }); final CliAgentsController agentsController; final MachineCredentialsController machinesController; final BotChatController chatController; final VoidCallback onSearch; - final VoidCallback onBtw; @override Widget build(BuildContext context) { @@ -574,11 +642,6 @@ class _DesktopChatHeader extends StatelessWidget { chatController: chatController, ), ), - _BtwButton( - agentsController: agentsController, - chatController: chatController, - onPressed: onBtw, - ), _SearchButton( chatController: chatController, onPressed: onSearch, @@ -647,57 +710,6 @@ class _ChatTitle extends StatelessWidget { } } -// The /btw sidekick entry point, sitting just left of search. It stays enabled -// while the main agent is working — that is exactly when a quick side question -// is useful. Empty conversations surface the normal "needs conversation" hint. -class _BtwButton extends StatelessWidget { - const _BtwButton({ - required this.agentsController, - required this.chatController, - required this.onPressed, - }); - - final CliAgentsController agentsController; - final BotChatController chatController; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: Listenable.merge([ - agentsController, - chatController, - ]), - builder: (BuildContext context, Widget? _) { - if (chatController.machine == null) { - return const SizedBox.shrink(); - } - final String agentKey = agentsController.activeAgent.key; - const Set btwAgents = {'claude', 'codex', 'agy'}; - if (!btwAgents.contains(agentKey)) { - return const SizedBox.shrink(); - } - return IconButton( - icon: const SizedBox( - width: 32, - child: Text( - 'BTW', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w800, - letterSpacing: 0, - ), - ), - ), - tooltip: context.l10n.btwTooltip, - onPressed: onPressed, - ); - }, - ); - } -} - class _SearchButton extends StatelessWidget { const _SearchButton({required this.chatController, required this.onPressed}); @@ -1216,6 +1228,9 @@ class _HistorySearchDialogState extends State<_HistorySearchDialog> { final TextEditingController _query = TextEditingController(); Future>? _future; bool _currentAgentOnly = false; + // The term the shown results actually came from, which is not necessarily + // what the field holds now. The caller highlights this one. + String _searchedFor = ''; @override void dispose() { @@ -1227,6 +1242,7 @@ class _HistorySearchDialogState extends State<_HistorySearchDialog> { final String query = _query.text.trim(); if (query.isEmpty) return; setState(() { + _searchedFor = query; _future = widget.chatController.searchHistory( query, currentAgentOnly: _currentAgentOnly, @@ -1271,7 +1287,9 @@ class _HistorySearchDialogState extends State<_HistorySearchDialog> { child: _HistorySearchResults( future: _future, onSelected: (ChatHistorySearchResult result) => - Navigator.of(context).pop(result), + Navigator.of(context).pop( + (hit: result, query: _searchedFor), + ), ), ), ], @@ -1690,21 +1708,70 @@ class _ChatNotice extends StatelessWidget { } } -// The turn's persisted execution steps, minus agy's generic "working" ping which -// carries no information once the answer is in (agy's real reasoning is folded -// from its plan preamble instead). +// The turn's persisted execution steps. List _persistedSteps(ChatMessage message) { final Object? raw = message.metadata['progressLines']; if (raw is! List) return const []; return raw .whereType() - .where( - (String line) => - line.trim().isNotEmpty && line != 'Antigravity is working...', - ) + .where((String line) => line.trim().isNotEmpty) .toList(growable: false); } +/// Pulses a tint behind the message a "search chats" jump landed on, so the eye +/// finds the row once the list stops scrolling. Only ever wraps that one row: +/// the fade runs on mount and the wrapper is dropped when the highlight clears. +class _SearchMatchFlash extends StatefulWidget { + const _SearchMatchFlash({required this.child, super.key}); + + final Widget child; + + @override + State<_SearchMatchFlash> createState() => _SearchMatchFlashState(); +} + +class _SearchMatchFlashState extends State<_SearchMatchFlash> + with SingleTickerProviderStateMixin { + late final AnimationController _fade = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 220), + reverseDuration: const Duration(milliseconds: 520), + ); + Timer? _hold; + + @override + void initState() { + super.initState(); + _fade.forward(); + _hold = Timer(const Duration(milliseconds: 1500), () { + if (mounted) _fade.reverse(); + }); + } + + @override + void dispose() { + _hold?.cancel(); + _fade.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final Color tint = Theme.of(context).colorScheme.tertiary; + return AnimatedBuilder( + animation: _fade, + builder: (BuildContext context, Widget? child) => DecoratedBox( + decoration: BoxDecoration( + color: tint.withValues(alpha: 0.18 * _fade.value), + borderRadius: BorderRadius.circular(14), + ), + child: child, + ), + child: widget.child, + ); + } +} + class _MessageBubble extends StatelessWidget { const _MessageBubble({ required this.message, @@ -1719,9 +1786,15 @@ class _MessageBubble extends StatelessWidget { required this.onRetry, required this.onCancelQueued, required this.onOptionSelected, + this.highlightQuery, }); final ChatMessage message; + + /// Set only while this bubble is the revealed "search chats" hit; marks the + /// term inside the rendered text. + final String? highlightQuery; + final bool retryable; final bool streaming; final bool awaitingFirstToken; @@ -1765,7 +1838,8 @@ class _MessageBubble extends StatelessWidget { segments.isNotEmpty ? segments.last.text : message.content, ) : null; - // agy opens with an "I will …" plan; fold it away on the finished bubble. + // Claude and Codex often open with an "I will …" plan; fold it away on the + // finished bubble. final ({String plan, String body})? planSplit = (!isUser && !system && !streaming && @@ -1811,6 +1885,7 @@ class _MessageBubble extends StatelessWidget { segments: segments, color: textColor, formatInlineEmphasis: !streaming, + highlightQuery: highlightQuery, ) else if (message.content.isNotEmpty) if (planSplit != null) @@ -1825,6 +1900,7 @@ class _MessageBubble extends StatelessWidget { text: planSplit.plan, color: textColor, formatInlineEmphasis: true, + highlightQuery: highlightQuery, ), ), const SizedBox(height: 8), @@ -1832,6 +1908,7 @@ class _MessageBubble extends StatelessWidget { text: planSplit.body, color: textColor, formatInlineEmphasis: true, + highlightQuery: highlightQuery, ), ], ) @@ -1840,6 +1917,7 @@ class _MessageBubble extends StatelessWidget { text: message.content, color: textColor, formatInlineEmphasis: !isUser && !streaming, + highlightQuery: highlightQuery, ), if (optionPrompt != null) OptionButtons( diff --git a/lib/features/chat/btw_controller.dart b/lib/features/chat/btw_controller.dart deleted file mode 100644 index b17a742..0000000 --- a/lib/features/chat/btw_controller.dart +++ /dev/null @@ -1,192 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; - -import '../../core/backend/backend_client.dart'; -import '../../core/i18n/app_strings.dart'; -import '../../core/models/chat_message.dart'; -import '../../core/settings/app_settings_controller.dart'; -import '../../core/util/error_text.dart'; - -/// Drives the /btw sidekick popup: a small, read-only side chat that forks the -/// main conversation's memory on the backend. It is intentionally simpler than -/// [BotChatController] — one conversation, no sessions, no cross-device mirror, -/// no queue — because it never participates in the actual task. -class BtwController extends ChangeNotifier { - BtwController({ - required BackendClient backendClient, - required this.agentKey, - required this.sessionId, - required AppLanguage language, - }) : _backend = backendClient, - _language = language; - - static const String _streamingKey = 'streaming'; - static const String _awaitingKey = 'awaitingFirstToken'; - static const String _requestIdKey = 'requestId'; - static const String _errorKey = 'errorDetail'; - - final BackendClient _backend; - final String agentKey; - final String sessionId; - AppLanguage _language; - - final List _messages = []; - String? _activeRequestId; - bool _loading = false; - bool _disposed = false; - String? _lastError; - - void _notify() { - if (!_disposed) notifyListeners(); - } - - @override - void dispose() { - _disposed = true; - // Closing the popup mid-answer stops the side turn on the backend too. - final String? requestId = _activeRequestId; - if (requestId != null) { - unawaited(_backend.cancelMessage(requestId).catchError((_) {})); - } - super.dispose(); - } - - List get messages => List.unmodifiable(_messages); - bool get isThinking => _activeRequestId != null; - bool get isLoading => _loading; - String? get lastError => _lastError; - AppStrings get _strings => AppStrings(_language); - - void setLanguage(AppLanguage language) => _language = language; - - Future load() async { - _loading = true; - _notify(); - try { - final List history = - await _backend.fetchBtwHistory(agentKey, sessionId: sessionId); - _messages - ..clear() - ..addAll(history); - } catch (_) { - // A side chat that fails to load just starts empty. - } finally { - _loading = false; - _notify(); - } - } - - Future send(String rawText) async { - final String text = rawText.trim(); - if (text.isEmpty || isThinking) return; - _lastError = null; - _messages.add(ChatMessage.user(text)); - - final String requestId = 'btw.${DateTime.now().microsecondsSinceEpoch}'; - _activeRequestId = requestId; - final ChatMessage placeholder = ChatMessage.assistant( - '', - metadata: { - _streamingKey: true, - _awaitingKey: true, - _requestIdKey: requestId, - }, - ); - _messages.add(placeholder); - _notify(); - - final StringBuffer buffer = StringBuffer(); - try { - final ChatReply reply = await _backend.sendBtwMessage( - agentKey: agentKey, - sessionId: sessionId, - prompt: text, - requestId: requestId, - onEvent: (BackendEvent event) { - switch (event.type) { - case 'agent_delta': - if (event.data['requestId'] != requestId) return; - buffer.write(event.data['text'] as String? ?? ''); - _updatePlaceholder(requestId, buffer.toString(), streaming: true); - break; - case 'agent_segment': - if (event.data['requestId'] != requestId) return; - if (buffer.isNotEmpty) buffer.write('\n\n'); - break; - } - }, - ); - _updatePlaceholder(requestId, reply.content, streaming: false); - } catch (err) { - if (err is BackendException && err.code == 'AGENT_CANCELLED') { - _updatePlaceholder(requestId, buffer.toString(), streaming: false); - } else { - final String detail = friendlyErrorText(_strings, err); - _lastError = detail; - _updatePlaceholder( - requestId, - buffer.toString(), - streaming: false, - error: detail, - ); - } - } finally { - if (_activeRequestId == requestId) _activeRequestId = null; - _notify(); - } - } - - Future cancel() async { - final String? requestId = _activeRequestId; - if (requestId == null) return; - try { - await _backend.cancelMessage(requestId); - } catch (_) { - // The turn may have already finished; the send path settles the state. - } - } - - Future clear() async { - if (isThinking) return; - try { - await _backend.clearBtw(agentKey, sessionId); - _messages.clear(); - _lastError = null; - _notify(); - } catch (err) { - _lastError = friendlyErrorText(_strings, err); - _notify(); - } - } - - void _updatePlaceholder( - String requestId, - String content, { - required bool streaming, - String? error, - }) { - final int index = _messages.lastIndexWhere( - (ChatMessage m) => - !m.isUser && m.metadata[_requestIdKey] == requestId, - ); - if (index == -1) return; - _messages[index] = _messages[index].copyWith( - content: content, - metadata: { - ..._messages[index].metadata, - _streamingKey: streaming, - _awaitingKey: streaming && content.isEmpty, - if (error != null) _errorKey: error, - }, - ); - _notify(); - } - - bool isStreaming(ChatMessage message) => - message.metadata[_streamingKey] == true; - bool isAwaiting(ChatMessage message) => - message.metadata[_awaitingKey] == true; - String? errorDetailFor(ChatMessage message) => - message.metadata[_errorKey] as String?; -} diff --git a/lib/features/chat/btw_dialog.dart b/lib/features/chat/btw_dialog.dart deleted file mode 100644 index 443958a..0000000 --- a/lib/features/chat/btw_dialog.dart +++ /dev/null @@ -1,325 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; - -import '../../core/backend/backend_client.dart'; -import '../../core/i18n/app_strings.dart'; -import '../../core/models/chat_message.dart'; -import '../../core/settings/app_settings_controller.dart'; -import '../../core/util/time_format.dart'; -import 'btw_controller.dart'; - -/// The /btw sidekick popup. Opened from the chat header; it forks the current -/// conversation on the backend so the side chat shares its memory but never -/// touches the main task. -class BtwDialog extends StatefulWidget { - const BtwDialog({ - required this.backend, - required this.agentKey, - required this.sessionId, - required this.language, - super.key, - }); - - final BackendClient backend; - final String agentKey; - final String sessionId; - final AppLanguage language; - - static Future show( - BuildContext context, { - required BackendClient backend, - required String agentKey, - required String sessionId, - required AppLanguage language, - }) { - return showDialog( - context: context, - builder: (BuildContext _) => BtwDialog( - backend: backend, - agentKey: agentKey, - sessionId: sessionId, - language: language, - ), - ); - } - - @override - State createState() => _BtwDialogState(); -} - -class _BtwDialogState extends State { - late final BtwController _controller; - final TextEditingController _input = TextEditingController(); - final ScrollController _scroll = ScrollController(); - - @override - void initState() { - super.initState(); - _controller = BtwController( - backendClient: widget.backend, - agentKey: widget.agentKey, - sessionId: widget.sessionId, - language: widget.language, - ); - _controller.addListener(_onChanged); - _controller.load(); - } - - @override - void dispose() { - _controller.removeListener(_onChanged); - _controller.dispose(); - _input.dispose(); - _scroll.dispose(); - super.dispose(); - } - - void _onChanged() { - if (!mounted) return; - setState(() {}); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_scroll.hasClients) { - _scroll.jumpTo(_scroll.position.minScrollExtent); - } - }); - } - - Future _send() async { - final String text = _input.text; - if (text.trim().isEmpty || _controller.isThinking) return; - _input.clear(); - await _controller.send(text); - } - - @override - Widget build(BuildContext context) { - final ColorScheme colors = Theme.of(context).colorScheme; - final List messages = _controller.messages; - return Dialog( - insetPadding: const EdgeInsets.all(16), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 560, maxHeight: 680), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _header(context, colors), - const Divider(height: 1), - Expanded( - child: _controller.isLoading && messages.isEmpty - ? const Center(child: CircularProgressIndicator()) - : messages.isEmpty - ? _empty(context, colors) - // SelectionArea keeps bubble text selectable without - // per-bubble overlay-based SelectableText, which crashed - // on teardown (InheritedElement '_dependents.isEmpty'). - : SelectionArea( - child: ListView.builder( - controller: _scroll, - reverse: true, - padding: const EdgeInsets.fromLTRB(14, 10, 14, 14), - itemCount: messages.length, - itemBuilder: (BuildContext context, int index) { - final ChatMessage message = - messages[messages.length - 1 - index]; - return _BtwBubble( - message: message, - awaiting: _controller.isAwaiting(message), - errorDetail: _controller.errorDetailFor(message), - ); - }, - ), - ), - ), - const Divider(height: 1), - _inputBar(context, colors), - ], - ), - ), - ); - } - - Widget _header(BuildContext context, ColorScheme colors) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 8, 12), - child: Row( - children: [ - Text( - 'BTW', - style: TextStyle( - color: colors.primary, - fontSize: 13, - fontWeight: FontWeight.w800, - letterSpacing: 0, - ), - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - context.l10n.btwTitle, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - Text( - context.l10n.btwSubtitle, - style: TextStyle(fontSize: 11, color: colors.outline), - ), - ], - ), - ), - IconButton( - icon: const Icon(Icons.delete_outline_rounded), - tooltip: context.l10n.btwClearTitle, - onPressed: _controller.isThinking ? null : _controller.clear, - ), - IconButton( - icon: const Icon(Icons.close_rounded), - tooltip: context.l10n.close, - onPressed: () => Navigator.of(context).pop(), - ), - ], - ), - ); - } - - Widget _empty(BuildContext context, ColorScheme colors) { - return Center( - child: Padding( - padding: const EdgeInsets.all(28), - child: Text( - context.l10n.btwEmpty, - textAlign: TextAlign.center, - style: TextStyle(color: colors.outline), - ), - ), - ); - } - - Widget _inputBar(BuildContext context, ColorScheme colors) { - final bool thinking = _controller.isThinking; - return Padding( - padding: const EdgeInsets.fromLTRB(12, 10, 12, 12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: TextField( - controller: _input, - minLines: 1, - maxLines: 5, - textInputAction: TextInputAction.newline, - onChanged: (_) => setState(() {}), - decoration: InputDecoration( - hintText: context.l10n.btwHint, - filled: true, - fillColor: colors.surface, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide.none, - ), - contentPadding: - const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - ), - ), - ), - const SizedBox(width: 8), - SizedBox.square( - dimension: 44, - child: IconButton.filledTonal( - onPressed: thinking - ? _controller.cancel - : _input.text.trim().isEmpty - ? null - : _send, - icon: Icon( - thinking ? Icons.stop_rounded : Icons.arrow_upward_rounded, - ), - tooltip: thinking ? context.l10n.stop : context.l10n.send, - ), - ), - ], - ), - ); - } -} - -class _BtwBubble extends StatelessWidget { - const _BtwBubble({ - required this.message, - required this.awaiting, - required this.errorDetail, - }); - - final ChatMessage message; - final bool awaiting; - final String? errorDetail; - - @override - Widget build(BuildContext context) { - final ColorScheme colors = Theme.of(context).colorScheme; - final bool isUser = message.isUser; - final Color bubbleColor = - isUser ? colors.primary : colors.surfaceContainerHighest; - final Color textColor = isUser ? colors.onPrimary : colors.onSurface; - final TextStyle textStyle = - TextStyle(color: textColor, height: 1.4, fontSize: 14); - return Align( - alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, - child: Column( - crossAxisAlignment: - isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, - children: [ - Container( - constraints: BoxConstraints( - maxWidth: MediaQuery.sizeOf(context).width * 0.78, - ), - margin: const EdgeInsets.symmetric(vertical: 4), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), - decoration: BoxDecoration( - color: bubbleColor, - borderRadius: BorderRadius.circular(12), - border: isUser ? null : Border.all(color: colors.outlineVariant), - ), - child: awaiting && message.content.isEmpty - ? SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - color: textColor, - ), - ) - : isUser - ? Text(message.content, style: textStyle) - : MarkdownBody( - data: message.content, - selectable: false, - styleSheet: MarkdownStyleSheet(p: textStyle), - ), - ), - if (errorDetail != null && errorDetail!.isNotEmpty) - Padding( - padding: const EdgeInsets.only(left: 4, bottom: 2), - child: Text( - errorDetail!, - style: TextStyle(fontSize: 11, color: colors.error), - ), - ) - else if (!(awaiting && message.content.isEmpty)) - Padding( - padding: const EdgeInsets.only(left: 4, right: 4, bottom: 2), - child: Text( - formatShortTime(context, message.createdAt.toIso8601String()), - style: TextStyle(fontSize: 10, color: colors.outline), - ), - ), - ], - ), - ); - } -} diff --git a/lib/features/chat/chat_content.dart b/lib/features/chat/chat_content.dart index 794d0a1..687094f 100644 --- a/lib/features/chat/chat_content.dart +++ b/lib/features/chat/chat_content.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:markdown/markdown.dart' as md; import '../../core/i18n/app_strings.dart'; import '../../core/models/chat_message.dart'; @@ -20,6 +21,7 @@ class SegmentedContent extends StatefulWidget { required this.segments, required this.color, required this.formatInlineEmphasis, + this.highlightQuery, super.key, }); @@ -27,6 +29,9 @@ class SegmentedContent extends StatefulWidget { final Color color; final bool formatInlineEmphasis; + /// See [MessageText.highlightQuery]. + final String? highlightQuery; + @override State createState() => _SegmentedContentState(); } @@ -43,6 +48,7 @@ class _SegmentedContentState extends State { text: segment.text, color: widget.color, formatInlineEmphasis: widget.formatInlineEmphasis, + highlightQuery: widget.highlightQuery, ), if (segment.createdAt != null) Padding( @@ -71,6 +77,14 @@ class _SegmentedContentState extends State { nonEmpty.sublist(0, nonEmpty.length - 1); final MessageSegment last = nonEmpty.last; final Color toggleColor = widget.color.withValues(alpha: 0.7); + // A search hit inside a collapsed progress update would otherwise be marked + // where nobody can see it, so reveal the stack while it is highlighted. + final String needle = widget.highlightQuery?.trim().toLowerCase() ?? ''; + final bool expanded = _expanded || + (needle.isNotEmpty && + earlier.any( + (MessageSegment s) => s.text.toLowerCase().contains(needle), + )); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -85,7 +99,7 @@ class _SegmentedContentState extends State { mainAxisSize: MainAxisSize.min, children: [ Icon( - _expanded + expanded ? Icons.expand_less_rounded : Icons.expand_more_rounded, size: 16, @@ -104,7 +118,7 @@ class _SegmentedContentState extends State { ), ), ), - if (_expanded) ...[ + if (expanded) ...[ const SizedBox(height: 6), for (final MessageSegment segment in earlier) ...[ _segmentText(segment), @@ -130,6 +144,7 @@ class MessageText extends StatefulWidget { required this.text, required this.color, required this.formatInlineEmphasis, + this.highlightQuery, super.key, }); @@ -137,6 +152,10 @@ class MessageText extends StatefulWidget { final Color color; final bool formatInlineEmphasis; + /// Term to mark inside this message while a "search chats" hit is being + /// revealed. Null in the normal case, which renders without any marking. + final String? highlightQuery; + @override State createState() => _MessageTextState(); } @@ -155,7 +174,8 @@ class _MessageTextState extends State { super.didUpdateWidget(oldWidget); if (oldWidget.text != widget.text || oldWidget.color != widget.color || - oldWidget.formatInlineEmphasis != widget.formatInlineEmphasis) { + oldWidget.formatInlineEmphasis != widget.formatInlineEmphasis || + oldWidget.highlightQuery != widget.highlightQuery) { _cached = null; } } @@ -178,19 +198,108 @@ class _MessageTextState extends State { height: 1.45, fontSize: 15, ); + final String needle = widget.highlightQuery?.trim() ?? ''; if (!widget.formatInlineEmphasis) { // Not SelectableText: a wrapping SelectionArea (see the message lists) // handles selection. SelectableText / MarkdownBody(selectable: true) // build overlay-based selection that registers a dependency on the // enclosing Scrollable and throws InheritedElement '_dependents.isEmpty' // when the conversation is torn down (e.g. creating/switching a session). - return Text(widget.text, style: style); + if (needle.isEmpty) return Text(widget.text, style: style); + return Text.rich(_markedSpan(widget.text, needle, style)); } return MarkdownBody( data: _normalizeAgentMarkdown(widget.text), selectable: false, softLineBreak: true, styleSheet: _markdownStyleSheet(context, widget.color, style), + // The search term is marked by parsing it as its own inline element, so + // the surrounding markdown still renders normally around it. + inlineSyntaxes: needle.isEmpty + ? null + : [_SearchHitSyntax(needle)], + builders: needle.isEmpty + ? const {} + : { + _searchHitTag: _SearchHitBuilder(style), + }, + ); + } +} + +// Marker-pen colours rather than scheme colours: the mark has to stay readable +// on both bubble backgrounds (primary for the user, surface for the agent) in +// both themes, and reading as "search highlight" matters more than blending in. +const Color _searchHitBackground = Color(0xFFFFD54F); +const Color _searchHitForeground = Color(0xDD000000); +const String _searchHitTag = 'relaySearchHit'; + +// Colour only, no weight or size change: the mark comes and goes on its own +// timer and must not reflow the paragraph under it. +TextStyle _searchHitStyle(TextStyle? base) { + return (base ?? const TextStyle()).copyWith( + backgroundColor: _searchHitBackground, + color: _searchHitForeground, + ); +} + +/// Splits [text] on every case-insensitive occurrence of [needle], marking the +/// matches. Used for the plain-text (non-markdown) rendering path. +TextSpan _markedSpan(String text, String needle, TextStyle base) { + final TextStyle hit = _searchHitStyle(base); + final String haystack = text.toLowerCase(); + final String lowered = needle.toLowerCase(); + final List spans = []; + int cursor = 0; + while (true) { + final int at = haystack.indexOf(lowered, cursor); + if (at < 0) break; + if (at > cursor) { + spans.add(TextSpan(text: text.substring(cursor, at))); + } + spans.add( + TextSpan(text: text.substring(at, at + lowered.length), style: hit), + ); + cursor = at + lowered.length; + } + if (cursor < text.length) { + spans.add(TextSpan(text: text.substring(cursor))); + } + return TextSpan(style: base, children: spans); +} + +/// Turns each occurrence of the search term into a [_searchHitTag] element so +/// the markdown builder can paint it without disturbing the rest of the parse. +class _SearchHitSyntax extends md.InlineSyntax { + _SearchHitSyntax(String query) + : super(RegExp.escape(query), caseSensitive: false); + + @override + bool onMatch(md.InlineParser parser, Match match) { + parser.addNode(md.Element.text(_searchHitTag, match[0]!)); + return true; + } +} + +class _SearchHitBuilder extends MarkdownElementBuilder { + _SearchHitBuilder(this.base); + + final TextStyle base; + + @override + Widget visitElementAfterWithContext( + BuildContext context, + md.Element element, + TextStyle? preferredStyle, + TextStyle? parentStyle, + ) { + // parentStyle carries whatever the enclosing markdown (bold, list, heading) + // resolved to, so the mark keeps the surrounding weight and size. + return Text.rich( + TextSpan( + text: element.textContent, + style: _searchHitStyle(parentStyle ?? base), + ), ); } } @@ -353,8 +462,8 @@ String _stripInlineMarkdown(String value) { } /// Splits a leading "here's my plan" preamble off an assistant answer so it can -/// be folded away. agy (Antigravity) habitually opens with one or more "I will …" -/// / "我将 …" planning paragraphs before the real answer. Returns (plan, body) when +/// be folded away. Claude and Codex often open with one or more "I will …" / +/// "我将 …" planning paragraphs before the real answer. Returns (plan, body) when /// such a preamble sits above a non-empty body, else null (so a message that is /// nothing but plan is never hidden). ({String plan, String body})? splitLeadingPlan(String text) { diff --git a/lib/features/chat/group_chat_screen.dart b/lib/features/chat/group_chat_screen.dart index 1ee5a3a..6b59dd8 100644 --- a/lib/features/chat/group_chat_screen.dart +++ b/lib/features/chat/group_chat_screen.dart @@ -1217,7 +1217,7 @@ class _SwarmFormDialogState extends State<_SwarmFormDialog> { config[group], modelId: modelId, ); - // Bound the width and let the button ellipsize: some catalogs (agy, opencode) + // Bound the width and let the button ellipsize: some catalogs (opencode) // have long labels that would otherwise overflow the row. return SizedBox( width: 188, diff --git a/lib/features/cli_agents/agent_status_lights.dart b/lib/features/cli_agents/agent_status_lights.dart index 7b03927..26c2f89 100644 --- a/lib/features/cli_agents/agent_status_lights.dart +++ b/lib/features/cli_agents/agent_status_lights.dart @@ -15,6 +15,26 @@ String agentUnavailableMessage(AppStrings strings, CliAgent agent) { } } +/// How long the agent's stored credential still has on the backend host, or +/// null when it reports no expiry. Relay cannot log the CLI in remotely, so the +/// message says when a login on that host is due rather than offering an action. +String? agentCredentialExpiryMessage( + AppStrings strings, + CliAgent agent, { + DateTime? now, +}) { + final CredentialExpiry? expiry = cliAgentCredentialExpiry(agent, now: now); + if (expiry == null) return null; + if (expiry.expired) { + return expiry.days > 0 + ? strings.credentialExpiredDays(expiry.days) + : strings.credentialExpiredToday; + } + return expiry.days > 0 + ? strings.credentialExpiresInDays(expiry.days) + : strings.credentialExpiresToday; +} + void showAgentUnavailableSnack(BuildContext context, CliAgent agent) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(agentUnavailableMessage(context.l10n, agent))), @@ -34,7 +54,7 @@ class AgentStatusLights extends StatelessWidget { @override Widget build(BuildContext context) { final AppStrings strings = context.l10n; - // Only OAuth agents (claude/codex/agy) show the second "logged in" light. + // Only OAuth agents (claude/codex) show the second "logged in" light. // hermes/opencode manage their key on the host out of Relay's view, so they // get just the install light and count as usable once installed. final bool showAuthLight = agent.authKind == 'oauth'; diff --git a/lib/features/cli_agents/cli_agents_drawer.dart b/lib/features/cli_agents/cli_agents_drawer.dart index 85f1a72..e68b810 100644 --- a/lib/features/cli_agents/cli_agents_drawer.dart +++ b/lib/features/cli_agents/cli_agents_drawer.dart @@ -734,47 +734,54 @@ class _ActiveMachineStatusTileState extends State { ); } - return Container( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( + // The tile's background must come from a Material, not a plain decoration: + // ListTile paints its ink splash on the nearest Material ancestor, so a + // DecoratedBox in between would hide the tap feedback. + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Material( color: Theme.of(context).colorScheme.surfaceContainerLow, borderRadius: BorderRadius.circular(12), - ), - child: ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - leading: _isLoading - ? const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Icon( - Icons.lens, - color: _isOnline - ? const Color(0xFF10B981) - : const Color(0xFFEF4444), - size: 14, - ), - title: Text( - machine.displayName, - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), - ), - subtitle: Text( - _isLoading - ? context.l10n.loadingStatus - : (_isOnline ? context.l10n.online : context.l10n.offline), - style: TextStyle( - color: _isLoading - ? Theme.of(context).colorScheme.outline - : (_isOnline + clipBehavior: Clip.antiAlias, + child: ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + leading: _isLoading + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon( + Icons.lens, + color: _isOnline ? const Color(0xFF10B981) - : const Color(0xFFEF4444)), - fontSize: 12, - fontWeight: FontWeight.w500, + : const Color(0xFFEF4444), + size: 14, + ), + title: Text( + machine.displayName, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + subtitle: Text( + _isLoading + ? context.l10n.loadingStatus + : (_isOnline ? context.l10n.online : context.l10n.offline), + style: TextStyle( + color: _isLoading + ? Theme.of(context).colorScheme.outline + : (_isOnline + ? const Color(0xFF10B981) + : const Color(0xFFEF4444)), + fontSize: 12, + fontWeight: FontWeight.w500, + ), ), + trailing: const Icon(Icons.chevron_right, size: 20), + onTap: _showStatusDialog, ), - trailing: const Icon(Icons.chevron_right, size: 20), - onTap: _showStatusDialog, ), ); } diff --git a/lib/features/machines/agent_login_flow_controller.dart b/lib/features/machines/agent_login_flow_controller.dart deleted file mode 100644 index d6c6fec..0000000 --- a/lib/features/machines/agent_login_flow_controller.dart +++ /dev/null @@ -1,155 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; - -import '../../core/backend/backend_client.dart'; - -enum AgentLoginPhase { - idle, - starting, - waitingForUrl, - readyForCode, - submitting, - done, - error, -} - -class AgentLoginFlowController extends ChangeNotifier { - AgentLoginFlowController({ - required Stream Function(String agentKey) startLogin, - required Future Function(String sessionId, String code) submitCode, - }) : _startLogin = startLogin, - _submitCode = submitCode; - - final Stream Function(String agentKey) _startLogin; - final Future Function(String sessionId, String code) _submitCode; - - StreamSubscription? _subscription; - AgentLoginPhase _phase = AgentLoginPhase.idle; - String? _sessionId; - String? _url; - String _output = ''; - String? _error; - bool _requiresCode = true; - - AgentLoginPhase get phase => _phase; - String? get sessionId => _sessionId; - String? get url => _url; - String get output => _output; - String? get error => _error; - bool get requiresCode => _requiresCode; - - bool get canSubmitCode => - _requiresCode && - _sessionId != null && - _sessionId!.isNotEmpty && - (_phase == AgentLoginPhase.readyForCode || - _phase == AgentLoginPhase.waitingForUrl); - - Future start(String agentKey) async { - await _subscription?.cancel(); - _sessionId = null; - _url = null; - _output = ''; - _error = null; - _requiresCode = true; - _setPhase(AgentLoginPhase.starting); - try { - _subscription = _startLogin(agentKey).listen( - _handleEvent, - onError: (Object err) { - _error = _messageFor(err); - _setPhase(AgentLoginPhase.error); - }, - ); - } catch (err) { - _error = _messageFor(err); - _setPhase(AgentLoginPhase.error); - } - } - - Future submitCode(String code) async { - if (!_requiresCode) { - _error = 'Login session does not accept an authorization code.'; - _setPhase(AgentLoginPhase.error); - return; - } - final String? id = _sessionId; - if (id == null || id.isEmpty) { - _error = 'Login session is not ready.'; - _setPhase(AgentLoginPhase.error); - return; - } - _setPhase(AgentLoginPhase.submitting); - try { - await _submitCode(id, code.trim()); - } catch (err) { - _error = _messageFor(err); - _setPhase(AgentLoginPhase.error); - } - } - - void _handleEvent(BackendEvent event) { - final String? eventSession = event.data['sessionId']?.toString(); - if (eventSession != null && eventSession.isNotEmpty) { - _sessionId = eventSession; - } - final Object? requiresCode = event.data['requiresCode']; - if (requiresCode is bool) { - _requiresCode = requiresCode; - } - switch (event.type) { - case 'login_started': - if (_phase == AgentLoginPhase.starting || - _phase == AgentLoginPhase.idle) { - _setPhase(AgentLoginPhase.waitingForUrl); - } else { - notifyListeners(); - } - break; - case 'login_url': - _url = event.data['url']?.toString(); - _setPhase(AgentLoginPhase.readyForCode); - break; - case 'login_output': - final String text = event.data['text']?.toString() ?? ''; - if (text.isNotEmpty) { - _output = (_output + text).trim(); - if (_output.length > 4000) { - _output = _output.substring(_output.length - 4000); - } - } - if (_phase == AgentLoginPhase.starting) { - _setPhase(AgentLoginPhase.waitingForUrl); - } else { - notifyListeners(); - } - break; - case 'login_done': - _setPhase(AgentLoginPhase.done); - break; - case 'login_error': - _error = event.data['error']?.toString() ?? 'Login failed.'; - _setPhase(AgentLoginPhase.error); - break; - default: - notifyListeners(); - } - } - - void _setPhase(AgentLoginPhase value) { - _phase = value; - notifyListeners(); - } - - String _messageFor(Object err) { - if (err is BackendException) return err.message; - return err.toString(); - } - - @override - void dispose() { - _subscription?.cancel(); - super.dispose(); - } -} diff --git a/lib/features/machines/deploy_backend_screen.dart b/lib/features/machines/deploy_backend_screen.dart index 35dd2d5..9724024 100644 --- a/lib/features/machines/deploy_backend_screen.dart +++ b/lib/features/machines/deploy_backend_screen.dart @@ -260,8 +260,8 @@ const List<_DeployStep> _zhSteps = <_DeployStep>[ _DeployStep( title: '准备一台后端机器', body: '一台你自己的电脑或服务器都行:家里的 PC、Mac,或一台云服务器。' - '先装好 Node.js 18+ 和至少一个 CLI 智能体(Claude Code、Codex、Antigravity 等)。' - '可在主机上登录,兼容的 OAuth agent 也可稍后在 Relay 中登录。', + '先装好 Node.js 18+ 和至少一个 CLI 智能体(Claude Code、Codex、OpenCode 或 Hermes),' + '并直接在主机上完成登录或 provider 配置;Relay 不代办 CLI 登录。', ), _DeployStep( title: '下载 Relay,运行安装脚本', @@ -282,7 +282,7 @@ const List<_DeployStep> _zhSteps = <_DeployStep>[ ), _DeployStep( title: '回到本页,连接前端', - body: '回到这个页面,三种方式任选其一:扫描二维码、上传二维码图片,或粘贴 JSON 内容。' + body: '回到这个页面:移动端可扫描二维码;所有平台都可上传二维码图片或粘贴 JSON 内容。' '然后输入你生成凭证时设置的密码。连接成功后,就能在应用里直接指挥后端的智能体了。', ), ]; @@ -292,8 +292,8 @@ const List<_DeployStep> _enSteps = <_DeployStep>[ title: 'Prepare a backend machine', body: 'Any computer you own works: a home PC, a Mac, or a cloud server. ' 'Install Node.js 18+ and at least one CLI agent (Claude Code, Codex, ' - 'Antigravity, …). Log in on the host, or use Relay later for a ' - 'compatible OAuth agent.', + 'OpenCode, or Hermes), then complete its login or provider setup on ' + 'that host. Relay does not perform CLI login.', ), _DeployStep( title: 'Download Relay and run the setup script', @@ -318,8 +318,8 @@ const List<_DeployStep> _enSteps = <_DeployStep>[ ), _DeployStep( title: 'Come back here and connect', - body: 'Return to this screen and use any one option: scan the QR code, ' - 'upload the QR image, or paste the JSON. Then enter the password you ' + body: 'Return to this screen. Mobile can scan the QR code; every platform ' + 'can upload its image or paste the JSON. Then enter the password you ' 'chose. Once connected, you can drive the backend agents right from the app.', ), ]; diff --git a/lib/features/machines/machine_credentials_screen.dart b/lib/features/machines/machine_credentials_screen.dart index d70bce7..0b669af 100644 --- a/lib/features/machines/machine_credentials_screen.dart +++ b/lib/features/machines/machine_credentials_screen.dart @@ -11,6 +11,8 @@ import 'package:mobile_scanner/mobile_scanner.dart'; import '../../core/backend/backend_client.dart'; import '../../core/credentials/qr_image_decoder.dart'; +import '../../core/credentials/qr_image_pixels.dart'; +import '../../core/credentials/qr_pixels.dart'; import '../../core/i18n/app_strings.dart'; import '../../core/models/cli_agent.dart'; import '../../core/models/machine_credential.dart'; @@ -19,7 +21,6 @@ import '../cli_agents/agent_status_lights.dart'; import '../cli_agents/cli_agents_controller.dart'; import '../ssh/ssh_terminal_controller.dart'; import '../ssh/ssh_terminal_screen.dart'; -import 'agent_login_flow_controller.dart'; import 'deploy_backend_screen.dart'; import 'machine_credentials_controller.dart'; @@ -147,7 +148,6 @@ class _MachineCredentialsScreenState extends State { const SizedBox(height: 18), _AgentCredentialStatusSection( agentsController: widget.agentsController!, - onLogin: _startAgentLogin, onRefresh: _refreshAgents, ), ], @@ -206,15 +206,27 @@ class _MachineCredentialsScreenState extends State { if (bytes == null || bytes.isEmpty) { throw MachineCredentialException(context.l10n.fileUnreadable); } - final String raw = await compute( - decodeCredentialQrImage, - bytes, - ).timeout( + // Prefer the platform's own decoder (the browser's, on Web) and fall back + // to the pure-Dart pipeline on a background isolate. Doing the Dart decode + // on Web would block the only thread there is, freezing the tab past the + // point where this timeout could still fire. + final QrPixels? pixels = await decodeImageToRgba(bytes).timeout( const Duration(seconds: 10), onTimeout: () => throw MachineCredentialException( context.l10n.credentialQrDecodeTimedOut, ), ); + final String raw = pixels != null + ? decodeQrFromRgba(pixels.width, pixels.height, pixels.rgba) + : await compute( + decodeCredentialQrImage, + bytes, + ).timeout( + const Duration(seconds: 10), + onTimeout: () => throw MachineCredentialException( + context.l10n.credentialQrDecodeTimedOut, + ), + ); if (raw.trim().isEmpty) { throw MachineCredentialException(context.l10n.invalidQr); } @@ -443,19 +455,6 @@ class _MachineCredentialsScreenState extends State { } } - Future _startAgentLogin(CliAgent agent) async { - final bool? changed = await showDialog( - context: context, - builder: (BuildContext ctx) => _AgentLoginDialog( - agent: agent, - backendClient: _backendClient, - ), - ); - if (changed == true) { - await _refreshAgents(); - } - } - Future _confirmDelete(MachineCredential credential) async { final bool? ok = await showDialog( context: context, @@ -518,12 +517,10 @@ class _MachineCredentialsScreenState extends State { class _AgentCredentialStatusSection extends StatelessWidget { const _AgentCredentialStatusSection({ required this.agentsController, - required this.onLogin, required this.onRefresh, }); final CliAgentsController agentsController; - final ValueChanged onLogin; final Future Function() onRefresh; @override @@ -572,7 +569,6 @@ class _AgentCredentialStatusSection extends StatelessWidget { _AgentCredentialStatusTile( agent: agentsController.agents[index], showDivider: index > 0, - onLogin: onLogin, ), ], ), @@ -588,12 +584,10 @@ class _AgentCredentialStatusTile extends StatelessWidget { const _AgentCredentialStatusTile({ required this.agent, required this.showDivider, - required this.onLogin, }); final CliAgent agent; final bool showDivider; - final ValueChanged onLogin; @override Widget build(BuildContext context) { @@ -601,8 +595,10 @@ class _AgentCredentialStatusTile extends StatelessWidget { final AppStrings strings = context.l10n; final bool usable = isCliAgentSelectable(agent); final Color? textColor = usable ? null : theme.colorScheme.onSurfaceVariant; + final CredentialExpiry? expiry = cliAgentCredentialExpiry(agent); final String? subtitle = usable - ? _readySubtitle(strings, agent) + ? (agentCredentialExpiryMessage(strings, agent) ?? + _readySubtitle(strings, agent)) : agentUnavailableMessage(strings, agent); return Column( children: [ @@ -614,17 +610,18 @@ class _AgentCredentialStatusTile extends StatelessWidget { ? null : Text( subtitle, - style: TextStyle(color: theme.colorScheme.outline), + style: TextStyle( + color: expiry?.expired == true + ? theme.colorScheme.error + : theme.colorScheme.outline, + ), ), trailing: Wrap( spacing: 10, crossAxisAlignment: WrapCrossAlignment.center, children: [ AgentStatusLights(agent: agent), - _AgentCredentialAction( - agent: agent, - onLogin: onLogin, - ), + _AgentCredentialAction(agent: agent), ], ), ), @@ -641,13 +638,9 @@ class _AgentCredentialStatusTile extends StatelessWidget { } class _AgentCredentialAction extends StatelessWidget { - const _AgentCredentialAction({ - required this.agent, - required this.onLogin, - }); + const _AgentCredentialAction({required this.agent}); final CliAgent agent; - final ValueChanged onLogin; @override Widget build(BuildContext context) { @@ -658,14 +651,9 @@ class _AgentCredentialAction extends StatelessWidget { child: Text(strings.unavailable), ); } - if (agent.authKind == 'oauth') { - return FilledButton( - onPressed: () => onLogin(agent), - child: Text(agent.authed ? strings.loginAgain : strings.login), - ); - } - // hermes/opencode get their key set up on the host out of Relay's view, so - // there's no in-app key action — just a hint that they're managed there. + // Every agent's credential is created on the backend host: claude/codex with + // their own `login` command, hermes/opencode with a provider key. Relay only + // reports the state it can read there. if (agent.key == 'opencode' || agent.key == 'hermes') { return OutlinedButton( onPressed: null, @@ -676,204 +664,6 @@ class _AgentCredentialAction extends StatelessWidget { } } -class _AgentLoginDialog extends StatefulWidget { - const _AgentLoginDialog({ - required this.agent, - required this.backendClient, - }); - - final CliAgent agent; - final BackendClient backendClient; - - @override - State<_AgentLoginDialog> createState() => _AgentLoginDialogState(); -} - -class _AgentLoginDialogState extends State<_AgentLoginDialog> { - late final AgentLoginFlowController _flow; - final TextEditingController _code = TextEditingController(); - - @override - void initState() { - super.initState(); - _flow = AgentLoginFlowController( - startLogin: widget.backendClient.streamAgentLogin, - submitCode: (String sessionId, String code) { - return widget.backendClient.submitAgentLoginCode( - sessionId: sessionId, - code: code, - ); - }, - )..addListener(_onFlowChanged); - _code.addListener(_onFlowChanged); - unawaited(_flow.start(widget.agent.key)); - } - - @override - void dispose() { - _flow.removeListener(_onFlowChanged); - _flow.dispose(); - _code.removeListener(_onFlowChanged); - _code.dispose(); - super.dispose(); - } - - void _onFlowChanged() { - if (mounted) setState(() {}); - } - - Future _submit() async { - final String code = _code.text.trim(); - if (code.isEmpty) return; - await _flow.submitCode(code); - } - - void _close() { - Navigator.of(context).pop(_flow.phase == AgentLoginPhase.done); - } - - @override - Widget build(BuildContext context) { - final AppStrings strings = context.l10n; - final ThemeData theme = Theme.of(context); - final bool submitting = _flow.phase == AgentLoginPhase.submitting; - final bool done = _flow.phase == AgentLoginPhase.done; - final bool hasCode = _code.text.trim().isNotEmpty; - final bool requiresCode = _flow.requiresCode; - return AlertDialog( - title: Text(strings.agentLoginTitle(widget.agent.label)), - content: SizedBox( - width: 520, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (_flow.phase == AgentLoginPhase.starting || - _flow.phase == AgentLoginPhase.waitingForUrl || - submitting) ...[ - const LinearProgressIndicator(minHeight: 2), - const SizedBox(height: 12), - ], - Text(_statusText(strings)), - if (_flow.url != null && _flow.url!.isNotEmpty) ...[ - const SizedBox(height: 12), - Text( - requiresCode - ? strings.agentLoginOpenUrl - : strings.agentLoginBrowserOpenUrl, - ), - const SizedBox(height: 8), - DecoratedBox( - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: Padding( - padding: const EdgeInsets.all(10), - child: SelectableText( - _flow.url!, - style: theme.textTheme.bodySmall, - ), - ), - ), - Align( - alignment: Alignment.centerRight, - child: TextButton.icon( - onPressed: () async { - await Clipboard.setData(ClipboardData(text: _flow.url!)); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(strings.copied)), - ); - }, - icon: const Icon(Icons.copy_rounded), - label: Text(strings.copy), - ), - ), - ], - if (requiresCode) ...[ - const SizedBox(height: 12), - TextField( - controller: _code, - enabled: !done && _flow.phase != AgentLoginPhase.error, - decoration: InputDecoration( - labelText: strings.agentLoginCode, - hintText: strings.agentLoginCodeHint, - ), - onSubmitted: (_) => unawaited(_submit()), - ), - ], - if (_flow.output.isNotEmpty) ...[ - const SizedBox(height: 12), - Text( - strings.agentLoginOutput, - style: theme.textTheme.labelMedium, - ), - const SizedBox(height: 6), - DecoratedBox( - decoration: BoxDecoration( - border: Border.all(color: theme.colorScheme.outlineVariant), - borderRadius: BorderRadius.circular(8), - ), - child: Padding( - padding: const EdgeInsets.all(10), - child: SelectableText( - _flow.output, - style: theme.textTheme.bodySmall, - ), - ), - ), - ], - if (_flow.phase == AgentLoginPhase.error) ...[ - const SizedBox(height: 12), - Text( - strings.agentLoginFailed(_flow.error ?? strings.unknown), - style: TextStyle(color: theme.colorScheme.error), - ), - ], - ], - ), - ), - ), - actions: [ - TextButton( - onPressed: _close, - child: Text(done ? strings.close : strings.cancel), - ), - if (!done && requiresCode) - FilledButton( - onPressed: _flow.canSubmitCode && hasCode && !submitting - ? () => unawaited(_submit()) - : null, - child: Text( - submitting - ? strings.agentLoginSubmitting - : strings.agentLoginSubmit, - ), - ), - ], - ); - } - - String _statusText(AppStrings strings) { - return switch (_flow.phase) { - AgentLoginPhase.idle || - AgentLoginPhase.starting => - strings.agentLoginStarting, - AgentLoginPhase.waitingForUrl => strings.agentLoginWaitingForUrl, - AgentLoginPhase.readyForCode => _flow.requiresCode - ? strings.agentLoginOpenUrl - : strings.agentLoginBrowserOpenUrl, - AgentLoginPhase.submitting => strings.agentLoginSubmitting, - AgentLoginPhase.done => strings.agentLoginDone, - AgentLoginPhase.error => strings.agentLoginFailed( - _flow.error ?? strings.unknown, - ), - }; - } -} - class _EmptyCredentialState extends StatelessWidget { const _EmptyCredentialState({ required this.isImporting, diff --git a/lib/features/quota/quota_scheduler_screen.dart b/lib/features/quota/quota_scheduler_screen.dart index 6691349..2aec568 100644 --- a/lib/features/quota/quota_scheduler_screen.dart +++ b/lib/features/quota/quota_scheduler_screen.dart @@ -35,6 +35,9 @@ class _QuotaSchedulerScreenState extends State { void initState() { super.initState(); _seenScheduleRevision = widget.chatController.quotaScheduleRevision; + // Show the last known quota straight away; _load replaces it once the fresh + // report arrives from the usage APIs. + _usage = widget.chatController.lastUsageReport; widget.chatController.addListener(_onControllerChanged); unawaited(_load()); } diff --git a/lib/features/quota/quota_usage_screen.dart b/lib/features/quota/quota_usage_screen.dart index 1e15e36..74d8332 100644 --- a/lib/features/quota/quota_usage_screen.dart +++ b/lib/features/quota/quota_usage_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import '../../core/backend/backend_client.dart'; @@ -18,18 +20,46 @@ class QuotaUsageScreen extends StatefulWidget { } class _QuotaUsageScreenState extends State { - late Future _usageFuture; + // Seeded from the last report the app fetched, so reopening the screen shows + // the previous numbers at once. The refresh below then replaces them; a query + // that reaches Anthropic and OpenAI is too slow to hold an empty screen for. + UsageReport? _report; + String? _error; + bool _loading = false; @override void initState() { super.initState(); - _usageFuture = widget.chatController.usageReport(); + _report = widget.chatController.lastUsageReport; + unawaited(_refresh()); } - void _refresh() { + Future _refresh() async { + if (_loading) return; setState(() { - _usageFuture = widget.chatController.usageReport(); + _loading = true; + _error = null; }); + try { + final UsageReport report = await widget.chatController.usageReport(); + if (!mounted) return; + setState(() { + _report = report; + _loading = false; + }); + } catch (err) { + if (!mounted) return; + setState(() { + _error = err.toString(); + _loading = false; + }); + // With numbers already on screen the failure would otherwise be silent. + if (_report != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(_error!)), + ); + } + } } @override @@ -37,70 +67,65 @@ class _QuotaUsageScreenState extends State { return Scaffold( appBar: AppBar( title: Text(context.l10n.usageQuery), + bottom: _loading + ? const PreferredSize( + preferredSize: Size.fromHeight(2), + child: LinearProgressIndicator(minHeight: 2), + ) + : null, actions: [ IconButton( icon: const Icon(Icons.refresh_rounded), tooltip: context.l10n.refresh, - onPressed: _refresh, + onPressed: _loading ? null : () => unawaited(_refresh()), ), ], ), - body: SafeArea( - child: FutureBuilder( - future: _usageFuture, - builder: ( - BuildContext context, - AsyncSnapshot snapshot, - ) { - if (snapshot.connectionState != ConnectionState.done) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const CircularProgressIndicator(), - const SizedBox(height: 12), - Text(context.l10n.loadingUsage), - ], - ), - ); - } - if (snapshot.hasError) { - return Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Text( - snapshot.error.toString(), - textAlign: TextAlign.center, - style: TextStyle( - color: Theme.of(context).colorScheme.error, - ), - ), - ), - ); - } - final UsageReport report = snapshot.data!; - return RefreshIndicator( - onRefresh: () async { - _refresh(); - await _usageFuture; - }, - child: ListView.separated( - padding: const EdgeInsets.all(16), - itemCount: report.agents.length, - separatorBuilder: (_, __) => const SizedBox(height: 12), - itemBuilder: (BuildContext context, int index) { - return Align( - alignment: Alignment.topCenter, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 760), - child: _UsageAgentPanel(agent: report.agents[index]), - ), - ); - }, - ), - ); - }, + body: SafeArea(child: _buildBody(context)), + ); + } + + Widget _buildBody(BuildContext context) { + final UsageReport? report = _report; + if (report == null) { + if (_error != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + _error!, + textAlign: TextAlign.center, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ); + } + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 12), + Text(context.l10n.loadingUsage), + ], ), + ); + } + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: report.agents.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (BuildContext context, int index) { + return Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 760), + child: _UsageAgentPanel(agent: report.agents[index]), + ), + ); + }, ), ); } diff --git a/lib/features/settings/app_settings_screen.dart b/lib/features/settings/app_settings_screen.dart index 6bb57b3..3f97a21 100644 --- a/lib/features/settings/app_settings_screen.dart +++ b/lib/features/settings/app_settings_screen.dart @@ -4,7 +4,8 @@ import '../../core/i18n/app_strings.dart'; import '../../core/settings/app_settings_controller.dart'; import 'getting_started_screen.dart'; -const String _applicationVersion = '0.1.4'; +// Shown on the settings screen; keep in step with pubspec.yaml on every release. +const String _applicationVersion = '0.1.5'; const int _fontScaleDivisions = 9; class AppSettingsScreen extends StatelessWidget { diff --git a/lib/features/settings/getting_started_screen.dart b/lib/features/settings/getting_started_screen.dart index db2b765..7065bfd 100644 --- a/lib/features/settings/getting_started_screen.dart +++ b/lib/features/settings/getting_started_screen.dart @@ -133,7 +133,8 @@ const List<_GettingStartedStep> _zhSteps = <_GettingStartedStep>[ ), _GettingStartedStep( title: '选择一个 CLI 智能体', - body: '打开左侧栏选择 agent。红绿状态灯表示安装和认证状态;可在“管理凭证”中登录兼容的 OAuth agent。', + body: + '打开左侧栏选择 agent。红绿状态灯表示安装和认证状态;所有 CLI 的登录或 provider 配置都要在后端主机上完成,然后可在“管理凭证”中重新检查状态。', ), _GettingStartedStep( title: '像发消息一样描述任务', @@ -162,7 +163,7 @@ const List<_GettingStartedStep> _enSteps = <_GettingStartedStep>[ _GettingStartedStep( title: 'Choose a CLI agent', body: - 'Open the left drawer and choose an agent. Red/green lights show installation and authentication; compatible OAuth agents can log in from Manage credentials.', + 'Open the left drawer and choose an agent. Red/green lights show installation and authentication. Complete every CLI login or provider setup on the backend host, then recheck it from Manage credentials.', ), _GettingStartedStep( title: 'Describe the task like a message', diff --git a/pubspec.lock b/pubspec.lock index 132e3aa..56ce184 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -449,7 +449,7 @@ packages: source: hosted version: "1.3.0" markdown: - dependency: transitive + dependency: "direct main" description: name: markdown sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9 @@ -476,10 +476,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mobile_scanner: dependency: "direct main" description: @@ -729,10 +729,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" timezone: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 7187887..923ea97 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: relay description: "A private Flutter control surface for local CLI coding agents." publish_to: "none" -version: 0.1.4+14 +version: 0.1.5+15 environment: sdk: ">=3.4.0 <4.0.0" @@ -23,6 +23,9 @@ dependencies: path_provider: ^2.1.4 web: ^1.1.1 flutter_markdown_plus: ^1.0.7 + # Already pulled in by flutter_markdown_plus; declared directly so chat + # rendering can add its own inline syntax (the search-hit marker). + markdown: ^7.3.1 firebase_core: ^4.10.0 firebase_messaging: ^16.3.0 xterm: ^4.0.0 diff --git a/scripts/restart_backend.sh b/scripts/restart_backend.sh index 10f1c8e..9a3847e 100755 --- a/scripts/restart_backend.sh +++ b/scripts/restart_backend.sh @@ -58,21 +58,34 @@ restart_pm2_app() { pm2 start "$PM2_ECOSYSTEM" --only "$PM2_APP_NAME" --update-env } +# /api/health sits behind requireAuth and this script holds no device token, so +# the healthy answer here is 401, not 200 — any HTTP status proves the process is +# listening and Express is serving. Only a connection failure (curl reports 000) +# or a 5xx means the backend is not back yet. Polling with a bad token also costs +# one of the 15 auth failures per minute the brute-force guard allows, which is +# another reason this must pass on the first try rather than by retrying. wait_for_health() { if ! command -v curl >/dev/null 2>&1; then printf '\n==> curl not found; skipped health check for %s\n' "$HEALTH_URL" return fi - printf '\n==> waiting for backend health: %s\n' "$HEALTH_URL" + printf '\n==> waiting for backend to answer: %s\n' "$HEALTH_URL" + local status for attempt in {1..20}; do - if curl -fsS "$HEALTH_URL" >/dev/null; then - printf 'Backend is healthy.\n' - return - fi - printf 'backend not ready yet (%s/20)\n' "$attempt" + status="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$HEALTH_URL" || true)" + case "$status" in + 000|'' | 5??) + printf 'backend not ready yet (%s/20, HTTP %s)\n' "$attempt" "${status:-none}" + ;; + *) + printf 'Backend is up (HTTP %s).\n' "$status" + return + ;; + esac sleep 1 done - printf 'Backend did not pass health check at %s\n' "$HEALTH_URL" >&2 + printf 'Backend did not answer at %s (last status: %s)\n' \ + "$HEALTH_URL" "${status:-none}" >&2 exit 1 } diff --git a/server/.env.example b/server/.env.example index 73e9073..c348c23 100644 --- a/server/.env.example +++ b/server/.env.example @@ -7,6 +7,11 @@ HOST=127.0.0.1 # Auth tokens live in server/tokens.json (one per device, revocable), created by # `npm run credential`. The Flutter app sends one as: Authorization: Bearer . +# Passphrase for the generated credential envelope. `npm run credential` prompts +# interactively when this is unset, which is the recommended way: a value here +# (or in --passphrase) is readable from the environment and shell history. +RELAY_CREDENTIAL_PASSPHRASE= + # Filled by npm run credential. MACHINE_ID= MACHINE_NAME= @@ -19,6 +24,9 @@ PUBLIC_BASE_URL= RELAY_TUNNEL_MODE= CLOUDFLARED_BIN= CLOUDFLARED_ARGS= +# PM2 process name whose logs `npm run credential` reads to detect a Quick +# Tunnel URL. Default: relay-tunnel, then bot-app-tunnel. +TUNNEL_PM2_NAME= # Default work directory a brand-new device starts from (each device then holds # its own current path locally). Leave empty to use ~/agent_deck. @@ -33,16 +41,42 @@ RELAY_DEFAULT_DIR= # Max runtime for one CLI agent turn. Default: 3600000 (60 minutes). AGENT_TIMEOUT_MS=3600000 +# Claude runs as a persistent Agent SDK session: one process per conversation. +# It stays alive between turns like a terminal. Follow-ups skip the cold start, +# and anything started in the background keeps running. Idle processes are closed, +# and there is a hard cap on how many exist at once. A conversation whose process +# was closed resumes its stored session on the next turn. Turns past the cap wait +# for a slot. +# RELAY_CLAUDE_IDLE_MS=900000 +# RELAY_CLAUDE_MAX_LIVE=3 +# Which `claude` binary to drive. Defaults to the one on PATH — the same +# install the app shows a version for and that you logged into. +# RELAY_CLAUDE_BIN= + +# OpenCode, Hermes and Codex are persistent too, over their stdio JSON-RPC +# servers (`opencode acp`, `hermes acp`, `codex app-server`). One process per +# agent hosts every chat for it (each session carries its own work tree), so the +# startup cost is paid once rather than once per chat. Sessions are closed after +# going idle and the process exits with the last one; a chat whose session was +# closed reloads its stored session on the next turn. The cap below is per agent +# and counts live sessions; turns past it wait for a slot. +# RELAY_AGENT_IDLE_MS=900000 +# RELAY_AGENT_MAX_SESSIONS=4 + # Set to 0 to disable model metadata discovery from installed CLIs and use # Relay's static fallback catalogs. Default: enabled. RELAY_MODEL_DISCOVERY=1 # Optional Codex state/config directory. Default: ~/.codex. CODEX_HOME= -# Max size of one chat prompt in bytes. Prompts ride to the CLI as a single -# argv token, which Linux caps at ~128KB. Default: 102400 (100 KB). +# Max size of one chat prompt payload in bytes. The same budget also bounds the +# transcript material assembled for a Swarm member. Default: 102400 (100 KB). PROMPT_MAX_BYTES= +# Maximum number of agent-to-agent mention waves after a human starts a Swarm +# round. Default: 3; set to 0 to allow only the human's initial mentions. +# RELAY_SWARM_MAX_HOPS=3 + # File transfer caps in bytes. Defaults: upload 100 MB, download 300 MB. UPLOAD_MAX_BYTES= DOWNLOAD_MAX_BYTES= @@ -53,13 +87,14 @@ CORS_ALLOW_ORIGIN= # Optional comma-separated allowlist of absolute paths the file API (browse / # upload / download) may reach. Empty = whole filesystem except the built-in -# deny list (server tokens/.env/credentials, ~/.ssh, CLI auth files). +# deny list (server tokens/.env/credentials/push and FCM stores, ~/.ssh, and +# Claude/Codex auth files). It is not a general secret scanner. RELAY_FS_ROOTS= # Hard timeout for outbound quota-usage HTTP requests. Default: 15000 (15s). +# Claude/Codex usage may refresh OAuth credentials on the host. Codex quota +# probing sends a minimal provider request and may consume quota. USAGE_HTTP_TIMEOUT_MS= -# Timeout for the local Antigravity language-server quota probe. Default: 12000. -AGY_QUOTA_PROBE_TIMEOUT_MS= # Optional Windows override for directory zip downloads. Defaults to powershell.exe. POWERSHELL_BIN= @@ -68,6 +103,22 @@ POWERSHELL_BIN= ENABLE_QUOTA_WATCH=true QUOTA_POLL_MS=300000 +# Keep Claude Code's five-hour window running so the usage screen never shows an +# unknown reset time. When the window is idle the backend sends one minimal +# Claude Code request (one output token on the selected keepalive model) to +# restart it, then waits for the next reset. This request can consume quota; set +# to false to disable it. +ENABLE_CLAUDE_KEEPALIVE=true +# Model used for that ping. Default: claude-haiku-4-5. +CLAUDE_KEEPALIVE_MODEL= +# Optional keepalive tuning (ms): delay after a reset before pinging, delay +# before re-reading usage, and the floor between two pings. +# CLAUDE_KEEPALIVE_GRACE_MS=30000 +# CLAUDE_KEEPALIVE_VERIFY_MS=30000 +# CLAUDE_KEEPALIVE_MIN_INTERVAL_MS=600000 +# How long to wait before retrying after a failed ping. +# CLAUDE_KEEPALIVE_ERROR_RETRY_MS=900000 + # Web Push (VAPID): lets quota/scheduled-message alerts reach a browser even when # the Relay tab is closed. Generate a keypair with: # node -e "console.log(require('web-push').generateVAPIDKeys())" @@ -86,3 +137,12 @@ FCM_SERVICE_ACCOUNT_FILE= # external usage API after a 429/error while serving the last-good cached value. # USAGE_BACKOFF_BASE_MS=30000 # USAGE_BACKOFF_MAX_MS=900000 + +# Optional absolute paths for generated state files, which otherwise live beside +# server.js. Used by the test suites; a deployment normally leaves them unset. +# The file API's deny list follows RELAY_TOKENS_FILE, but the others are not +# secret-protected once moved outside server/ — keep them off shared paths. +# RELAY_TOKENS_FILE= +# RELAY_HISTORY_FILE= +# RELAY_GROUPS_FILE= +# RELAY_QUOTA_SCHEDULES_FILE= diff --git a/server/.gitignore b/server/.gitignore index f339460..ea6c211 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -18,3 +18,6 @@ credentials/*.png credentials/*.passphrase.txt agent-settings.json models-extra.json + +# Backups of the state files above are just as sensitive as the originals. +*.bak diff --git a/server/lib/acp-session-pool.js b/server/lib/acp-session-pool.js new file mode 100644 index 0000000..8ade715 --- /dev/null +++ b/server/lib/acp-session-pool.js @@ -0,0 +1,156 @@ +'use strict'; + +const { createStdioAgentPool } = require('./stdio-agent-pool'); + +// The ACP (Agent Client Protocol) driver, used by opencode and hermes: both +// ship an `acp` subcommand that speaks it on stdio. stdio-agent-pool.js owns the +// process, the wire and the session cap; this file is only the protocol. +// +// ACP turns are request/response — `session/prompt` resolves when the turn ends +// — and settings apply to a live session with no restart, which is why nothing +// here is fixed at open time. +const PROTOCOL_VERSION = 1; + +function createAcpDriver(rpc) { + return { + async initialize() { + const init = await rpc.request('initialize', { + protocolVersion: PROTOCOL_VERSION, + // Relay does not proxy the filesystem or terminals: the agent runs on + // the same machine, so it uses its own. + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + }); + const caps = (init && init.agentCapabilities) || {}; + const sessionCaps = caps.sessionCapabilities || {}; + rpc.caps.loadSession = !!caps.loadSession; + // Hermes advertises no close, so its evicted sessions are only dropped. + rpc.caps.close = !!sessionCaps.close; + }, + + async openSession(req) { + if (req.resumeId && rpc.caps.loadSession) { + try { + await rpc.request('session/load', { + sessionId: req.resumeId, + cwd: req.cwd, + mcpServers: [], + }); + return { sessionId: req.resumeId, startedNew: false }; + } catch (_err) { + // A stored session the agent no longer has. Start a fresh one rather + // than failing the turn — the same recovery the per-turn runner did + // when `--session` was rejected. + } + } + const created = await rpc.request('session/new', { + cwd: req.cwd, + mcpServers: [], + }); + const sessionId = created && created.sessionId; + if (!sessionId) throw new Error(`${rpc.agentKey} returned no session id`); + return { sessionId, startedNew: !!req.resumeId }; + }, + + closeSession(entry) { + if (!rpc.caps.close) return Promise.resolve(); + return rpc.request('session/close', { sessionId: entry.sessionId }); + }, + + // Model and mode changes are hot in ACP: they apply to the live session + // with no restart, unlike the Claude pool where they are fixed at spawn. + // An agent that rejects a value keeps running on its own default rather + // than failing the user's turn over a setting. + applySettings(entry, req) { + const set = async (field, method, key) => { + const value = req[field]; + if (!value || entry.applied[field] === value || entry.closed) return; + try { + await rpc.request(method, { sessionId: entry.sessionId, [key]: value }); + entry.applied[field] = value; + } catch (_err) { + // Left on the agent's own default. + } + }; + return Promise.all([ + set('modelId', 'session/set_model', 'modelId'), + set('modeId', 'session/set_mode', 'modeId'), + ]); + }, + + startTurn(entry, req, turn) { + rpc + .request('session/prompt', { + sessionId: entry.sessionId, + prompt: [{ type: 'text', text: String(req.prompt) }], + }) + .then( + (result) => turn.finish(result || { stopReason: 'end_turn' }), + (err) => turn.fail(err), + ); + }, + + cancelTurn(entry) { + rpc.notify('session/cancel', { sessionId: entry.sessionId }); + }, + + handleMessage(msg) { + const params = msg.params || {}; + // The agent asks us things too. Permission requests are the only one + // Relay answers; everything else is refused explicitly so the agent never + // hangs waiting on a reply that is not coming. + if (msg.id !== undefined) { + if (msg.method !== 'session/request_permission') { + rpc.replyError(msg.id, -32601, `unsupported method: ${msg.method}`); + return; + } + const entry = rpc.sessionFor(params.sessionId); + const turn = entry && entry.turn; + const title = (params.toolCall && params.toolCall.title) || 'tool call'; + let approve = false; + try { + approve = !!(turn && turn.onPermission && turn.onPermission({ title })); + } catch (_err) { + approve = false; + } + // The runner decides yes or no; picking the option that says so is + // protocol knowledge and stays here. + const options = Array.isArray(params.options) ? params.options : []; + const pick = (...kinds) => { + for (const kind of kinds) { + const found = options.find((option) => option && option.kind === kind); + if (found) return found.optionId; + } + return null; + }; + const optionId = approve + ? pick('allow_always', 'allow_once') + : pick('reject_once', 'reject_always'); + rpc.reply( + msg.id, + optionId + ? { outcome: { outcome: 'selected', optionId } } + : { outcome: { outcome: 'cancelled' } }, + ); + return; + } + if (msg.method !== 'session/update') return; + const entry = rpc.sessionFor(params.sessionId); + const turn = entry && entry.turn; + if (!turn) return; + const update = params.update || {}; + // Only assistant text reaches the user, and it is what makes a silent + // retry unsafe. + if (update.sessionUpdate === 'agent_message_chunk') turn.emitted = true; + turn.onMessage(update); + }, + }; +} + +function createAcpSessionPool(options = {}) { + return createStdioAgentPool({ ...options, driver: createAcpDriver }); +} + +module.exports = { createAcpSessionPool }; diff --git a/server/lib/agent-login.js b/server/lib/agent-login.js deleted file mode 100644 index 616873e..0000000 --- a/server/lib/agent-login.js +++ /dev/null @@ -1,381 +0,0 @@ -'use strict'; - -const { spawn } = require('child_process'); -const { randomUUID } = require('crypto'); -const fs = require('fs'); -const os = require('os'); -const path = require('path'); - -const { commandExists } = require('./agents'); - -const SESSION_TTL_MS = 15 * 60 * 1000; -const SESSION_MAX_RUNNING_MS = 15 * 60 * 1000; -const URL_RE = /https?:\/\/[^\s"'<>]+/g; -const URL_TRAILING_PUNCT_RE = /[),.;]+$/; -const AGY_TOKEN_RELATIVE = [ - '.gemini', - 'antigravity-cli', - 'antigravity-oauth-token', -]; - -const AUTH_HOSTS = { - claude: ['anthropic.com', 'claude.ai'], - codex: ['openai.com', 'chatgpt.com'], - agy: ['accounts.google.com', 'google.com'], -}; -const AUTH_URL_RE = /(auth|authorize|device|login|oauth|verify)/i; - -const LOGIN_COMMANDS = { - claude: ['claude', 'auth', 'login', '--claudeai'], - codex: ['codex', 'login', '--device-auth'], - agy: ['agy'], -}; - -function agyTokenPath(homeDir) { - return path.join(homeDir, ...AGY_TOKEN_RELATIVE); -} - -function hasNonEmptyFile(fsModule, filePath) { - try { - return String(fsModule.readFileSync(filePath, 'utf8')).trim().length > 0; - } catch (_err) { - return false; - } -} - -function shellQuote(value) { - return `'${String(value).replace(/'/g, `'\\''`)}'`; -} - -function scriptCommand(args) { - return args.map(shellQuote).join(' '); -} - -function redactLoginText(text) { - return String(text || '').replace(URL_RE, '[login URL]'); -} - -function normalizeUrl(value) { - return String(value || '').replace(URL_TRAILING_PUNCT_RE, ''); -} - -function hostMatches(hostname, expectedHost) { - return hostname === expectedHost || hostname.endsWith(`.${expectedHost}`); -} - -function loginUrlScore(agent, value) { - const normalized = normalizeUrl(value); - if (!normalized) return Number.NEGATIVE_INFINITY; - try { - const parsed = new URL(normalized); - const hostname = parsed.hostname.toLowerCase(); - let score = parsed.protocol === 'https:' ? 10 : 0; - if ((AUTH_HOSTS[agent] || []).some((host) => hostMatches(hostname, host))) { - score += 1000; - } - const authText = `${hostname}${parsed.pathname}${parsed.search}`; - if (AUTH_URL_RE.test(authText)) score += 200; - score += Math.min(authText.length, 300); - return score; - } catch (_err) { - return normalized.length; - } -} - -function selectLoginUrl(agent, urls) { - let best = ''; - let bestScore = Number.NEGATIVE_INFINITY; - for (const rawUrl of urls) { - const url = normalizeUrl(rawUrl); - const score = loginUrlScore(agent, url); - if (score > bestScore) { - best = url; - bestScore = score; - } - } - return { url: best, score: bestScore }; -} - -function createAgentLoginManager(options = {}) { - const sessions = new Map(); - const spawnFn = options.spawn || spawn; - const nowFn = options.now || (() => Date.now()); - const idFn = options.randomUUID || randomUUID; - const commandExistsFn = options.commandExists || commandExists; - const fsModule = options.fs || fs; - const homeDir = options.homeDir || os.homedir(); - const pollIntervalMs = options.pollIntervalMs || 1000; - const sessionTtlMs = options.sessionTtlMs || SESSION_TTL_MS; - const maxRunningMs = options.maxRunningMs || SESSION_MAX_RUNNING_MS; - - function sessionPayload(session) { - return { - sessionId: session.id, - agent: session.agent, - authMode: session.authMode, - requiresCode: session.requiresCode, - }; - } - - function emit(session, type, payload = {}) { - const event = { - type, - data: { - ...sessionPayload(session), - ...payload, - }, - }; - for (const listener of session.listeners) { - try { - listener(event); - } catch (_err) { - // A dropped SSE client should not affect the login process. - } - } - } - - function replay(session, listener) { - listener({ - type: 'login_started', - data: sessionPayload(session), - }); - if (session.url) { - listener({ - type: 'login_url', - data: { ...sessionPayload(session), url: session.url }, - }); - } - if (session.status === 'done') { - listener({ - type: 'login_done', - data: sessionPayload(session), - }); - } else if (session.status === 'error') { - listener({ - type: 'login_error', - data: { - ...sessionPayload(session), - error: session.error || 'Login failed.', - }, - }); - } - } - - function handleOutput(session, chunk) { - const text = String(chunk || ''); - session.output += text; - const urls = text.match(URL_RE) || []; - const selectedUrl = selectLoginUrl(session.agent, urls); - if ( - selectedUrl.url - && selectedUrl.url !== session.url - && selectedUrl.score >= session.urlScore - ) { - session.url = selectedUrl.url; - session.urlScore = selectedUrl.score; - emit(session, 'login_url', { url: session.url }); - } - if (!session.codeSubmitted && text.trim()) { - emit(session, 'login_output', { text: redactLoginText(text).slice(-2000) }); - } - } - - function finish(session, status, error = '') { - if (session.status !== 'running') return; - if (session.pollTimer) { - clearInterval(session.pollTimer); - session.pollTimer = null; - } - session.status = status; - session.error = error; - session.finishedAt = nowFn(); - emit(session, status === 'done' ? 'login_done' : 'login_error', { - ...(error ? { error } : {}), - }); - } - - function killChild(session) { - if (!session.child || typeof session.child.kill !== 'function') return; - try { - session.child.kill('SIGTERM'); - } catch (_err) { - // The child may already have exited; finish() still records the terminal state. - } - } - - function abortSession(session, message) { - if (session.status !== 'running') return; - killChild(session); - finish(session, 'error', message); - } - - function cleanup() { - const now = nowFn(); - for (const [id, session] of sessions.entries()) { - if ( - session.status === 'running' - && now - session.createdAt > maxRunningMs - ) { - abortSession(session, 'Login session timed out.'); - } - if ( - session.status !== 'running' - && now - (session.finishedAt || session.createdAt) > sessionTtlMs - ) { - sessions.delete(id); - } - } - } - - function startAgyTokenPoll(session) { - const tokenPath = agyTokenPath(homeDir); - session.pollTimer = setInterval(() => { - if (hasNonEmptyFile(fsModule, tokenPath)) { - finish(session, 'done'); - } - }, pollIntervalMs); - } - - function start(agent) { - cleanup(); - const args = LOGIN_COMMANDS[agent]; - if (!args) { - const err = new Error(`Login is not supported for ${agent}`); - err.code = 'LOGIN_UNSUPPORTED'; - throw err; - } - if (!commandExistsFn(args[0])) { - const err = new Error(`${agent} CLI is not installed`); - err.code = 'CLI_NOT_INSTALLED'; - throw err; - } - const id = idFn(); - const session = { - id, - agent, - status: 'running', - url: '', - urlScore: Number.NEGATIVE_INFINITY, - output: '', - error: '', - codeSubmitted: false, - authMode: agent === 'agy' ? 'browserOAuth' : 'deviceCode', - requiresCode: agent !== 'agy', - createdAt: nowFn(), - finishedAt: null, - listeners: new Set(), - child: null, - pollTimer: null, - }; - sessions.set(id, session); - - if (agent === 'agy' && hasNonEmptyFile(fsModule, agyTokenPath(homeDir))) { - finish(session, 'done'); - return session; - } - - const child = spawnFn( - 'script', - ['-qfec', scriptCommand(args), '/dev/null'], - { stdio: ['pipe', 'pipe', 'pipe'] }, - ); - session.child = child; - child.stdout?.on('data', (chunk) => handleOutput(session, chunk)); - child.stderr?.on('data', (chunk) => handleOutput(session, chunk)); - child.on('error', (err) => { - finish(session, 'error', err.message || 'Failed to start login.'); - }); - child.on('exit', (code, signal) => { - if (session.agent === 'agy') { - if (hasNonEmptyFile(fsModule, agyTokenPath(homeDir))) { - finish(session, 'done'); - } else { - finish( - session, - 'error', - signal - ? `Agy login process ended with signal ${signal}.` - : `Agy login process exited before the OAuth token appeared (code ${code}).`, - ); - } - return; - } - if (code === 0) { - finish(session, 'done'); - } else { - finish( - session, - 'error', - signal - ? `Login process ended with signal ${signal}.` - : `Login process exited with code ${code}.`, - ); - } - }); - if (agent === 'agy') startAgyTokenPoll(session); - emit(session, 'login_started'); - return session; - } - - function subscribe(sessionId, listener) { - cleanup(); - const session = sessions.get(sessionId); - if (!session) return () => {}; - session.listeners.add(listener); - replay(session, listener); - // Only drop the listener on disconnect — do NOT kill the login. The CLI - // completes the OAuth flow on its own in the PTY, and the user typically has - // to leave the app (backgrounding it, which drops this SSE) to authorize in a - // browser. Killing here would abort a login at the worst moment. Abandoned - // sessions are still reaped by the maxRunningMs timeout in cleanup(). - return () => { - session.listeners.delete(listener); - }; - } - - function submitCode(sessionId, code) { - const session = sessions.get(sessionId); - if (!session) { - const err = new Error('Login session not found.'); - err.code = 'LOGIN_SESSION_NOT_FOUND'; - throw err; - } - if (!session.requiresCode) { - const err = new Error('Login session does not accept an authorization code.'); - err.code = 'LOGIN_CODE_NOT_REQUIRED'; - throw err; - } - if (session.status !== 'running' || !session.child?.stdin?.writable) { - const err = new Error('Login session is not accepting input.'); - err.code = 'LOGIN_SESSION_CLOSED'; - throw err; - } - session.codeSubmitted = true; - session.child.stdin.write(`${String(code || '').trim()}\n`); - } - - function status(sessionId) { - cleanup(); - const session = sessions.get(sessionId); - if (!session) return null; - return { - sessionId: session.id, - agent: session.agent, - status: session.status, - url: session.url, - error: session.error, - authMode: session.authMode, - requiresCode: session.requiresCode, - }; - } - - return { start, subscribe, submitCode, status, cleanup }; -} - -module.exports = { - LOGIN_COMMANDS, - agyTokenPath, - createAgentLoginManager, - selectLoginUrl, - scriptCommand, -}; diff --git a/server/lib/agent-options.js b/server/lib/agent-options.js index 0434152..8c24ad4 100644 --- a/server/lib/agent-options.js +++ b/server/lib/agent-options.js @@ -1,12 +1,12 @@ 'use strict'; // Single source of truth for the per-agent Model / Effort / Permission controls -// exposed in the chat composer's "+" drawer. Each selectable option carries the -// exact CLI argv tokens it maps to, so agents.js can splice them into a spawn -// without knowing agent-specific flag shapes. +// exposed in the chat composer's "+" drawer. The tables feed normalized settings +// into the Claude SDK, ACP, Codex app-server, and legacy argv helpers without +// making the runners duplicate validation. // // Capability-aware: each agent only exposes the controls supported by its CLI. -// Antigravity (`agy`) supports --model and permission flags, but no effort flag. +// Hermes, for example, has no per-invocation model or effort flag. // // Every group is an explicit, named choice — there is no opaque "default" entry, // so the user always knows exactly which model, reasoning effort, and permission @@ -16,7 +16,6 @@ const fs = require('fs'); const path = require('path'); const { discoverModels } = require('./model-discovery'); -const { configuredAgyModel } = require('./agy-paths'); // Static fallback model catalog. The live list normally comes from // model-discovery (which reads what the installed CLI actually ships, newest @@ -63,48 +62,6 @@ const BASE_MODELS = { args: ['-m', 'gpt-5.4-mini'], }, ], - agy: [ - { - id: 'gemini-3-5-flash-medium', - label: 'Gemini 3.5 Flash (Medium)', - args: ['--model', 'Gemini 3.5 Flash (Medium)'], - }, - { - id: 'gemini-3-5-flash-high', - label: 'Gemini 3.5 Flash (High)', - args: ['--model', 'Gemini 3.5 Flash (High)'], - }, - { - id: 'gemini-3-5-flash-low', - label: 'Gemini 3.5 Flash (Low)', - args: ['--model', 'Gemini 3.5 Flash (Low)'], - }, - { - id: 'gemini-3-1-pro-low', - label: 'Gemini 3.1 Pro (Low)', - args: ['--model', 'Gemini 3.1 Pro (Low)'], - }, - { - id: 'gemini-3-1-pro-high', - label: 'Gemini 3.1 Pro (High)', - args: ['--model', 'Gemini 3.1 Pro (High)'], - }, - { - id: 'claude-sonnet-4-6-thinking', - label: 'Claude Sonnet 4.6 (Thinking)', - args: ['--model', 'Claude Sonnet 4.6 (Thinking)'], - }, - { - id: 'claude-opus-4-6-thinking', - label: 'Claude Opus 4.6 (Thinking)', - args: ['--model', 'Claude Opus 4.6 (Thinking)'], - }, - { - id: 'gpt-oss-120b-medium', - label: 'GPT-OSS 120B (Medium)', - args: ['--model', 'GPT-OSS 120B (Medium)'], - }, - ], // opencode models are `provider/model`; these free entries work without // credentials. Live discovery isn't wired for opencode, so this static list // (plus models-extra.json) is the catalog. Run `opencode models` for the full @@ -139,7 +96,6 @@ const EFFORTS = { { id: 'high', label: 'High', args: ['-c', 'model_reasoning_effort=high'] }, { id: 'xhigh', label: 'Extra high', args: ['-c', 'model_reasoning_effort=xhigh'] }, ], - agy: [], // opencode exposes reasoning effort via `--variant`, but valid variants are // model-specific (an unsupported one errors), so it stays opt-in with no // default — selecting one adds `--variant `. @@ -168,10 +124,9 @@ const FAST_MODES = { }; // Permission tiers. The bypass tier is listed first but is no longer the -// default — AGENT_DEFAULTS below picks a safer "auto" tier per agent. For -// Codex, non-bypass tiers must pin approval_policy=never — `codex exec` is -// non-interactive, so any approval prompt would hang forever instead of being -// answered. +// default — AGENT_DEFAULTS below picks a safer "auto" tier per agent. Codex +// keeps approvals off on every tier, so the sandbox is the whole boundary: +// Relay has no approval UI, so a prompt has no one to answer it. const PERMISSIONS = { claude: [ { @@ -200,10 +155,8 @@ const PERMISSIONS = { description: 'No sandbox, no approvals.', args: ['--dangerously-bypass-approvals-and-sandbox'], }, - // Use the `-c sandbox_mode=` config override rather than `-s`: `codex exec - // resume` accepts `-c` but not `-s`, so the config form works for both new - // and resumed turns. approval_policy=never is mandatory — exec is - // non-interactive, so any approval prompt would hang. + // The args below are the equivalent CLI flags, kept so this stays one + // source of truth; codexSessionOptions is what the runner actually uses. { id: 'workspace-write', label: 'Workspace write', @@ -223,22 +176,12 @@ const PERMISSIONS = { args: ['-c', 'sandbox_mode=danger-full-access', '-c', 'approval_policy=never'], }, ], - agy: [ - { - id: 'bypass', - label: 'Bypass (full auto)', - description: 'Auto-approve all tool requests.', - args: ['--dangerously-skip-permissions'], - }, - { - id: 'sandbox', - label: 'Sandbox', - description: 'Run with terminal restrictions enabled.', - args: ['--sandbox'], - }, - ], - // opencode `run` is non-interactive, so the default tier auto-approves (a - // prompt would hang). "Ask" leaves approvals to opencode (may block edits). + // opencode and hermes run over ACP, where approval requests come to Relay + // itself. Until there is an approval UI the default tier approves them all, + // and the cautious tier refuses — which is at least deterministic, where a + // non-interactive CLI run could stall. The args are the equivalent CLI flags, + // kept so the tables stay one source of truth; acpSessionOptions is what the + // runners actually use. opencode: [ { id: 'bypass', @@ -249,12 +192,10 @@ const PERMISSIONS = { { id: 'ask', label: 'Ask', - description: 'Let opencode decide; some actions may be blocked.', + description: 'Refuse anything that needs approval; edits may be blocked.', args: [], }, ], - // Hermes' chat -q is non-interactive; --yolo bypasses approval prompts so the - // run can't hang. "Cautious" omits it (Hermes may block dangerous commands). hermes: [ { id: 'yolo', @@ -265,17 +206,16 @@ const PERMISSIONS = { { id: 'cautious', label: 'Cautious', - description: 'Keep approvals; dangerous commands may be blocked.', + description: 'Refuse anything that needs approval; edits are blocked.', args: [], }, ], }; -// claude/codex/agy CLI invocation + how to query/update each binary. +// CLI invocation + how to query/update each binary. const CLI = { claude: { bin: 'claude', versionArgs: ['--version'], updateArgs: ['update'] }, codex: { bin: 'codex', versionArgs: ['--version'], updateArgs: ['update'] }, - agy: { bin: 'agy', versionArgs: ['--version'], updateArgs: ['update'] }, // TODO(opencode/hermes): confirm version/update subcommands once installed. opencode: { bin: 'opencode', versionArgs: ['--version'], updateArgs: ['upgrade'] }, hermes: { bin: 'hermes', versionArgs: ['--version'], updateArgs: ['update'] }, @@ -286,12 +226,11 @@ const CLI = { // is always knowable. The model default is derived from the live catalog (newest // first) rather than pinned here, so it tracks the installed CLI. Permission // starts on a safer "auto" tier instead of full bypass: claude auto-accepts -// edits, codex writes within the workspace (approvals disabled so exec never -// hangs), and agy runs sandboxed. +// edits and codex writes within the workspace (approvals disabled so exec never +// hangs). const AGENT_DEFAULTS = { claude: { effort: 'high', permission: 'acceptEdits', fast: 'off' }, codex: { effort: 'medium', permission: 'workspace-write', fast: 'off' }, - agy: { permission: 'sandbox' }, // Non-interactive defaults that can actually do work; effort stays unset // (model-specific) and opencode's model default comes from the catalog. opencode: { permission: 'bypass' }, @@ -300,22 +239,7 @@ const AGENT_DEFAULTS = { // Agents whose model group gets an automatic default (the newest catalog entry // or the CLI's configured default when available). -const MODEL_DEFAULT_AGENTS = new Set(['claude', 'codex', 'agy', 'opencode']); - -function modelDiscoveryDisabled() { - return ( - process.env.RELAY_MODEL_DISCOVERY === '0' || - process.env.RELAY_MODEL_DISCOVERY === 'false' - ); -} - -function configuredAgyModelId(models) { - if (modelDiscoveryDisabled()) return null; - const configured = configuredAgyModel(); - if (!configured) return null; - const match = models.find((model) => model.label === configured); - return match ? match.id : null; -} +const MODEL_DEFAULT_AGENTS = new Set(['claude', 'codex', 'opencode']); function defaultsFor(agentKey) { return defaultsForModels(agentKey, modelsFor(agentKey)); @@ -323,17 +247,38 @@ function defaultsFor(agentKey) { const EXTRA_MODELS_FILE = path.join(__dirname, '..', 'models-extra.json'); +// Parsed models-extra.json, re-read only when the file's mtime/size changes. +// modelsFor runs on every turn and every option-picker open, so the common case +// (no such file) must not cost a failing read each time. +let extraModelsCache = { stamp: null, value: null }; + +function readExtraModels() { + let stamp = ''; + try { + const stat = fs.statSync(EXTRA_MODELS_FILE); + stamp = `${stat.size}:${stat.mtimeMs}`; + } catch (_err) { + stamp = ''; + } + if (extraModelsCache.stamp === stamp) return extraModelsCache.value; + let value = null; + if (stamp) { + try { + value = JSON.parse(fs.readFileSync(EXTRA_MODELS_FILE, 'utf-8')); + } catch (_err) { + value = null; + } + } + extraModelsCache = { stamp, value }; + return value; +} + // Merge user-supplied pinned models from models-extra.json on top of the base // catalog. Entries are appended (deduped by id); a brand-new model becomes // selectable by editing that file alone, no redeploy. Malformed files are // ignored so a typo never breaks the options endpoint. function mergeExtraModels(agentKey, base) { - let extra; - try { - extra = JSON.parse(fs.readFileSync(EXTRA_MODELS_FILE, 'utf-8')); - } catch (_err) { - return base; - } + const extra = readExtraModels(); const list = extra && Array.isArray(extra[agentKey]) ? extra[agentKey] : null; if (!list) return base; const seen = new Set(base.map((m) => m.id)); @@ -435,11 +380,7 @@ function defaultsForModels(agentKey, models) { const defaults = { ...(AGENT_DEFAULTS[agentKey] || {}) }; let defaultModel = null; if (MODEL_DEFAULT_AGENTS.has(agentKey) && models.length) { - const modelId = - agentKey === 'agy' - ? configuredAgyModelId(models) || models[0].id - : models[0].id; - defaultModel = models.find((model) => model.id === modelId) || models[0]; + [defaultModel] = models; defaults.model = defaultModel.id; } const efforts = effortOptionsFor(agentKey, defaultModel); @@ -554,10 +495,97 @@ function buildArgs(agentKey, settings) { return args; } +// Claude runs as a persistent Agent SDK session rather than a per-turn argv +// invocation, so its settings are resolved into SDK options instead of flags. +// The resolution itself is normalizeSettings', so the option tables above stay +// the single source of truth for both shapes. +const CLAUDE_PERMISSION_MODES = { + bypass: 'bypassPermissions', + acceptEdits: 'acceptEdits', + plan: 'plan', +}; + +function claudeSdkOptions(settings) { + const chosen = normalizeSettings('claude', settings); + const options = {}; + if (chosen.model) options.model = chosen.model; + if (chosen.effort) options.effort = chosen.effort; + const permissionMode = CLAUDE_PERMISSION_MODES[chosen.permission]; + if (permissionMode) { + options.permissionMode = permissionMode; + // The SDK requires this acknowledgement alongside full bypass; it is the + // same gate the CLI's --dangerously-skip-permissions carries. + if (permissionMode === 'bypassPermissions') { + options.allowDangerouslySkipPermissions = true; + } + } + options.settings = { fastMode: chosen.fast === 'on' }; + return options; +} + +// ACP agents (opencode, hermes) hold a persistent session too, but their +// settings are applied over the protocol rather than as argv: the model with +// session/set_model, the permission tier as a session mode where the agent has +// one that matches, and in every case by deciding how Relay answers the agent's +// session/request_permission calls. Resolution stays normalizeSettings' so the +// option tables above remain the single source of truth. +// +// opencode's tiers have no mode to map onto — its `plan` mode disallows edits +// entirely, which is not what "Ask" means — so it relies on the answers alone. +const ACP_PERMISSION_MODES = { + hermes: { yolo: 'dont_ask', cautious: 'default' }, +}; + +// The tier that means "approve whatever the agent asks for". Every other tier +// refuses, because Relay has no approval UI to route the request to. +const ACP_AUTO_APPROVE = { opencode: 'bypass', hermes: 'yolo' }; + +function acpSessionOptions(agentKey, settings) { + const chosen = normalizeSettings(agentKey, settings); + let modelId = chosen.model || null; + // Hermes names models `provider:model` over ACP, while its config and + // models-extra.json use the CLI's `provider/model` form. Translate so a + // pinned id keeps selecting the same model. + if (agentKey === 'hermes' && modelId) modelId = modelId.replace('/', ':'); + const modes = ACP_PERMISSION_MODES[agentKey] || {}; + return { + modelId, + modeId: modes[chosen.permission] || null, + approve: chosen.permission === ACP_AUTO_APPROVE[agentKey], + }; +} + +// Codex runs as a persistent app-server thread, so its settings resolve to +// protocol values instead of `-c` overrides. Every tier keeps approvals off: +// the sandbox is the boundary, and Relay has no approval UI to answer prompts +// with — the same reason the argv tiers above pin approval_policy=never. +const CODEX_SANDBOXES = { + bypass: 'danger-full-access', + 'workspace-write': 'workspace-write', + 'read-only': 'read-only', + 'full-access': 'danger-full-access', +}; + +function codexSessionOptions(settings) { + const chosen = normalizeSettings('codex', settings); + return { + model: chosen.model || null, + effort: chosen.effort || null, + sandbox: CODEX_SANDBOXES[chosen.permission] || 'workspace-write', + approvalPolicy: 'never', + serviceTier: chosen.fast === 'on' ? 'fast' : 'default', + // Only reachable if codex asks anyway; the unsandboxed tiers say yes. + approve: chosen.permission === 'bypass' || chosen.permission === 'full-access', + }; +} + module.exports = { defaultsFor, CLI, describeAgent, normalizeSettings, buildArgs, + claudeSdkOptions, + acpSessionOptions, + codexSessionOptions, }; diff --git a/server/lib/agent-settings.js b/server/lib/agent-settings.js index 1fa2fd1..a63656e 100644 --- a/server/lib/agent-settings.js +++ b/server/lib/agent-settings.js @@ -9,7 +9,7 @@ const path = require('path'); -const { defaultsFor, normalizeSettings } = require('./agent-options'); +const { normalizeSettings } = require('./agent-options'); const { createJsonStore } = require('./json-store'); const SETTINGS_FILE = path.join(__dirname, '..', 'agent-settings.json'); @@ -18,11 +18,12 @@ const SETTINGS_FILE = path.join(__dirname, '..', 'agent-settings.json'); // hit the disk each time. const store = createJsonStore(SETTINGS_FILE, { defaultValue: {} }); -// Effective settings for a scope: stored selection normalized for the agent, -// falling back to defaults for any group not yet chosen or not supported. +// Effective settings for a scope: stored selection normalized for the agent. +// normalizeSettings already falls back to the agent's default for any group that +// is unset or unsupported, so the stored object goes in as-is — seeding it with +// the defaults first only built the same catalog a second time. function getSettings(agentKey, scopeKey) { - const stored = store.load()[scopeKey] || {}; - return normalizeSettings(agentKey, { ...defaultsFor(agentKey), ...stored }); + return normalizeSettings(agentKey, store.load()[scopeKey] || {}); } // Persist a (partial) selection for a scope. Only the provided groups change; @@ -31,7 +32,6 @@ function getSettings(agentKey, scopeKey) { function setSettings(agentKey, scopeKey, partial) { return store.mutate((all) => { const merged = normalizeSettings(agentKey, { - ...defaultsFor(agentKey), ...(all[scopeKey] || {}), ...(partial || {}), }); diff --git a/server/lib/agent-status.js b/server/lib/agent-status.js index cd34439..cb601dd 100644 --- a/server/lib/agent-status.js +++ b/server/lib/agent-status.js @@ -12,7 +12,6 @@ const statusCache = new Map(); const AUTH_KIND = { claude: 'oauth', codex: 'oauth', - agy: 'oauth', hermes: 'apiKey', opencode: 'apiKeyOptional', }; @@ -37,35 +36,46 @@ function fileHasText(fsModule, filePath) { } } -function claudeAuthed(fsModule, homeDir) { +// Expiry claim of a JWT, in epoch milliseconds. The payload is decoded, never +// verified: only `exp` is read and no token value leaves this module. +function jwtExpiresAt(token) { + const payload = String(token || '').split('.')[1]; + if (!payload) return null; + try { + const claims = JSON.parse( + Buffer.from(payload, 'base64url').toString('utf8'), + ); + return Number.isFinite(claims.exp) ? claims.exp * 1000 : null; + } catch (_err) { + return null; + } +} + +function expiryOrNull(value) { + return Number.isFinite(value) && value > 0 ? value : null; +} + +function claudeCredential(fsModule, homeDir) { const creds = readJson( fsModule, path.join(homeDir, '.claude', '.credentials.json'), ); - const oauth = creds && creds.claudeAiOauth; - return !!( - oauth && - nonEmpty(oauth.accessToken) && - nonEmpty(oauth.refreshToken) - ); + const oauth = (creds && creds.claudeAiOauth) || {}; + return { + authed: nonEmpty(oauth.accessToken) && nonEmpty(oauth.refreshToken), + expiresAt: expiryOrNull(oauth.expiresAt), + }; } -function codexAuthed(fsModule, homeDir) { +function codexCredential(fsModule, homeDir) { const auth = readJson(fsModule, path.join(homeDir, '.codex', 'auth.json')); - const tokens = auth && auth.tokens; - return !!(tokens && nonEmpty(tokens.access_token)); -} - -function agyAuthed(fsModule, homeDir) { - return fileHasText( - fsModule, - path.join( - homeDir, - '.gemini', - 'antigravity-cli', - 'antigravity-oauth-token', - ), - ); + const tokens = (auth && auth.tokens) || {}; + return { + authed: nonEmpty(tokens.access_token), + // The id_token carries the session expiry the user actually has to renew by + // logging in again; the access token is rotated on its own far more often. + expiresAt: jwtExpiresAt(tokens.id_token), + }; } function hasApiKeyLikeValue(value, keyName = '') { @@ -107,20 +117,21 @@ function hermesAuthed(fsModule, homeDir) { return hasApiKeyLikeValue(auth) || hermesConfigAuthed(fsModule, homeDir); } -function agentAuthed(agentKey, installed, fsModule, homeDir) { +// Login state plus, for the OAuth agents, when the stored credential runs out. +// `expiresAt` is null whenever the agent has no such timestamp on disk, which is +// the case for every host-managed API key. +function agentCredential(agentKey, installed, fsModule, homeDir) { switch (agentKey) { case 'claude': - return claudeAuthed(fsModule, homeDir); + return claudeCredential(fsModule, homeDir); case 'codex': - return codexAuthed(fsModule, homeDir); - case 'agy': - return agyAuthed(fsModule, homeDir); + return codexCredential(fsModule, homeDir); case 'hermes': - return hermesAuthed(fsModule, homeDir); + return { authed: hermesAuthed(fsModule, homeDir), expiresAt: null }; case 'opencode': - return installed; + return { authed: installed, expiresAt: null }; default: - return false; + return { authed: false, expiresAt: null }; } } @@ -128,11 +139,12 @@ function buildStatuses({ fsModule, homeDir, commandExistsFn }) { const statuses = {}; for (const agent of Object.values(AGENTS)) { const installed = commandExistsFn(agent.bin || agent.key); - const authed = agentAuthed(agent.key, installed, fsModule, homeDir); + const credential = agentCredential(agent.key, installed, fsModule, homeDir); statuses[agent.key] = { installed, - authed, + authed: credential.authed, authKind: AUTH_KIND[agent.key] || 'unknown', + credentialExpiresAt: credential.expiresAt, }; } return statuses; diff --git a/server/lib/agent-turn.js b/server/lib/agent-turn.js index 0cdbfa9..a223447 100644 --- a/server/lib/agent-turn.js +++ b/server/lib/agent-turn.js @@ -356,7 +356,7 @@ async function runAgentTurn(options) { awaitingFirstToken: false, // Keep the turn's progress/step lines instead of wiping them so the app // can fold them into a collapsed "thinking" area on the finished bubble - // (otherwise codex/agy's execution summary vanishes the moment it ends). + // (otherwise codex's execution summary vanishes the moment it ends). progressLines: Array.isArray(message.metadata.progressLines) ? message.metadata.progressLines : [], diff --git a/server/lib/agents.js b/server/lib/agents.js index 97b3b92..5e0b953 100644 --- a/server/lib/agents.js +++ b/server/lib/agents.js @@ -1,42 +1,29 @@ 'use strict'; -const { spawn, spawnSync } = require('child_process'); -const crypto = require('crypto'); const fs = require('fs'); const os = require('os'); const path = require('path'); const { getDefaultWorkdir } = require('./workdir'); -const { buildArgs } = require('./agent-options'); +const { + claudeSdkOptions, + acpSessionOptions, + codexSessionOptions, +} = require('./agent-options'); const { createJsonStore } = require('./json-store'); +const { createClaudeSessionPool } = require('./claude-session-pool'); +const { createAcpSessionPool } = require('./acp-session-pool'); +const { createCodexSessionPool } = require('./codex-session-pool'); const TIMEOUT_MS = parseInt( process.env.AGENT_TIMEOUT_MS || String(60 * 60 * 1000), 10, ); -// Cap how much process output we hold in memory. A long `claude --verbose -// stream-json` run can emit tens of MB to stdout; the captured buffer is only -// used as an error fallback (the real reply is parsed line-by-line or read from -// codex's -o file), so keeping just the tail bounds memory without losing the -// most recent, most relevant output. -const MAX_CAPTURED_OUTPUT = 8 * 1024 * 1024; - -function appendCapped(buffer, text) { - const next = buffer + text; - return next.length > MAX_CAPTURED_OUTPUT - ? next.slice(next.length - MAX_CAPTURED_OUTPUT) - : next; -} - // Persistent CLI sessions: each session key keeps one continuous conversation. // Keys are scoped by workdir + agent + optional chat session id. clearSession // lets the app start a fresh machine-side conversation after history is cleared. const SESSION_FILE = path.join(__dirname, '..', 'agent-sessions.json'); -const CODEX_STATE_DB = path.join(os.homedir(), '.codex', 'state_5.sqlite'); -const AGY_ROOT = path.join(os.homedir(), '.gemini', 'antigravity-cli'); -const UUID_RE = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; class AgentCancelledError extends Error { constructor() { @@ -69,7 +56,7 @@ const AUTH_ERROR_RE = new RegExp( 'please log\\s?in', 'please sign in', '(?:must|need to) log\\s?in', - 'run\\s+`?(?:claude|codex|agy|gemini)?\\s*login`?', + 'run\\s+`?(?:claude|codex)?\\s*login`?', '/login\\b', 'login (?:required|expired)', 'unauthorized', @@ -107,81 +94,6 @@ function clearSession(sessionKey) { return true; } -function sqlIdentifier(value) { - return `"${String(value).replace(/"/g, '""')}"`; -} - -function sqlLiteral(value) { - if (value === null || value === undefined) return 'NULL'; - if (typeof value === 'number') { - return Number.isFinite(value) ? String(value) : 'NULL'; - } - if (typeof value === 'boolean') return value ? '1' : '0'; - return `'${String(value).replace(/'/g, "''")}'`; -} - -function sqliteRun(dbPath, sql, options = {}) { - const args = []; - if (options.json) args.push('-json'); - args.push(dbPath, sql); - const result = spawnSync('sqlite3', args, { - encoding: 'utf8', - maxBuffer: 16 * 1024 * 1024, - }); - if (result.error) throw result.error; - if (result.status !== 0) { - const detail = String(result.stderr || result.stdout || '').trim(); - throw new Error(detail || `sqlite3 exited with code ${result.status}`); - } - return String(result.stdout || ''); -} - -function sqliteJson(dbPath, sql) { - const raw = sqliteRun(dbPath, sql, { json: true }).trim(); - return raw ? JSON.parse(raw) : []; -} - -function sqliteExec(dbPath, sql) { - sqliteRun(dbPath, `PRAGMA busy_timeout=5000; ${sql}`); -} - -function copySqliteDatabase(srcPath, destPath) { - try { - sqliteExec(srcPath, `VACUUM INTO ${sqlLiteral(destPath)};`); - } catch (_err) { - fs.copyFileSync(srcPath, destPath, fs.constants.COPYFILE_EXCL); - } -} - -function replaceExactTextInFile(filePath, from, to) { - const text = fs.readFileSync(filePath, 'utf8'); - if (!text.includes(from)) return; - fs.writeFileSync(filePath, text.split(from).join(to)); -} - -function replaceExactTextInTree(rootDir, from, to) { - if (!fs.existsSync(rootDir)) return; - const stack = [rootDir]; - while (stack.length) { - const current = stack.pop(); - const entries = fs.readdirSync(current, { withFileTypes: true }); - for (const entry of entries) { - const entryPath = path.join(current, entry.name); - if (entry.isDirectory()) { - stack.push(entryPath); - continue; - } - if (!entry.isFile()) continue; - if (!/\.(json|jsonl|md|txt|log)$/i.test(entry.name)) continue; - try { - replaceExactTextInFile(entryPath, from, to); - } catch (_err) { - // Best-effort cleanup of visible text references inside the copied tree. - } - } - } -} - // Some installers (opencode) put the binary in a per-user dir that isn't on the // server's PATH, so detection scans PATH first, then known fallback locations. // Results are cached briefly so /api/agents stays fast. @@ -259,13 +171,6 @@ function oneLine(value, max = 100) { return text.length > max ? `${text.slice(0, max - 1)}...` : text; } -function fallback(stdout, stderr, code, label) { - const merged = [String(stdout).trim(), String(stderr).trim()] - .filter(Boolean) - .join('\n'); - return merged || `(${label} exited with code ${code}, no output)`; -} - function makeDeltaEmitter(onEvent) { let streamed = ''; return (value) => { @@ -285,107 +190,6 @@ function makeDeltaEmitter(onEvent) { }; } -function spawnStream({ cmd, args, cwd, label, onLine, finalize, signal }) { - return new Promise((resolve, reject) => { - if (signal && signal.aborted) { - reject(new AgentCancelledError()); - return; - } - - const proc = spawn(cmd, args, { - cwd, - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], - }); - // Decode as UTF-8 at the stream layer so Node's StringDecoder buffers any - // multi-byte character (e.g. a 3-byte Chinese glyph) that straddles a chunk - // boundary. Calling chunk.toString() per-chunk would split it and emit U+FFFD - // replacement characters (the "���" tofu) into the output. - proc.stdout.setEncoding('utf8'); - proc.stderr.setEncoding('utf8'); - - let stdout = ''; - let stderr = ''; - let buffer = ''; - let finished = false; - - const cleanup = () => { - clearTimeout(timer); - if (signal) signal.removeEventListener('abort', cancel); - }; - - const cancel = () => { - if (finished) return; - finished = true; - cleanup(); - proc.kill('SIGKILL'); - reject(new AgentCancelledError()); - }; - - const timer = setTimeout(() => { - if (finished) return; - finished = true; - cleanup(); - proc.kill('SIGKILL'); - resolve( - `Timed out after ${Math.round( - TIMEOUT_MS / 60000, - )} minutes and was stopped. Split the task or simplify the prompt.`, - ); - }, TIMEOUT_MS); - - if (signal) signal.addEventListener('abort', cancel, { once: true }); - - proc.stdout.on('data', (chunk) => { - const text = chunk.toString(); - stdout = appendCapped(stdout, text); - if (!onLine) return; - buffer += text; - let index; - while ((index = buffer.indexOf('\n')) >= 0) { - const line = buffer.slice(0, index); - buffer = buffer.slice(index + 1); - if (line.trim()) { - try { - onLine(line); - } catch (_err) { - // Ignore malformed progress lines. - } - } - } - }); - - proc.stderr.on('data', (chunk) => { - stderr = appendCapped(stderr, chunk.toString()); - }); - - proc.on('error', (err) => { - if (finished) return; - finished = true; - cleanup(); - resolve(`Unable to start ${label}: ${err.message}`); - }); - - proc.on('close', (code) => { - if (finished) return; - finished = true; - cleanup(); - if (onLine && buffer.trim()) { - try { - onLine(buffer); - } catch (_err) { - // Ignore trailing malformed progress. - } - } - try { - resolve(finalize({ code, stdout, stderr })); - } catch (err) { - resolve(`${label} output parsing failed: ${err.message}`); - } - }); - }); -} - // Shared tail for every runner: resolve the __retry marker (a stale resumed // session was cleared — run the turn once more from scratch) and the // __authError marker (raise a typed error instead of returning CLI text). @@ -423,27 +227,112 @@ function toolBrief(name, input) { return `${name}${detail ? `: ${oneLine(detail, 80)}` : ''}`; } -// Core Claude invocation shared by the normal chat runner and the /btw sidekick. -// `resumeId` resumes that session (optionally forked so the original is left -// untouched); when null a brand-new session is started. The resolved/forked -// session id is persisted under `sessionKey`. -function runClaudeInvocation({ - prompt, - onEvent, - signal, - workdir, - settings, - sessionKey, - resumeId = null, - forkSession = false, - canRetry = true, - retry, -}) { +// Relay drives whichever `claude` the host has installed and logged into, so +// the SDK is pointed at that binary instead of the copy it ships — same +// version, same auth, same settings as the CLI shown in the app. Falling back +// to null lets the SDK resolve its own bundled binary. +function claudeExecutablePath() { + return process.env.RELAY_CLAUDE_BIN || executableInPath('claude') || null; +} + +// One live CLI process per scope, reused across turns instead of respawned per +// turn. See claude-session-pool.js for the lifecycle and why the pool is only +// ever a cache over the stored session id. +const claudePool = createClaudeSessionPool({ turnTimeoutMs: TIMEOUT_MS }); + +// opencode and hermes both ship an `acp` subcommand: the same idea as the +// Claude SDK over a different protocol — one process that stays open and takes +// turn after turn. See acp-session-pool.js. ACP itself has no delete, so the +// purge path shells out to each CLI, which owns its own session store. +const opencodePool = createAcpSessionPool({ + agentKey: 'opencode', + turnTimeoutMs: TIMEOUT_MS, + command: () => { + const bin = locateBin('opencode'); + return bin ? { cmd: bin, args: ['acp'] } : null; + }, + deleteCommand: (sessionId) => { + const bin = locateBin('opencode'); + return bin ? { cmd: bin, args: ['session', 'delete', sessionId] } : null; + }, +}); + +const hermesPool = createAcpSessionPool({ + agentKey: 'hermes', + turnTimeoutMs: TIMEOUT_MS, + command: () => { + const bin = locateBin('hermes'); + return bin ? { cmd: bin, args: ['acp'] } : null; + }, + deleteCommand: (sessionId) => { + const bin = locateBin('hermes'); + return bin + ? { cmd: bin, args: ['sessions', 'delete', '--yes', sessionId] } + : null; + }, +}); + +// codex speaks its own app-server protocol rather than ACP, but the pool +// mechanics are shared. See codex-session-pool.js. +const codexPool = createCodexSessionPool({ + agentKey: 'codex', + turnTimeoutMs: TIMEOUT_MS, + command: () => { + const bin = locateBin('codex'); + return bin ? { cmd: bin, args: ['app-server'] } : null; + }, +}); + +// Agents whose sessions Relay hosts itself, so deleting a chat can request +// machine-side transcript deletion instead of only forgetting its id. +const SESSION_POOLS = { + claude: claudePool, + opencode: opencodePool, + hermes: hermesPool, + codex: codexPool, +}; + +// Clear Relay's scope and ask the integration to delete its CLI-side transcript. +// External deletion is best effort, but omitting the request would always leave +// the forgotten transcript on disk and potentially resumable. +async function purgeSession(sessionKey, options = {}) { + const pool = SESSION_POOLS[String(options.agentKey || '')]; + if (!pool) return clearSession(sessionKey); + const prior = getSession(sessionKey); + const cleared = clearSession(sessionKey); + await pool.forget( + sessionKey, + prior && prior.id + ? { + purge: true, + sessionId: prior.id, + cwd: options.workdir || getDefaultWorkdir(), + } + : {}, + ); + return cleared; +} + +// Close every live agent process. Called on shutdown so a restart never leaves +// orphaned CLI processes holding memory. +function shutdownPools() { + return Promise.all( + Object.values(SESSION_POOLS).map((pool) => + pool.shutdown().catch(() => {}), + ), + ); +} + +// `resumeId` continues that session; when null a brand-new one is started. The +// resolved session id is persisted under `sessionKey`. +function runClaude(prompt, onEvent, sessionKey, signal, workdir, settings) { const cwd = workdir || getDefaultWorkdir(); + const prior = getSession(sessionKey); + const resumeId = prior && prior.id ? prior.id : null; const resuming = !!resumeId; - // Resume reuses the saved session ID; new conversations use our UUID as - // --session-id until the CLI reports the canonical ID. - let sessionId = resuming ? resumeId : crypto.randomUUID(); + // Resume reuses the saved session ID; a new conversation gets its id from + // the CLI's first message and persists it once the turn succeeds. + let sessionId = resuming ? resumeId : null; let finalText = ''; let isError = false; // A turn can contain several assistant messages (Claude's mid-task follow-up @@ -453,830 +342,347 @@ function runClaudeInvocation({ let emitDelta = makeDeltaEmitter(onEvent); let currentMsgId = null; - // model / effort / permission for this scope. buildArgs supplies the - // permission flag too; an unconfigured scope defaults to the acceptEdits - // "auto" tier (--permission-mode acceptEdits), not full bypass. - const args = [ - '--print', - '--output-format', - 'stream-json', - '--include-partial-messages', - '--verbose', - ...buildArgs('claude', settings), - ]; - if (resuming) { - args.push('--resume', sessionId); - // Forking branches the conversation into a new session id, inheriting the - // original's full memory without writing back to it — this is how /btw asks - // a side question without disturbing the main task. - if (forkSession) args.push('--fork-session'); - } else { - args.push('--session-id', sessionId); - } - args.push('--', String(prompt)); + // model / effort / permission for this scope. claudeSdkOptions resolves the + // permission tier too; an unconfigured scope defaults to the acceptEdits + // "auto" tier, not full bypass. These are fixed for the life of a session + // process, so the pool restarts (and resumes) when they change. + const sdkOptions = claudeSdkOptions(settings); - return finishRun(spawnStream({ - cmd: 'claude', - args, - cwd, - label: 'claude', - signal, - onLine: (line) => { - let event; - try { - event = JSON.parse(line); - } catch (_err) { - return; + const onMessage = (event) => { + if (event.session_id) sessionId = event.session_id; + if ( + event.type === 'assistant' && + event.message && + Array.isArray(event.message.content) + ) { + const msgId = event.message.id || 'msg'; + if (currentMsgId !== null && msgId !== currentMsgId) { + // Claude moved on to a fresh follow-up message in the same turn. + emit(onEvent, { type: 'segment' }); + emitDelta = makeDeltaEmitter(onEvent); } - if (event.session_id) sessionId = event.session_id; - if ( - event.type === 'assistant' && - event.message && - Array.isArray(event.message.content) - ) { - const msgId = event.message.id || 'msg'; - if (currentMsgId !== null && msgId !== currentMsgId) { - // Claude moved on to a fresh follow-up message in the same turn. - emit(onEvent, { type: 'segment' }); - emitDelta = makeDeltaEmitter(onEvent); - } - currentMsgId = msgId; - for (const block of event.message.content) { - if (block.type === 'text' && block.text) { - emitDelta(block.text); - emit(onEvent, `Claude: ${oneLine(block.text)}`); - } else if (block.type === 'tool_use') { - emit(onEvent, `Tool: ${toolBrief(block.name, block.input)}`); - } + currentMsgId = msgId; + for (const block of event.message.content) { + if (block.type === 'text' && block.text) { + emitDelta(block.text); + emit(onEvent, `Claude: ${oneLine(block.text)}`); + } else if (block.type === 'tool_use') { + emit(onEvent, `Tool: ${toolBrief(block.name, block.input)}`); } - } else if (event.type === 'result') { - if (event.subtype && event.subtype !== 'success') isError = true; - if (typeof event.result === 'string') finalText = event.result; } - }, - finalize: ({ stderr }) => { - const error = String(stderr).trim(); - if (finalText.trim()) { - if (!isError && sessionKey) setSession(sessionKey, { id: sessionId }); - if (isError && isAuthError(finalText)) return { __authError: true }; - return `${isError ? 'Claude returned an error:\n' : ''}${finalText.trim()}`; - } - // Resume can fail if the CLI removed an old session. Drop it and retry. - if ( - resuming && - /no conversation|session.*(not found|does not exist)|no such session|could not find/i.test( - error, - ) - ) { - if (canRetry) { - if (sessionKey) clearSession(sessionKey); - return { __retry: true }; + } + }; + + const finalize = (stderr) => { + const error = String(stderr || '').trim(); + if (finalText.trim()) { + if (!isError && sessionKey) setSession(sessionKey, { id: sessionId }); + if (isError && isAuthError(finalText)) return { __authError: true }; + return `${isError ? 'Claude returned an error:\n' : ''}${finalText.trim()}`; + } + // Resume can fail if the CLI removed an old session. Drop it and retry. + if ( + resuming && + /no conversation|session.*(not found|does not exist)|no such session|could not find/i.test( + error, + ) + ) { + if (sessionKey) clearSession(sessionKey); + return { __retry: true }; + } + if (isAuthError(error)) return { __authError: true }; + return error || '(claude produced no output)'; + }; + + const run = claudePool + .send({ + key: sessionKey || `claude:${cwd}`, + prompt: String(prompt), + cwd, + sdkOptions, + // Any change to the resolved options means the live process is running + // the wrong configuration and has to be replaced. + optionsKey: JSON.stringify(sdkOptions), + resumeId, + executablePath: claudeExecutablePath(), + signal, + onMessage, + }) + .then( + ({ result, sessionId: resolvedId, stderr }) => { + if (resolvedId) sessionId = resolvedId; + if (typeof result.result === 'string') finalText = result.result; + // A timed-out turn reports the stop reason as the reply, not as a + // Claude error. + if ( + result.subtype && + result.subtype !== 'success' && + result.subtype !== 'timeout' + ) { + isError = true; } - } - if (isAuthError(error)) return { __authError: true }; - return error || '(claude produced no output)'; - }, - }), { agentKey: 'claude', onEvent, retry }); -} + return finalize(stderr); + }, + (err) => { + if (err && err.code === 'AGENT_CANCELLED') throw new AgentCancelledError(); + return finalize(err && err.message); + }, + ); -function runClaude(prompt, onEvent, sessionKey, signal, workdir, settings) { - const prior = getSession(sessionKey); - return runClaudeInvocation({ - prompt, + return finishRun(run, { + agentKey: 'claude', onEvent, - signal, - workdir, - settings, - sessionKey, - resumeId: prior && prior.id ? prior.id : null, retry: () => runClaude(prompt, onEvent, sessionKey, signal, workdir, settings), }); } -// The /btw sidekick: a read-only side question that inherits the main -// conversation's memory. The first question forks the main Claude session (so it -// sees everything so far without ever writing back to it); follow-up questions -// resume that fork so the side chat stays coherent. Permission is forced to the -// plan (read-only) tier — the sidekick never edits. -function runBtw(prompt, onEvent, options = {}) { - const { mainSessionKey, btwSessionKey, signal, workdir, settings } = options; - const readOnlySettings = { ...(settings || {}), permission: 'plan' }; - const btwPrior = getSession(btwSessionKey); - if (btwPrior && btwPrior.id) { - return runClaudeInvocation({ - prompt, - onEvent, - signal, - workdir, - settings: readOnlySettings, - sessionKey: btwSessionKey, - resumeId: btwPrior.id, - // If the side fork is gone, clear it and re-fork from the main thread. - canRetry: true, - retry: () => runBtw(prompt, onEvent, options), - }); - } - const mainPrior = getSession(mainSessionKey); - const mainSessionId = mainPrior && mainPrior.id ? mainPrior.id : null; - return runClaudeInvocation({ - prompt, - onEvent, - signal, - workdir, - settings: readOnlySettings, - sessionKey: btwSessionKey, - resumeId: mainSessionId, - forkSession: !!mainSessionId, - // Forking from the main session: never clear the main session on failure. - canRetry: false, - }); -} - -function runBtwAgent(agentKey, prompt, onEvent, options = {}) { - if (agentKey === 'claude') return runBtw(prompt, onEvent, options); - if (agentKey === 'codex') return runCodexBtw(prompt, onEvent, options); - if (agentKey === 'agy') return runAgyBtw(prompt, onEvent, options); - throw new Error(`BTW is not available for ${agentKey || 'this agent'}`); -} - +// The app-server names item types in camelCase; `codex exec --json` used +// snake_case. Both spellings are accepted so the labels survive either. function codexItemLabel(item) { const type = item.type || item.item_type; - if (type === 'command_execution') { - return `Command: ${oneLine(item.command || '', 80)}`; + if (type === 'commandExecution' || type === 'command_execution') { + const command = Array.isArray(item.command) + ? item.command.join(' ') + : item.command || ''; + return `Command: ${oneLine(command, 80)}`; } - if (type === 'file_change' || type === 'patch_apply') { + if (type === 'fileChange' || type === 'file_change' || type === 'patch_apply') { return 'File change'; } - if (type === 'agent_message') { + if (type === 'agentMessage' || type === 'agent_message') { return `Codex: ${oneLine(item.text || '')}`; } if (type === 'reasoning') return null; - if (type === 'mcp_tool_call') { + if (type === 'mcpToolCall' || type === 'mcp_tool_call') { return `MCP: ${oneLine(item.tool || item.name || '', 60)}`; } - if (type === 'web_search') { + if (type === 'webSearch' || type === 'web_search') { return `Search: ${oneLine(item.query || '', 60)}`; } return null; } -function codexRolloutCopy(parentRolloutPath, parentThreadId, childThreadId) { - const source = fs.readFileSync(parentRolloutPath, 'utf8'); - const hasTrailingNewline = source.endsWith('\n'); - const rawLines = hasTrailingNewline - ? source.slice(0, -1).split('\n') - : source.split('\n'); - const lines = []; - for (let i = 0; i < rawLines.length; i++) { - const line = rawLines[i]; - if (!line.trim()) continue; - try { - JSON.parse(line); - lines.push(line.split(parentThreadId).join(childThreadId)); - } catch (_err) { - // If the source thread is actively being written, the last line may be a - // partial JSON record. Drop that one so the child rollout stays readable. - if (i !== rawLines.length - 1 || hasTrailingNewline) { - lines.push(line.split(parentThreadId).join(childThreadId)); +// codex runs as a persistent app-server thread (see codex-session-pool.js): +// one process hosts every chat, `turn/start` carries a turn, and the reply +// arrives as `item/agentMessage/delta` notifications. Every setting except the +// sandbox applies per turn, so only a sandbox change reopens the thread — and +// that still resumes the same conversation. +function runCodex(prompt, onEvent, sessionKey, signal, workdir, settings) { + const cwd = workdir || getDefaultWorkdir(); + const prior = getSession(sessionKey); + const resumeId = (prior && prior.id) || null; + const options = codexSessionOptions(settings); + // Accumulate the streamed text so a single-message turn has an authoritative + // result; multi-message turns are rebuilt from segments by agent-turn. + let finalText = ''; + let currentItemId = null; + + const onMessage = (event) => { + if (event.type === 'delta') { + // Codex can emit several agent messages in one turn; each new item id + // starts a segment so follow-ups keep their own timestamp. + if (currentItemId !== null && event.itemId !== currentItemId) { + emit(onEvent, { type: 'segment' }); } + currentItemId = event.itemId; + if (!event.text) return; + finalText += event.text; + emit(onEvent, { type: 'delta', text: event.text }); + return; } - } - return `${lines.join('\n')}\n`; -} - -function codexChildRolloutPath(parentRolloutPath, parentThreadId, childThreadId) { - const dir = path.dirname(parentRolloutPath); - const base = path.basename(parentRolloutPath); - if (base.includes(parentThreadId)) { - return path.join(dir, base.split(parentThreadId).join(childThreadId)); - } - const stamp = new Date() - .toISOString() - .replace(/\.\d+Z$/, '') - .replace(/:/g, '-'); - return path.join(dir, `rollout-${stamp}-${childThreadId}.jsonl`); -} - -function cloneCodexThread(parentThreadId) { - if (!UUID_RE.test(String(parentThreadId || ''))) { - throw new Error('Cannot fork Codex BTW: main Codex session id is invalid.'); - } - if (!fs.existsSync(CODEX_STATE_DB)) { - throw new Error('Cannot fork Codex BTW: Codex state database was not found.'); - } - - const rows = sqliteJson( - CODEX_STATE_DB, - `SELECT * FROM threads WHERE id = ${sqlLiteral(parentThreadId)} LIMIT 1;`, - ); - const parent = rows[0]; - if (!parent) { - throw new Error('Cannot fork Codex BTW: main Codex thread was not found.'); - } - if (!parent.rollout_path || !fs.existsSync(parent.rollout_path)) { - throw new Error( - 'Cannot fork Codex BTW: main Codex rollout file was not found.', - ); - } - - const childThreadId = crypto.randomUUID(); - const childRolloutPath = codexChildRolloutPath( - parent.rollout_path, - parentThreadId, - childThreadId, - ); - fs.mkdirSync(path.dirname(childRolloutPath), { recursive: true }); - fs.writeFileSync( - childRolloutPath, - codexRolloutCopy(parent.rollout_path, parentThreadId, childThreadId), - { flag: 'wx' }, - ); + const label = codexItemLabel(event.item); + if (label) emit(onEvent, label); + }; - const nowMs = Date.now(); - const now = Math.floor(nowMs / 1000); - const child = { - ...parent, - id: childThreadId, - rollout_path: childRolloutPath, - created_at: now, - updated_at: now, - created_at_ms: nowMs, - updated_at_ms: nowMs, - tokens_used: 0, - archived: 0, - archived_at: null, - title: parent.title ? `BTW: ${parent.title}` : 'BTW side conversation', - preview: parent.preview ? `BTW: ${parent.preview}` : '', + // Relay pins approvals off on every tier, so this is only a backstop against + // a turn hanging on a prompt nobody can answer. + const onPermission = ({ title }) => { + if (options.approve) return true; + emit(onEvent, `Blocked (needs approval): ${oneLine(title, 60)}`); + return false; }; - const columns = Object.keys(child); - const insertThread = [ - `INSERT INTO threads (${columns.map(sqlIdentifier).join(', ')})`, - `VALUES (${columns - .map((column) => sqlLiteral(child[column])) - .join(', ')});`, - ].join(' '); - const insertTools = [ - 'INSERT OR IGNORE INTO thread_dynamic_tools', - '(thread_id, position, name, description, input_schema, defer_loading,', - 'namespace)', - `SELECT ${sqlLiteral(childThreadId)}, position, name, description,`, - 'input_schema, defer_loading, namespace', - `FROM thread_dynamic_tools WHERE thread_id = ${sqlLiteral(parentThreadId)};`, - ].join(' '); - const insertEdge = [ - 'INSERT OR REPLACE INTO thread_spawn_edges', - '(parent_thread_id, child_thread_id, status)', - `VALUES (${sqlLiteral(parentThreadId)},`, - `${sqlLiteral(childThreadId)}, 'active');`, - ].join(' '); - try { - sqliteExec( - CODEX_STATE_DB, - `BEGIN IMMEDIATE; ${insertThread} ${insertTools} ${insertEdge} COMMIT;`, + + const run = codexPool + .send({ + key: sessionKey || `codex:${cwd}`, + prompt: String(prompt), + cwd, + resumeId, + // Only the sandbox is fixed when a thread is opened. + fixedKey: options.sandbox, + ...options, + signal, + onMessage, + onPermission, + }) + .then( + ({ result, sessionId, startedNew, stderr }) => { + if (startedNew) { + emit(onEvent, 'The old session is no longer valid. Started a new one.'); + } + if (finalText.trim()) { + if (sessionId) setSession(sessionKey, { id: sessionId }); + return finalText.trim(); + } + if (result.stopReason === 'timeout') return result.message; + if (isAuthError(stderr)) return { __authError: true }; + return errorLines(stderr) || '(codex produced no output)'; + }, + (err) => { + if (err && err.code === 'AGENT_CANCELLED') throw new AgentCancelledError(); + const message = (err && err.message) || ''; + const stderr = (err && err.stderr) || ''; + if (isAuthError(message) || isAuthError(stderr)) { + return { __authError: true }; + } + return ( + [message, errorLines(stderr)].filter(Boolean).join('\n').trim() || + '(codex produced no output)' + ); + }, ); - } catch (err) { - // The transaction is atomic, but the rollout file was written first. If the - // insert fails there is no thread row referencing it, so drop the orphan. - try { - fs.unlinkSync(childRolloutPath); - } catch (_err) { - // Already gone. - } - throw err; - } - return childThreadId; -} -function runCodex( + return finishRun(run, { agentKey: 'codex', onEvent }); +} + +// Agents log freely to stderr, so when a turn produced no text at all the tail +// is the only clue — but routine INFO chatter is not an error message, and +// dumping it as the assistant's reply would be worse than saying nothing. +function errorLines(text) { + return String(text || '') + .split(/\r?\n/) + .filter((line) => /error|fatal|critical|traceback|exception/i.test(line)) + .slice(-5) + .join('\n') + .trim(); +} + +// opencode and hermes both run as persistent ACP sessions (see +// acp-session-pool.js): one process hosts every chat, `session/prompt` carries +// a turn, and the reply arrives as `agent_message_chunk` updates — real +// token-level streaming, where the old per-turn CLI paths could stream whole +// JSON lines at best (hermes could not stream at all). A new messageId inside +// one turn marks a follow-up message (segment). +function runAcpAgent({ + agentKey, + pool, prompt, onEvent, sessionKey, signal, workdir, settings, - retryOverride, -) { +}) { const cwd = workdir || getDefaultWorkdir(); const prior = getSession(sessionKey); - const resuming = !!(prior && prior.id); - const lastMsg = path.join( - os.tmpdir(), - `codex-last-${process.pid}-${Date.now()}.txt`, - ); - - // buildArgs supplies model (-m), effort (-c model_reasoning_effort=) and - // permission (default = the workspace-write tier, approvals disabled). The - // `-c` forms work for both `exec` and `exec resume`. - const common = [ - '--json', - ...buildArgs('codex', settings), - '--skip-git-repo-check', - '-o', - lastMsg, - ]; - // The resume subcommand does not support -C, so spawn cwd selects the repo; - // new sessions still pass -C explicitly. - const args = resuming - ? ['exec', 'resume', ...common, prior.id, '--', String(prompt)] - : [ - 'exec', - ...common, - '-C', - cwd, - '--', - String(prompt), - ]; + const resumeId = (prior && prior.id) || null; + // model and mode are applied to the live session over the protocol; `approve` + // decides how Relay answers the agent's approval requests. + const { modelId, modeId, approve } = acpSessionOptions(agentKey, settings); + // Accumulate the streamed text so a single-message turn has an authoritative + // result; multi-message turns are rebuilt from segments by agent-turn. + let finalText = ''; + let currentMsgId = null; - let threadId = resuming ? prior.id : null; - let sawTextDelta = false; - // Codex can emit several agent_message items in one turn; each completed - // message starts a new segment so follow-ups keep their own timestamp. - let emitDelta = makeDeltaEmitter(onEvent); - let pendingNewSegment = false; + // ACP chunks are already deltas, so they are emitted as-is rather than + // through makeDeltaEmitter (whose prefix de-duplication is for CLIs that + // re-send the whole message each time, and would drop a repeated token). + const pushText = (text) => { + if (!text) return; + finalText += text; + emit(onEvent, { type: 'delta', text }); + }; - return finishRun(spawnStream({ - cmd: 'codex', - args, - cwd, - label: 'codex', - signal, - onLine: (line) => { - let event; - try { - event = JSON.parse(line); - } catch (_err) { - return; - } - if (event.type === 'thread.started' && event.thread_id) { - threadId = event.thread_id; - } - const deltaText = - typeof event.text === 'string' - ? event.text - : typeof event.delta === 'string' - ? event.delta - : ''; - if (event.type && String(event.type).includes('delta') && deltaText) { - if (pendingNewSegment) { - emit(onEvent, { type: 'segment' }); - emitDelta = makeDeltaEmitter(onEvent); - pendingNewSegment = false; - } - sawTextDelta = true; - emitDelta(deltaText); - } - if (event.type !== 'item.completed' || !event.item) return; - if (event.item.type === 'agent_message' && event.item.text) { - if (sawTextDelta) emitDelta(event.item.text); - // The next agent_message (if any) belongs to a new segment. - pendingNewSegment = true; + const onMessage = (update) => { + if (update.sessionUpdate === 'agent_message_chunk') { + const msgId = update.messageId || 'msg'; + if (currentMsgId !== null && msgId !== currentMsgId) { + emit(onEvent, { type: 'segment' }); } - const label = codexItemLabel(event.item); - if (label) emit(onEvent, label); - }, - finalize: ({ code, stdout, stderr }) => { - let text = ''; - try { - text = fs.readFileSync(lastMsg, 'utf-8').trim(); - fs.unlinkSync(lastMsg); - } catch (_err) { - // Fall back to process output. - } - const error = String(stderr).trim(); - if ( - !text && - resuming && - /no.*session|session.*not found|unknown session|no recorded|not found/i.test( - error, - ) - ) { - clearSession(sessionKey); - return { __retry: true }; - } - if (text) { - if (threadId) setSession(sessionKey, { id: threadId }); - return text; - } - if (isAuthError(error) || isAuthError(stdout)) return { __authError: true }; - return fallback(stdout, stderr, code, 'codex'); - }, - }), { - agentKey: 'codex', - onEvent, - retry: - retryOverride || - (() => runCodex(prompt, onEvent, sessionKey, signal, workdir, settings)), - }); -} - -function runCodexBtw(prompt, onEvent, options = {}) { - const { mainSessionKey, btwSessionKey, signal, workdir, settings } = options; - const readOnlySettings = { ...(settings || {}), permission: 'read-only' }; - const btwPrior = getSession(btwSessionKey); - if (!btwPrior || !btwPrior.id) { - const mainPrior = getSession(mainSessionKey); - const mainThreadId = mainPrior && mainPrior.id ? mainPrior.id : null; - if (mainThreadId) { - const childThreadId = cloneCodexThread(mainThreadId); - setSession(btwSessionKey, { - id: childThreadId, - parentId: mainThreadId, - forkedAt: new Date().toISOString(), - }); + currentMsgId = msgId; + const content = update.content || {}; + if (content.type === 'text') pushText(content.text); + } else if (update.sessionUpdate === 'tool_call') { + emit(onEvent, `Tool: ${oneLine(update.title || update.kind || 'tool', 60)}`); } - } - return runCodex( - prompt, - onEvent, - btwSessionKey, - signal, - workdir, - readOnlySettings, - () => runCodexBtw(prompt, onEvent, options), - ); -} - -// agy cannot take an explicit new session ID. It records the latest -// conversation per cwd in last_conversations.json, which we read after a run -// and reuse with --conversation next time. -const AGY_LAST_CONV = path.join( - AGY_ROOT, - 'cache', - 'last_conversations.json', -); -const AGY_CONVERSATIONS_DIR = path.join(AGY_ROOT, 'conversations'); -const AGY_BRAIN_DIR = path.join(AGY_ROOT, 'brain'); - -function cloneAgyConversation(parentConversationId) { - if (!UUID_RE.test(String(parentConversationId || ''))) { - throw new Error( - 'Cannot fork Antigravity BTW: main conversation id is invalid.', - ); - } - const childConversationId = crypto.randomUUID(); - const srcDb = path.join(AGY_CONVERSATIONS_DIR, `${parentConversationId}.db`); - const destDb = path.join(AGY_CONVERSATIONS_DIR, `${childConversationId}.db`); - if (!fs.existsSync(srcDb)) { - throw new Error( - 'Cannot fork Antigravity BTW: main conversation database was not found.', - ); - } - - fs.mkdirSync(AGY_CONVERSATIONS_DIR, { recursive: true }); - copySqliteDatabase(srcDb, destDb); - try { - sqliteExec( - destDb, - `UPDATE trajectory_meta SET cascade_id = ${sqlLiteral(childConversationId)} - WHERE cascade_id = ${sqlLiteral(parentConversationId)};`, - ); - } catch (_err) { - // The filename is the primary lookup key. If metadata rewriting fails, keep - // the cloned database; agy can still resume it by --conversation. - } - - const srcPb = path.join(AGY_CONVERSATIONS_DIR, `${parentConversationId}.pb`); - const destPb = path.join(AGY_CONVERSATIONS_DIR, `${childConversationId}.pb`); - if (fs.existsSync(srcPb) && !fs.existsSync(destPb)) { - fs.copyFileSync(srcPb, destPb, fs.constants.COPYFILE_EXCL); - } - - const srcBrain = path.join(AGY_BRAIN_DIR, parentConversationId); - const destBrain = path.join(AGY_BRAIN_DIR, childConversationId); - if (fs.existsSync(srcBrain) && !fs.existsSync(destBrain)) { - fs.cpSync(srcBrain, destBrain, { recursive: true, errorOnExist: true }); - replaceExactTextInTree(destBrain, parentConversationId, childConversationId); - } - return childConversationId; -} - -function agyReplyFromTranscript(lines, prompt) { - const expectedPrompt = String(prompt || '').trim(); - if (!expectedPrompt) return ''; - - const events = []; - for (const line of lines) { - if (!String(line || '').trim()) { - events.push(null); - continue; - } - try { - events.push(JSON.parse(line)); - } catch (_err) { - events.push(null); - } - } - - let currentUserInputIndex = -1; - for (let i = 0; i < events.length; i++) { - const obj = events[i]; - if ( - obj && - obj.source === 'USER_EXPLICIT' && - obj.type === 'USER_INPUT' && - typeof obj.content === 'string' && - obj.content.trim() === expectedPrompt - ) { - currentUserInputIndex = i; - } - } - if (currentUserInputIndex === -1) return ''; - - let reply = ''; - for (let i = currentUserInputIndex + 1; i < events.length; i++) { - const obj = events[i]; - if ( - obj && - obj.source === 'MODEL' && - obj.type === 'PLANNER_RESPONSE' && - typeof obj.content === 'string' && - obj.content.trim() - ) { - reply = obj.content.trim(); - } - } - return reply; -} - -function agyTranscriptPath(convId) { - if (!convId) return null; - const logDir = path.join( - os.homedir(), - '.gemini', - 'antigravity-cli', - 'brain', - convId, - '.system_generated', - 'logs', - ); - const fullPath = path.join(logDir, 'transcript_full.jsonl'); - const normalPath = path.join(logDir, 'transcript.jsonl'); - if (fs.existsSync(fullPath)) return fullPath; - if (fs.existsSync(normalPath)) return normalPath; - return null; -} - -function readTranscriptLines(targetPath) { - return fs - .readFileSync(targetPath, 'utf-8') - .trim() - .split('\n'); -} - -function agyTranscriptSnapshot(convId) { - const targetPath = agyTranscriptPath(convId); - if (!targetPath) return null; - return { - path: targetPath, - lineCount: readTranscriptLines(targetPath).length, }; -} -// Assemble agy's argv. Pure and exported so the prompt-placement contract is -// pinned by a regression test without spawning the binary. -// -// The one thing that matters here: agy's --print/--prompt is a VALUE flag — it -// takes the prompt as its argument, not a trailing positional. A bare `--print` -// followed by other flags swallows the next one (e.g. --sandbox) as the prompt -// and drops the user's message. So the prompt must ride as a single -// `--print=` token; the `=` form also keeps a prompt that starts with -// '-' or spans multiple lines safely inside the value. buildArgs supplies the -// selected model (--model) plus the permission flag (default --sandbox). -function buildAgyArgs({ settings, cwd, conversationId, prompt }) { - const args = [...buildArgs('agy', settings), '--add-dir', cwd]; - if (conversationId) args.push('--conversation', conversationId); - args.push(`--print=${String(prompt)}`); - return args; -} + // Relay has no approval UI, so a permission request is answered from the + // configured tier instead of being left to hang a turn nobody can unblock. + // The answer is yes or no; the driver picks the option that says so. + const onPermission = ({ title }) => { + if (approve) return true; + emit(onEvent, `Blocked (needs approval): ${oneLine(title, 60)}`); + return false; + }; -function runAgy(prompt, onEvent, sessionKey, signal, workdir, settings) { - const cwd = workdir || getDefaultWorkdir(); - const prior = getSession(sessionKey); - const priorConvId = prior && prior.id ? prior.id : null; - const priorTranscript = agyTranscriptSnapshot(priorConvId); - emit(onEvent, 'Antigravity is working...'); - const args = buildAgyArgs({ - settings, - cwd, - conversationId: priorConvId, - prompt, - }); - return finishRun(spawnStream({ - cmd: 'agy', - args, - cwd, - label: 'antigravity', - onLine: null, - signal, - finalize: ({ code, stdout, stderr }) => { - const text = String(stdout).trim(); - // Capture this conversation ID by cwd for the next turn. - let convId = null; - try { - const map = JSON.parse(fs.readFileSync(AGY_LAST_CONV, 'utf-8')); - if (map[cwd]) { - convId = map[cwd]; - setSession(sessionKey, { id: convId }); + const noOutput = `(${agentKey} produced no output)`; + const run = pool + .send({ + key: sessionKey || `${agentKey}:${cwd}`, + prompt: String(prompt), + cwd, + resumeId, + modelId, + modeId, + signal, + onMessage, + onPermission, + }) + .then( + ({ result, sessionId, startedNew, stderr }) => { + if (startedNew) { + emit(onEvent, 'The old session is no longer valid. Started a new one.'); } - } catch (_err) { - // If it is unavailable, the next turn starts a new conversation. - } - - // agy --print can emit the entire resumed conversation to stdout. Read - // the transcript, but only trust a response that appears after this - // turn's USER_INPUT. Otherwise a stale transcript tail can make the app - // show the previous answer for the current prompt. - if (convId) { - try { - const targetPath = agyTranscriptPath(convId); - if (targetPath) { - const lines = readTranscriptLines(targetPath); - const parseOnlyNewLines = - priorConvId === convId && - priorTranscript && - priorTranscript.path === targetPath && - lines.length >= priorTranscript.lineCount; - const transcriptReply = agyReplyFromTranscript( - parseOnlyNewLines - ? lines.slice(priorTranscript.lineCount) - : lines, - prompt, - ); - if (transcriptReply) return transcriptReply; - } - } catch (_err) { - // Fall back to stdout if transcript parsing fails. + if (finalText.trim()) { + if (sessionId) setSession(sessionKey, { id: sessionId }); + return finalText.trim(); } - } + if (result.stopReason === 'timeout') return result.message; + if (isAuthError(stderr)) return { __authError: true }; + return errorLines(stderr) || noOutput; + }, + (err) => { + if (err && err.code === 'AGENT_CANCELLED') throw new AgentCancelledError(); + const message = (err && err.message) || ''; + const stderr = (err && err.stderr) || ''; + if (isAuthError(message) || isAuthError(stderr)) { + return { __authError: true }; + } + return ( + [message, errorLines(stderr)].filter(Boolean).join('\n').trim() || + noOutput + ); + }, + ); - if (!text && (isAuthError(stdout) || isAuthError(stderr))) { - return { __authError: true }; - } - return text || fallback(stdout, stderr, code, 'agy'); - }, - }), { agentKey: 'agy', onEvent }); + return finishRun(run, { agentKey, onEvent }); } -function runAgyBtw(prompt, onEvent, options = {}) { - const { mainSessionKey, btwSessionKey, signal, workdir, settings } = options; - const sandboxSettings = { ...(settings || {}), permission: 'sandbox' }; - const btwPrior = getSession(btwSessionKey); - if (!btwPrior || !btwPrior.id) { - const mainPrior = getSession(mainSessionKey); - const mainConversationId = mainPrior && mainPrior.id ? mainPrior.id : null; - if (mainConversationId) { - const childConversationId = cloneAgyConversation(mainConversationId); - setSession(btwSessionKey, { - id: childConversationId, - parentId: mainConversationId, - forkedAt: new Date().toISOString(), - }); - } - } - return runAgy(prompt, onEvent, btwSessionKey, signal, workdir, sandboxSettings); -} - -// opencode: `run --format json` streams JSON events (one per line). Each carries -// the sessionID (captured for resume) and `type:"text"` parts hold the assistant -// output; a new messageID marks a follow-up message (segment). Model / effort -// (--variant) / permission flags come from buildArgs; -s resumes a session. function runOpencode(prompt, onEvent, sessionKey, signal, workdir, settings) { - const cwd = workdir || getDefaultWorkdir(); - const bin = locateBin('opencode') || 'opencode'; - const prior = getSession(sessionKey); - const resuming = !!(prior && prior.id); - let sessionId = resuming ? prior.id : null; - // Accumulate the streamed text so a single-message turn has an authoritative - // result; multi-message turns are rebuilt from segments by agent-turn. - let finalText = ''; - const onDelta = (event) => { - if (event && event.type === 'delta' && event.text) finalText += event.text; - onEvent(event); - }; - let emitDelta = makeDeltaEmitter(onDelta); - let currentMsgId = null; - - const args = [ - 'run', - '--format', - 'json', - ...buildArgs('opencode', settings), - '--dir', - cwd, - ]; - if (resuming) args.push('--session', sessionId); - args.push('--', String(prompt)); - - return finishRun(spawnStream({ - cmd: bin, - args, - cwd, - label: 'opencode', - signal, - onLine: (line) => { - let event; - try { - event = JSON.parse(line); - } catch (_err) { - return; - } - if (event.sessionID) sessionId = event.sessionID; - const part = event.part || {}; - if (event.type === 'text' && typeof part.text === 'string' && part.text) { - const msgId = part.messageID || 'msg'; - if (currentMsgId !== null && msgId !== currentMsgId) { - emit(onEvent, { type: 'segment' }); - emitDelta = makeDeltaEmitter(onDelta); - } - currentMsgId = msgId; - emitDelta(part.text); - } else if (event.type === 'tool' || part.type === 'tool') { - const name = part.tool || part.name || event.tool || 'tool'; - emit(onEvent, `Tool: ${oneLine(name, 60)}`); - } - }, - finalize: ({ code, stdout, stderr }) => { - if (finalText.trim()) { - if (sessionId) setSession(sessionKey, { id: sessionId }); - return finalText.trim(); - } - const error = String(stderr).trim(); - if ( - resuming && - /session.*(not found|does not exist)|no.*session|unknown session/i.test( - error, - ) - ) { - clearSession(sessionKey); - return { __retry: true }; - } - if (isAuthError(error) || isAuthError(stdout)) return { __authError: true }; - return fallback(stdout, stderr, code, 'opencode'); - }, - }), { + return runAcpAgent({ agentKey: 'opencode', + pool: opencodePool, + prompt, onEvent, - retry: () => - runOpencode(prompt, onEvent, sessionKey, signal, workdir, settings), + sessionKey, + signal, + workdir, + settings, }); } -// hermes: `chat -q -Q` is the programmatic mode — it prints a -// `session_id: ` line (captured for resume) followed by the final response. -// --resume continues a stored session (verified to carry context). It is not a -// streaming protocol, so the reply lands as one segment. function runHermes(prompt, onEvent, sessionKey, signal, workdir, settings) { - const cwd = workdir || getDefaultWorkdir(); - const bin = locateBin('hermes') || 'hermes'; - const prior = getSession(sessionKey); - const resuming = !!(prior && prior.id); - emit(onEvent, 'Hermes is working...'); - - const args = ['chat', '-q', String(prompt), '-Q', ...buildArgs('hermes', settings)]; - if (resuming) args.push('--resume', prior.id); - - return finishRun(spawnStream({ - cmd: bin, - args, - cwd, - label: 'hermes', - onLine: null, - signal, - finalize: ({ code, stdout, stderr }) => { - const error = String(stderr).trim(); - // The reply is the clean stdout; hermes prints `session_id: ` and the - // "↻ Resumed session ..." banner to stderr. - const sidMatch = error.match(/session_id:\s*(\S+)/); - const sid = sidMatch ? sidMatch[1] : null; - const text = String(stdout) - .split(/\r?\n/) - .filter((line) => !/^\s*↻/.test(line)) - .join('\n') - .trim(); - if (text) { - if (sid) setSession(sessionKey, { id: sid }); - return text; - } - // A stored session can vanish (e.g. record_sessions disabled). Drop it and - // retry once without --resume. - if ( - resuming && - /session.*(not found|does not exist)|no.*session|unknown session/i.test( - error, - ) - ) { - clearSession(sessionKey); - return { __retry: true }; - } - if (isAuthError(stdout) || isAuthError(error)) return { __authError: true }; - return fallback(stdout, stderr, code, 'hermes'); - }, - }), { + return runAcpAgent({ agentKey: 'hermes', + pool: hermesPool, + prompt, onEvent, - retry: () => - runHermes(prompt, onEvent, sessionKey, signal, workdir, settings), + sessionKey, + signal, + workdir, + settings, }); } @@ -1293,12 +699,6 @@ const AGENTS = { description: 'OpenAI Codex CLI', run: runCodex, }, - agy: { - key: 'agy', - label: 'Antigravity', - description: 'Antigravity CLI', - run: runAgy, - }, // Experimental: listed in the app with explicit install/auth status. opencode: { key: 'opencode', @@ -1358,10 +758,12 @@ module.exports = { getAgent, commandExists, runAgent, - runBtw, - runBtwAgent, getSession, clearSession, - agyReplyFromTranscript, - buildAgyArgs, + purgeSession, + shutdownPools, + claudePool, + opencodePool, + hermesPool, + codexPool, }; diff --git a/server/lib/agy-paths.js b/server/lib/agy-paths.js deleted file mode 100644 index c2b5d5b..0000000 --- a/server/lib/agy-paths.js +++ /dev/null @@ -1,25 +0,0 @@ -'use strict'; - -// Shared Antigravity (`agy`) on-disk locations. usage.js (quota plan label) and -// agent-options.js (default model) both need the model configured in agy's own -// settings.json, so the path + read live here once instead of in each module. - -const fs = require('fs'); -const os = require('os'); -const path = require('path'); - -const AGY_DIR = path.join(os.homedir(), '.gemini', 'antigravity-cli'); -const AGY_SETTINGS = path.join(AGY_DIR, 'settings.json'); - -// The model label configured in agy's settings.json, or '' when unset or -// unreadable. Best-effort: a missing/corrupt file is just "no preference". -function configuredAgyModel() { - try { - const parsed = JSON.parse(fs.readFileSync(AGY_SETTINGS, 'utf-8')); - return typeof parsed.model === 'string' ? parsed.model : ''; - } catch (_err) { - return ''; - } -} - -module.exports = { AGY_DIR, configuredAgyModel }; diff --git a/server/lib/claude-session-pool.js b/server/lib/claude-session-pool.js new file mode 100644 index 0000000..20eb128 --- /dev/null +++ b/server/lib/claude-session-pool.js @@ -0,0 +1,431 @@ +'use strict'; + +// Persistent Claude sessions. +// +// Relay used to run one `claude --print --resume ` process per turn: the +// process died the moment the turn ended, so anything it started in the +// background (watchers, servers, long-running tasks) died with it, and every +// turn paid the cold-start cost of booting the CLI, its MCP servers, and the +// stored transcript. +// +// This pool keeps one live `query()` per scope instead, using the Agent SDK's +// streaming-input mode — the same thing a terminal session is: one process that +// stays open and takes message after message on stdin. +// +// The pool is a *cache*, never the source of truth. The session id in +// agent-sessions.json stays authoritative, so whenever a live process is +// missing, evicted, or dies, the next turn cold-starts with `resume: ` and +// behaves exactly like the old per-turn model. That keeps the failure mode of +// "no warm process" identical to Relay's previous behaviour rather than a new +// one. +const DEFAULT_IDLE_MS = 15 * 60 * 1000; +const DEFAULT_MAX_LIVE = 3; +// After an interrupt, how long to wait for the CLI to wind the turn down +// cleanly before falling back to killing the process. Interrupt is the whole +// point of keeping the session alive, but cancel must never hang on it. +const INTERRUPT_GRACE_MS = 5000; + +function positiveInt(value, fallbackValue, min, max) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed >= min && parsed <= max + ? parsed + : fallbackValue; +} + +function cancelledError() { + const err = new Error('request cancelled'); + err.code = 'AGENT_CANCELLED'; + return err; +} + +function sessionLostError(cause) { + const err = new Error( + cause && cause.message ? cause.message : 'claude session ended', + ); + err.code = 'CLAUDE_SESSION_LOST'; + if (cause) err.cause = cause; + return err; +} + +// stdin for one live session: an async iterable the SDK drains, that we push +// user messages into as turns arrive. Staying un-ended is what keeps the CLI +// process alive between turns. +function createInputQueue() { + const pending = []; + const waiters = []; + let ended = false; + return { + push(text) { + const message = { + type: 'user', + message: { role: 'user', content: String(text) }, + parent_tool_use_id: null, + }; + const waiter = waiters.shift(); + if (waiter) waiter({ value: message, done: false }); + else pending.push(message); + }, + end() { + if (ended) return; + ended = true; + while (waiters.length) waiters.shift()({ value: undefined, done: true }); + }, + [Symbol.asyncIterator]() { + return { + next() { + if (pending.length) { + return Promise.resolve({ value: pending.shift(), done: false }); + } + if (ended) return Promise.resolve({ value: undefined, done: true }); + return new Promise((resolve) => waiters.push(resolve)); + }, + }; + }, + }; +} + +function createClaudeSessionPool(options = {}) { + const env = options.env || process.env; + // Explicit options win as given; only operator-supplied env values are + // clamped to a sane range. + const idleMs = + options.idleMs ?? + positiveInt( + env.RELAY_CLAUDE_IDLE_MS, + DEFAULT_IDLE_MS, + 10 * 1000, + 24 * 60 * 60 * 1000, + ); + const maxLive = + options.maxLive ?? + positiveInt(env.RELAY_CLAUDE_MAX_LIVE, DEFAULT_MAX_LIVE, 1, 64); + const now = options.now || (() => Date.now()); + const turnTimeoutMs = options.turnTimeoutMs || 60 * 60 * 1000; + const interruptGraceMs = options.interruptGraceMs ?? INTERRUPT_GRACE_MS; + + // The SDK is ESM-only and the server is CommonJS, so it is loaded lazily via + // dynamic import (works on every supported Node) and cached. + let sdkPromise = null; + function loadSdk() { + if (options.sdk) return Promise.resolve(options.sdk); + if (!sdkPromise) sdkPromise = import('@anthropic-ai/claude-agent-sdk'); + return sdkPromise; + } + + const live = new Map(); + const slotWaiters = []; + + function releaseSlot() { + const waiter = slotWaiters.shift(); + if (waiter) waiter(); + } + + // A finished turn frees the session for eviction but not the slot: the + // process stays live. Group chats summon several members at once, so without + // this a caller waiting on the cap would never be woken by a turn ending — + // only by an unrelated eviction. + function pumpWaiters() { + if (!slotWaiters.length) return; + const victim = lruIdleEntry(); + if (victim) closeEntry(victim).catch(() => {}); + } + + function clearIdleTimer(entry) { + if (entry.idleTimer) clearTimeout(entry.idleTimer); + entry.idleTimer = null; + } + + function scheduleIdleClose(entry) { + clearIdleTimer(entry); + if (entry.closed) return; + entry.idleTimer = setTimeout(() => { + if (entry.turn) return; + closeEntry(entry).catch(() => {}); + }, idleMs); + if (typeof entry.idleTimer.unref === 'function') entry.idleTimer.unref(); + } + + function settleTurn(entry, settle) { + const turn = entry.turn; + if (!turn) return; + entry.turn = null; + clearTimeout(turn.timer); + if (turn.detachAbort) turn.detachAbort(); + settle(turn); + } + + async function closeEntry(entry, err) { + if (entry.closed) return; + entry.closed = true; + clearIdleTimer(entry); + if (live.get(entry.key) === entry) live.delete(entry.key); + settleTurn(entry, (turn) => { + if (turn.cancelled) return turn.reject(cancelledError()); + const lost = sessionLostError(err); + // Whether the user already saw part of this turn decides if it can be + // silently re-run. + lost.emitted = turn.emitted; + return turn.reject(lost); + }); + try { + entry.input.end(); + } catch (_err) { + // Already ended. + } + try { + entry.query.close(); + } catch (_err) { + // Already gone. + } + releaseSlot(); + } + + // Evict the least recently used session that is not mid-turn. + function lruIdleEntry() { + let victim = null; + for (const entry of live.values()) { + if (entry.turn || entry.closed) continue; + if (!victim || entry.lastActivity < victim.lastActivity) victim = entry; + } + return victim; + } + + async function acquireSlot() { + // The cap covers every live CLI process the pool owns. When every session + // is mid-turn there is nothing safe to evict, so the caller waits for a + // slot rather than the pool quietly exceeding its own memory budget. + while (live.size >= maxLive) { + const victim = lruIdleEntry(); + if (victim) { + await closeEntry(victim); + continue; + } + await new Promise((resolve) => slotWaiters.push(resolve)); + } + } + + function routeMessage(entry, message) { + if (message && message.session_id) entry.sessionId = message.session_id; + const turn = entry.turn; + if (!turn) return; + if (message && message.type === 'result') { + settleTurn(entry, (settled) => { + if (settled.cancelled) settled.reject(cancelledError()); + else settled.resolve(message); + }); + return; + } + // Only assistant messages reach the user, and they are what makes a silent + // retry unsafe. + if (message && message.type === 'assistant') turn.emitted = true; + try { + turn.onMessage(message); + } catch (_err) { + // A rendering failure must not take the session down. + } + } + + async function spawnEntry(request) { + // Load before taking a slot: nothing may await between acquireSlot() and + // registering the entry, or two concurrent spawns both pass the cap check. + const { query } = await loadSdk(); + await acquireSlot(); + const input = createInputQueue(); + const entry = { + key: request.key, + cwd: request.cwd, + optionsKey: request.optionsKey, + sessionId: request.resumeId || null, + input, + query: null, + turn: null, + idleTimer: null, + closed: false, + lastActivity: now(), + }; + // Reserve the slot before the first await so two concurrent callers can't + // both slip past the cap. + live.set(entry.key, entry); + try { + entry.query = query({ + prompt: input, + options: { + ...request.sdkOptions, + cwd: request.cwd, + // Match what the plain CLI does: Claude Code's own system prompt and + // the user's on-disk settings, CLAUDE.md, and MCP servers. + systemPrompt: { type: 'preset', preset: 'claude_code' }, + includePartialMessages: true, + ...(request.resumeId ? { resume: request.resumeId } : {}), + ...(request.executablePath + ? { pathToClaudeCodeExecutable: request.executablePath } + : {}), + stderr: (data) => { + entry.stderr = `${entry.stderr || ''}${data}`.slice(-8192); + }, + }, + }); + } catch (err) { + await closeEntry(entry, err); + throw err; + } + entry.reader = (async () => { + try { + for await (const message of entry.query) routeMessage(entry, message); + await closeEntry(entry); + } catch (err) { + await closeEntry(entry, err); + } + })(); + return entry; + } + + function runTurn(entry, request) { + return new Promise((resolve, reject) => { + const turn = { + onMessage: request.onMessage || (() => {}), + emitted: false, + cancelled: false, + resolve, + reject, + timer: null, + detachAbort: null, + }; + entry.turn = turn; + entry.lastActivity = now(); + clearIdleTimer(entry); + + const stop = (reason) => { + if (entry.turn !== turn) return; + turn.cancelled = true; + Promise.resolve() + .then(() => entry.query.interrupt()) + .catch(() => {}) + .then(() => { + // Interrupt is best-effort: if the CLI does not wind the turn down + // promptly, drop the whole session so cancel is never a hang. + setTimeout(() => { + if (entry.turn === turn) closeEntry(entry, reason).catch(() => {}); + }, interruptGraceMs).unref?.(); + }); + }; + + const signal = request.signal; + if (signal) { + if (signal.aborted) { + entry.turn = null; + reject(cancelledError()); + scheduleIdleClose(entry); + return; + } + const onAbort = () => stop(new Error('cancelled')); + signal.addEventListener('abort', onAbort, { once: true }); + turn.detachAbort = () => signal.removeEventListener('abort', onAbort); + } + + turn.timer = setTimeout(() => { + settleTurn(entry, (settled) => { + settled.resolve({ + type: 'result', + subtype: 'timeout', + is_error: true, + result: `Timed out after ${Math.round( + turnTimeoutMs / 60000, + )} minutes and was stopped. Split the task or simplify the prompt.`, + }); + }); + closeEntry(entry, new Error('turn timed out')).catch(() => {}); + }, turnTimeoutMs); + if (typeof turn.timer.unref === 'function') turn.timer.unref(); + + try { + entry.input.push(request.prompt); + } catch (err) { + settleTurn(entry, (settled) => settled.reject(err)); + } + }).then( + (result) => { + entry.lastActivity = now(); + scheduleIdleClose(entry); + pumpWaiters(); + return { result, sessionId: entry.sessionId, stderr: entry.stderr || '' }; + }, + (err) => { + entry.lastActivity = now(); + if (!entry.closed) scheduleIdleClose(entry); + pumpWaiters(); + throw err; + }, + ); + } + + // Run one turn on `key`, reusing the live session when there is one. + async function send(request) { + let entry = live.get(request.key); + // The live session knows the current id (a brand-new or forked session gets + // one the caller has not stored yet), so prefer it over the caller's. + let resumeId = (entry && entry.sessionId) || request.resumeId; + if ( + entry && + (entry.closed || + entry.cwd !== request.cwd || + entry.optionsKey !== request.optionsKey) + ) { + // Model / effort / permission / fast-mode changes are fixed at spawn + // time, so a settings change restarts the process and resumes into the + // same conversation. The user sees continuity; the flags are re-applied. + await closeEntry(entry); + entry = null; + } + const warm = !!entry; + // A restart resumes the conversation it replaced; the live session's id + // wins over the caller's, which may be one turn behind. + if (!entry) entry = await spawnEntry({ ...request, resumeId }); + try { + return await runTurn(entry, request); + } catch (err) { + const lost = err && err.code === 'CLAUDE_SESSION_LOST' && !err.emitted; + if (!warm || !lost) throw err; + // A warm session died before producing anything. Fall back to the cold + // path so a stale pooled process is never worse than no pool at all. + const fresh = await spawnEntry({ ...request, resumeId }); + return runTurn(fresh, request); + } + } + + // Drop the live process for a scope. With `purge`, also make a best-effort + // request to delete the stored transcript. + async function forget(key, opts = {}) { + const entry = live.get(key); + const sessionId = opts.sessionId || (entry && entry.sessionId) || null; + const cwd = opts.cwd || (entry && entry.cwd) || undefined; + if (entry) await closeEntry(entry); + if (!opts.purge || !sessionId) return false; + try { + const { deleteSession } = await loadSdk(); + await deleteSession(sessionId, cwd ? { dir: cwd } : undefined); + return true; + } catch (_err) { + // The transcript may already be gone; the scope is dropped either way. + return false; + } + } + + async function shutdown() { + await Promise.all([...live.values()].map((entry) => closeEntry(entry))); + } + + function stats() { + return { + live: live.size, + maxLive, + idleMs, + waiting: slotWaiters.length, + keys: [...live.keys()], + }; + } + + return { send, forget, shutdown, stats }; +} + +module.exports = { createClaudeSessionPool }; diff --git a/server/lib/codex-session-pool.js b/server/lib/codex-session-pool.js new file mode 100644 index 0000000..6d41068 --- /dev/null +++ b/server/lib/codex-session-pool.js @@ -0,0 +1,181 @@ +'use strict'; + +const { createStdioAgentPool } = require('./stdio-agent-pool'); + +// The Codex app-server driver. stdio-agent-pool.js owns the process, the wire +// and the session cap; this file is only the protocol. +// +// `codex app-server` is JSON-RPC 2.0 on stdio like ACP, but its turn lifecycle +// is different in one way that shapes this file: `turn/start` returns as soon +// as the turn is *accepted*, and the turn ends later with a `turn/completed` +// notification. So the request only records the turn id (which cancellation +// needs) and the pool's turn is settled from the notification stream. +// +// Everything Relay configures except the sandbox can be set per turn, so a +// settings change never reopens anything. The sandbox is fixed when a thread is +// opened, which is what the runner passes as `fixedKey`. +const CLIENT_INFO = { name: 'relay', title: 'Relay', version: '1' }; + +// The decision vocabulary differs per approval request, and answering with the +// wrong token reads as a denial, so each is spelled out rather than guessed. +const APPROVAL_DECISIONS = { + 'item/commandExecution/requestApproval': { yes: 'accept', no: 'decline' }, + 'item/fileChange/requestApproval': { yes: 'accept', no: 'decline' }, + execCommandApproval: { yes: 'approved', no: 'abort' }, + applyPatchApproval: { yes: 'approved', no: 'abort' }, +}; + +function approvalTitle(params) { + const command = params.command || (params.toolCall && params.toolCall.title); + if (Array.isArray(command)) return command.join(' '); + return command || params.itemId || 'tool call'; +} + +function threadOptions(req) { + const options = { cwd: req.cwd }; + if (req.sandbox) options.sandbox = req.sandbox; + if (req.approvalPolicy) options.approvalPolicy = req.approvalPolicy; + if (req.model) options.model = req.model; + if (req.serviceTier) options.serviceTier = req.serviceTier; + return options; +} + +function createCodexDriver(rpc) { + // Only the turn/start response carries the id, so a turn cancelled before it + // arrives has nothing to interrupt yet — interrupt() is called again from + // there. Without that, cancelling early would leave codex running the turn + // until the pool gave up and dropped the whole session. + function interrupt(entry, turn) { + if (!turn.turnId || turn.interrupted) return; + turn.interrupted = true; + rpc + .request('turn/interrupt', { + threadId: entry.sessionId, + turnId: turn.turnId, + }) + .catch(() => { + // The pool's grace timer drops the session if this does not land. + }); + } + + return { + initialize() { + return rpc.request('initialize', { clientInfo: CLIENT_INFO }); + }, + + async openSession(req) { + if (req.resumeId) { + try { + const resumed = await rpc.request('thread/resume', { + threadId: req.resumeId, + ...threadOptions(req), + }); + return { sessionId: resumed.thread.id, startedNew: false }; + } catch (_err) { + // A stored thread codex no longer has. Start a fresh one rather than + // failing the turn — the same recovery the per-turn runner did when + // `exec resume` rejected the id. + } + } + const started = await rpc.request('thread/start', threadOptions(req)); + const sessionId = started && started.thread && started.thread.id; + if (!sessionId) throw new Error('codex returned no thread id'); + return { sessionId, startedNew: !!req.resumeId }; + }, + + closeSession(entry) { + // Releases codex's live state for the thread; the transcript stays on + // disk so the next turn can resume it. + return rpc.request('thread/unsubscribe', { threadId: entry.sessionId }); + }, + + startTurn(entry, req, turn) { + const params = { + threadId: entry.sessionId, + input: [{ type: 'text', text: String(req.prompt) }], + cwd: req.cwd, + }; + if (req.model) params.model = req.model; + if (req.effort) params.effort = req.effort; + if (req.approvalPolicy) params.approvalPolicy = req.approvalPolicy; + if (req.serviceTier) params.serviceTier = req.serviceTier; + rpc.request('turn/start', params).then( + (result) => { + // Only the id: completion arrives as a notification. + turn.turnId = result && result.turn && result.turn.id; + if (turn.cancelled) interrupt(entry, turn); + }, + (err) => turn.fail(err), + ); + }, + + cancelTurn: interrupt, + + deleteSession(sessionId) { + return rpc + .request('thread/delete', { threadId: sessionId }) + .then(() => true, () => false); + }, + + handleMessage(msg) { + const params = msg.params || {}; + // Every Relay tier runs with approvalPolicy "never", so these should not + // arrive at all — but an unanswered request would hang the turn forever, + // so they are answered from the runner's policy anyway. Anything else is + // refused explicitly rather than answered with a shape codex cannot read. + if (msg.id !== undefined) { + const decisions = APPROVAL_DECISIONS[msg.method]; + if (!decisions) { + rpc.replyError(msg.id, -32601, `unsupported method: ${msg.method}`); + return; + } + const entry = rpc.sessionFor(params.threadId); + const turn = entry && entry.turn; + let approve = false; + try { + approve = !!( + turn && + turn.onPermission && + turn.onPermission({ title: approvalTitle(params) }) + ); + } catch (_err) { + approve = false; + } + rpc.reply(msg.id, { decision: approve ? decisions.yes : decisions.no }); + return; + } + + const entry = rpc.sessionFor(params.threadId); + const turn = entry && entry.turn; + if (!turn) return; + switch (msg.method) { + case 'item/agentMessage/delta': + // Assistant text is the only thing that reaches the user, and it is + // what makes a silent retry unsafe. + turn.emitted = true; + turn.onMessage({ type: 'delta', text: params.delta, itemId: params.itemId }); + return; + case 'item/completed': + turn.onMessage({ type: 'item', item: params.item || {} }); + return; + case 'turn/completed': { + const status = (params.turn && params.turn.status) || 'completed'; + turn.finish({ stopReason: status, turn: params.turn }); + return; + } + case 'error': + // A retryable error is codex telling us it is still working. + if (params.willRetry) return; + turn.fail(new Error((params.error && params.error.message) || 'codex error')); + return; + default: + } + }, + }; +} + +function createCodexSessionPool(options = {}) { + return createStdioAgentPool({ ...options, driver: createCodexDriver }); +} + +module.exports = { createCodexSessionPool }; diff --git a/server/lib/filesystem.js b/server/lib/filesystem.js index 42a6874..1059268 100644 --- a/server/lib/filesystem.js +++ b/server/lib/filesystem.js @@ -5,6 +5,7 @@ const os = require('os'); const path = require('path'); const { getDefaultWorkdir, ensureWorkdirExists } = require('./workdir'); +const { TOKENS_FILE } = require('./tokens'); class FilesystemError extends Error { constructor(message, { status = 400, code = 'FS_ERROR' } = {}) { @@ -32,7 +33,9 @@ function isInside(parent, child) { const SERVER_DIR = path.resolve(__dirname, '..'); const SENSITIVE_PATHS = [ - path.join(SERVER_DIR, 'tokens.json'), + // Taken from tokens.js rather than rebuilt here, so RELAY_TOKENS_FILE cannot + // move the token store out from under the denylist. + TOKENS_FILE, path.join(SERVER_DIR, '.env'), path.join(SERVER_DIR, 'credentials'), path.join(SERVER_DIR, 'push-subscriptions.json'), diff --git a/server/lib/group-turn.js b/server/lib/group-turn.js index ebd85e4..75cd1b5 100644 --- a/server/lib/group-turn.js +++ b/server/lib/group-turn.js @@ -1,20 +1,20 @@ 'use strict'; -// Pure helpers for the group-chat orchestrator (see docs/group-chat.md). These -// turn the canonical group transcript into the per-agent prompt material: +// Pure helpers for the group-chat orchestrator (see docs/handbook.md, "Swarms"). +// These turn the canonical group transcript into per-agent prompt material: // // * who authored a transcript message (attribution), // * which agents a human message summons (@mention parsing), // * the delta a given agent has not seen since it last spoke ("plan B"), -// * a speaker-labeled prompt for that delta, bounded to the argv size cap. +// * a speaker-labeled prompt for that delta, bounded to the prompt size cap. // // Keeping them pure (no I/O, no agent runners) makes the labeling — the part the // design calls out as what keeps attribution and tone correct — directly testable. const HUMAN_AUTHOR = 'human'; -// A group prompt rides to the CLI as one argv token like any other, so it must -// stay under the same byte cap. Default leaves headroom below the 100KB chat cap. +// A group prompt shares the ordinary chat byte budget. The default leaves +// headroom below the 100KB request cap. const DEFAULT_MAX_PROMPT_BYTES = 96 * 1024; function slug(value) { @@ -94,23 +94,39 @@ function lineFor(message, labelFor) { } // Build the prompt handed to the agent taking the floor: a header that states it -// is in a group and it is now its turn, an optional `persona` line carrying the -// user's per-member work instructions, then each delta message labeled with its -// speaker. Bounded to maxBytes by keeping the most recent messages and noting any -// omission, so a long silence cannot produce a prompt that exceeds the argv cap. +// is in a group and it is now its turn, the `roster` of members it may summon +// (already excluding itself; empty when agent-to-agent summoning is off), an +// optional `persona` line carrying the user's per-member work instructions, then +// each delta message labeled with its speaker. Bounded to maxBytes by keeping the +// most recent messages and noting any omission, so a long silence cannot produce +// a prompt that exceeds the request budget. function buildGroupPrompt({ selfLabel, persona, delta, labelFor, + roster, maxBytes = DEFAULT_MAX_PROMPT_BYTES, }) { const name = String(selfLabel || 'this agent'); const role = typeof persona === 'string' ? persona.trim() : ''; + // Summoning only happens if the agent knows it can, and knows the exact token + // that resolves. Each entry is `Label (@key)`: the key always parses, while a + // nickname only does when it is a single word. + const others = (Array.isArray(roster) ? roster : []) + .filter((member) => member && member.key) + .map((member) => `${member.label || member.key} (@${member.key})`); + const rosterLine = others.length + ? `\n\nOther members of this swarm: ${others.join(', ')}. ` + + 'Mentioning one of them by that @name hands them the floor once you ' + + 'finish, and they will see this exchange. Only do it when you actually ' + + 'need them; say nothing of the sort to end the exchange.' + : ''; const header = `You are "${name}" in a group chat with a human and possibly other AI agents. ` + 'Each line below is prefixed with its speaker. Reply only as yourself, ' + 'addressing the conversation; it is now your turn to respond.' + + rosterLine + (role ? `\n\nYour role in this swarm: ${role}` : ''); const footer = `(It is now your turn, ${name}.)`; const omitted = '[earlier messages omitted]'; @@ -144,8 +160,8 @@ function buildGroupPrompt({ const body = kept.length > 0 ? kept.join('\n\n') : '(no new messages)'; let prompt = `${header}\n\n${body}\n\n${footer}`; - // Defence in depth: a single oversized message can still blow the budget; hard - // cap the result so it always reaches the CLI rather than failing the spawn. + // Defence in depth: a single oversized message can still blow the budget; + // hard-cap the generated protocol payload before handing it to a runner. if (Buffer.byteLength(prompt, 'utf8') > maxBytes) { prompt = Buffer.from(prompt, 'utf8').subarray(0, maxBytes).toString('utf8'); } diff --git a/server/lib/groups.js b/server/lib/groups.js index 1ea99b6..4466125 100644 --- a/server/lib/groups.js +++ b/server/lib/groups.js @@ -2,10 +2,10 @@ // Swarm (group chat) state: a swarm is a named, ordered set of agent members // that share one canonical transcript. It sits above the per-agent scopes (see -// docs/group-chat.md). Each member keeps its own resumable CLI session; the -// swarm additionally pins its own work tree (`workdir`) and per-member -// model/effort/permission/fast (`memberConfigs`) so it is configured independently of -// each member's solo chat. +// docs/handbook.md, "Swarms"). Each member keeps its own resumable CLI session; +// the swarm additionally pins its own work tree (`workdir`) and per-member +// model/effort/permission/fast (`memberConfigs`) independently of each member's +// solo chat. // // Persisted with the shared json-store (in-memory cache + atomic 0o600 writes), // consistent with the other backend state files. The on-disk shape is: @@ -41,7 +41,7 @@ function normalizeName(value, fallback) { return (text || fallback).slice(0, 80); } -// Members are agent keys (claude, codex, agy, ...). Dedupe, keep order, cap the +// Members are agent keys (claude, codex, opencode, ...). Dedupe, keep order, cap the // count, and reject anything that isn't a plausible agent key so a member can // never inject a separator into a derived scope key. function normalizeMembers(members) { diff --git a/server/lib/model-discovery.js b/server/lib/model-discovery.js index 1723d2a..1b5a584 100644 --- a/server/lib/model-discovery.js +++ b/server/lib/model-discovery.js @@ -22,10 +22,17 @@ const DISABLED = process.env.RELAY_MODEL_DISCOVERY === '0' || process.env.RELAY_MODEL_DISCOVERY === 'false'; -// agentKey -> { stamp, models }. models is a non-empty array or null; both are -// cached so a missing/empty result never re-spawns on every turn. +// agentKey -> { stamp, models, checkedAt }. models is a non-empty array or null; +// both are cached so a missing/empty result never re-spawns on every turn. const cache = new Map(); +// Locating a CLI costs a subprocess (`command -v `), and the option pickers +// ask for the catalog on every open, so re-checking the binary per call put a +// synchronous spawn on a hot path and blocked the event loop for everything +// else. Look for a new binary at most this often; `clearModelDiscoveryCache` +// still busts the entry immediately after a CLI update. +const RECHECK_MS = 60_000; + // Resolve a command name to its real (symlink-followed) absolute path, or null. function resolveBinary(command) { try { @@ -54,7 +61,7 @@ function fileStamp(filePath) { } } -// Stream a (potentially large, ~250MB) binary in chunks, collecting every match +// Stream a potentially large binary in chunks, collecting every match // of `regex` without loading the whole file into memory. A short tail overlap // between chunks keeps a token from being missed at a boundary. function scanFile(filePath, regex) { @@ -270,33 +277,6 @@ function runCodexCatalog(args, timeout = 5000) { return parseCodexCatalog(String(result.stdout || '')); } -// ---- agy --------------------------------------------------------------------- - -// agy has no greppable slugs but ships an `agy models` command that prints -// human-readable names. We pass the printed name straight back as --model; the -// exact arg format is unverified, so this is a best-effort scaffold. -function discoverAgyModels() { - const result = spawnSync('agy', ['models'], { - encoding: 'utf8', - timeout: 8000, - }); - if (result.status !== 0) return null; - const byId = new Map(); - for (const rawLine of String(result.stdout || '').split(/\r?\n/)) { - const line = rawLine.trim(); - if (!line) continue; - // Drop any help/usage noise that isn't a model name. - if (/^(usage|flags?|list available|-h\b|--help\b)/i.test(line)) continue; - const id = line - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); - if (!id || byId.has(id)) continue; - byId.set(id, { id, label: line, args: ['--model', line] }); - } - return byId.size ? [...byId.values()] : null; -} - // ---- shared helpers ---------------------------------------------------------- function sortByFamilyThenVersionDesc(list) { @@ -366,30 +346,40 @@ const STRATEGIES = { return cached.length ? cached : null; }, }, - agy: { - // Resolve the launcher only for cache-stamping; discovery shells out to - // `agy models` rather than reading the (stripped) binary. - locate: () => resolveBinary('agy'), - discover: () => discoverAgyModels(), - }, }; +// Identity of everything a discovered catalog depends on: the CLI binary, plus +// the CLI's own model cache for codex. +function stampFor(agentKey, bin) { + const extra = + agentKey === 'codex' + ? fileStamp(path.join(codexHome(), 'models_cache.json')) || '' + : ''; + return `${fileStamp(bin) || ''}|${extra}`; +} + // Discovered model options for an agent, or null to fall back to the static // catalog. Cached by binary/cache stamps so CLI and catalog updates refresh it. function discoverModels(agentKey) { if (DISABLED) return null; const strategy = STRATEGIES[agentKey]; if (!strategy) return null; + const now = Date.now(); + const cached = cache.get(agentKey); + if (cached && now - cached.checkedAt < RECHECK_MS) return cached.models; try { const bin = strategy.locate(); - if (!bin) return null; - const extraStamp = - agentKey === 'codex' - ? fileStamp(path.join(codexHome(), 'models_cache.json')) || '' - : ''; - const stamp = `${fileStamp(bin) || ''}|${extraStamp}`; - const cached = cache.get(agentKey); - if (cached && cached.stamp === stamp) return cached.models; + if (!bin) { + // Remember "not installed" too, so a host without this CLI does not pay a + // subprocess on every call just to learn that again. + cache.set(agentKey, { stamp: '', models: null, checkedAt: now }); + return null; + } + const stamp = stampFor(agentKey, bin); + if (cached && cached.stamp === stamp) { + cached.checkedAt = now; + return cached.models; + } let models = null; try { models = strategy.discover(bin); @@ -397,12 +387,13 @@ function discoverModels(agentKey) { models = null; } const normalized = Array.isArray(models) && models.length ? models : null; - const finalExtraStamp = - agentKey === 'codex' - ? fileStamp(path.join(codexHome(), 'models_cache.json')) || '' - : ''; - const finalStamp = `${fileStamp(bin) || ''}|${finalExtraStamp}`; - cache.set(agentKey, { stamp: finalStamp, models: normalized }); + cache.set(agentKey, { + // Discovery can rewrite the CLI's own model cache, so stamp again after it + // ran instead of trusting the value read before. + stamp: stampFor(agentKey, bin), + models: normalized, + checkedAt: Date.now(), + }); return normalized; } catch (_err) { return null; diff --git a/server/lib/quota-keepalive.js b/server/lib/quota-keepalive.js new file mode 100644 index 0000000..31a7406 --- /dev/null +++ b/server/lib/quota-keepalive.js @@ -0,0 +1,107 @@ +'use strict'; + +const { getClaudeUsage, invalidateUsageCache, primeClaudeSession } = require('./usage'); + +// Restart the window a little after it lapses so the usage API has already +// rolled over when we ping. +const GRACE_MS = parseInt(process.env.CLAUDE_KEEPALIVE_GRACE_MS || '30000', 10); +// How long to wait before re-reading usage to confirm a ping started a window. +const VERIFY_MS = parseInt(process.env.CLAUDE_KEEPALIVE_VERIFY_MS || '30000', 10); +// Floor between two pings. Guards against hammering the API if a ping somehow +// does not open a window (wrong account, plan without a five-hour window). +const MIN_PRIME_INTERVAL_MS = parseInt( + process.env.CLAUDE_KEEPALIVE_MIN_INTERVAL_MS || '600000', + 10, +); +const ERROR_RETRY_MS = parseInt( + process.env.CLAUDE_KEEPALIVE_ERROR_RETRY_MS || '900000', + 10, +); +const MIN_WAIT_MS = 15_000; +const MAX_WAIT_MS = 6 * 60 * 60 * 1000; + +function clampWait(ms) { + if (!Number.isFinite(ms)) return ERROR_RETRY_MS; + return Math.min(MAX_WAIT_MS, Math.max(MIN_WAIT_MS, Math.round(ms))); +} + +// Pure scheduling decision, split out so the loop stays trivially testable. +// `usage` is the normalized { resetsAt, stale } view of Claude's five-hour block. +function planKeepalive({ usage, now, nextPrimeAllowedAt }) { + const resetsAt = usage && usage.resetsAt ? Date.parse(usage.resetsAt) : NaN; + if (Number.isFinite(resetsAt) && resetsAt > now) { + // Window is running: sleep until just after it lapses. + return { action: 'wait', waitMs: clampWait(resetsAt - now + GRACE_MS) }; + } + if (usage && usage.stale) { + // A cached value whose source is unreachable says nothing about the live + // window; re-read rather than send a turn the user did not ask for. + return { action: 'wait', waitMs: clampWait(VERIFY_MS) }; + } + if (now < nextPrimeAllowedAt) { + return { action: 'wait', waitMs: clampWait(nextPrimeAllowedAt - now) }; + } + return { action: 'prime', waitMs: clampWait(VERIFY_MS) }; +} + +function readClaudeFiveHour(report) { + const block = (report && report.data && report.data.five_hour) || {}; + return { resetsAt: block.resets_at || null, stale: !!(report && report.stale) }; +} + +// Keeps Claude Code's five-hour window cycling so the usage screen never has to +// report "unknown". Codex learns its reset time from a separate minimal request; +// neither provider request should be described as free of quota impact. +function startClaudeQuotaKeepalive({ + readUsage = async () => readClaudeFiveHour(await getClaudeUsage()), + prime = primeClaudeSession, + invalidate = () => invalidateUsageCache('claude'), +} = {}) { + let timer = null; + let stopped = false; + let nextPrimeAllowedAt = 0; + + async function tick() { + let waitMs = ERROR_RETRY_MS; + try { + const usage = await readUsage(); + const plan = planKeepalive({ + usage, + now: Date.now(), + nextPrimeAllowedAt, + }); + waitMs = plan.waitMs; + if (plan.action === 'prime') { + nextPrimeAllowedAt = Date.now() + MIN_PRIME_INTERVAL_MS; + const result = await prime(); + invalidate(); + console.log( + `[quota:claude] keepalive ping sent (HTTP ${result && result.status}); ` + + 'five-hour window restarted', + ); + } + } catch (err) { + console.warn(`[quota:claude] keepalive failed: ${err.message}`); + waitMs = ERROR_RETRY_MS; + } + if (stopped) return; + timer = setTimeout(tick, clampWait(waitMs)); + if (timer.unref) timer.unref(); + } + + console.log( + '[quota:claude] five-hour keepalive started; a minimal request restarts the ' + + 'window whenever it lapses', + ); + tick(); + + return { + stop() { + stopped = true; + if (timer) clearTimeout(timer); + timer = null; + }, + }; +} + +module.exports = { startClaudeQuotaKeepalive, planKeepalive }; diff --git a/server/lib/quota-schedules.js b/server/lib/quota-schedules.js index 2f0f84a..dbb8117 100644 --- a/server/lib/quota-schedules.js +++ b/server/lib/quota-schedules.js @@ -5,7 +5,9 @@ const { randomUUID } = require('crypto'); const { createJsonStore } = require('./json-store'); -const SCHEDULES_FILE = path.join(__dirname, '..', 'quota-schedules.json'); +const SCHEDULES_FILE = process.env.RELAY_QUOTA_SCHEDULES_FILE + ? path.resolve(process.env.RELAY_QUOTA_SCHEDULES_FILE) + : path.join(__dirname, '..', 'quota-schedules.json'); const MAX_PROMPT_LENGTH = 12000; const RESET_GRACE_MS = 10 * 60 * 1000; // Keep the file bounded: all live (pending/running) schedules are always kept, diff --git a/server/lib/stdio-agent-pool.js b/server/lib/stdio-agent-pool.js new file mode 100644 index 0000000..df7d9e5 --- /dev/null +++ b/server/lib/stdio-agent-pool.js @@ -0,0 +1,691 @@ +'use strict'; + +const { spawn } = require('child_process'); +const fs = require('fs'); +const os = require('os'); + +// Persistent agent sessions over a line-delimited JSON-RPC process. +// +// Relay used to run one CLI process per turn: the process died the moment the +// turn ended, so anything it started in the background died with it, and every +// turn paid the cold-start cost of booting the CLI again. +// +// opencode and hermes speak ACP (` acp`); codex speaks its own app-server +// protocol (`codex app-server`). Both are JSON-RPC 2.0 on stdio, and in both one +// process hosts *many* sessions (the working directory is chosen per session), +// so a pool keeps a single process per agent and multiplexes every chat through +// it. This pays each CLI's substantial startup cost once instead of once per +// chat. +// +// This module owns everything the two protocols share — the process, the wire, +// the session cap, idle eviction and cancellation — and takes a `driver` for the +// parts that differ. See acp-session-pool.js and codex-session-pool.js. +// +// The pool is a *cache*, never the source of truth. The session id in +// agent-sessions.json stays authoritative, so whenever the process dies or a +// session is evicted, the next turn re-opens it by resuming that id and behaves +// exactly like the old per-turn model. A dead pool is never worse than no pool. +const DEFAULT_IDLE_MS = 15 * 60 * 1000; +// Live sessions per agent. The cap bounds per-session resource growth while +// still allowing several conversations to run independently. +const DEFAULT_MAX_SESSIONS = 4; +// After asking the agent to cancel, how long to wait for it to wind the turn +// down before dropping the session. Cancel must never hang. +const CANCEL_GRACE_MS = 5000; +// Closing stdin is how these agents are asked to exit; the kill is the backstop. +const KILL_GRACE_MS = 2000; +const MAX_STDERR = 8192; + +function positiveInt(value, fallbackValue, min, max) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed >= min && parsed <= max + ? parsed + : fallbackValue; +} + +function cancelledError() { + const err = new Error('request cancelled'); + err.code = 'AGENT_CANCELLED'; + return err; +} + +function sessionLostError(agentKey, cause) { + const err = new Error((cause && cause.message) || `${agentKey} session ended`); + err.code = 'AGENT_SESSION_LOST'; + if (cause) err.cause = cause; + return err; +} + +function rpcError(error) { + const err = new Error((error && error.message) || 'request failed'); + err.code = 'AGENT_REQUEST_FAILED'; + err.data = error && error.data; + return err; +} + +function existingDir(dir) { + if (!dir) return null; + try { + return fs.statSync(dir).isDirectory() ? dir : null; + } catch (_err) { + return null; + } +} + +function createStdioAgentPool(options = {}) { + const agentKey = options.agentKey || 'agent'; + const env = options.env || process.env; + const now = options.now || (() => Date.now()); + // Explicit options win as given; only operator-supplied env values are + // clamped to a sane range. + const idleMs = + options.idleMs ?? + positiveInt( + env.RELAY_AGENT_IDLE_MS, + DEFAULT_IDLE_MS, + 10 * 1000, + 24 * 60 * 60 * 1000, + ); + const maxSessions = + options.maxSessions ?? + positiveInt(env.RELAY_AGENT_MAX_SESSIONS, DEFAULT_MAX_SESSIONS, 1, 64); + const turnTimeoutMs = options.turnTimeoutMs || 60 * 60 * 1000; + const cancelGraceMs = options.cancelGraceMs ?? CANCEL_GRACE_MS; + const killGraceMs = options.killGraceMs ?? KILL_GRACE_MS; + // Resolved lazily so an agent that isn't installed fails at turn time with a + // real message instead of at server boot. + const resolveCommand = options.command || (() => null); + const resolveDeleteCommand = options.deleteCommand || null; + const createDriver = options.driver; + + const live = new Map(); + const slotWaiters = []; + let conn = null; + let connPromise = null; + let shuttingDown = false; + // Callers between "released a session" and "about to open one" hold the + // process, so the swap does not look like the pool going idle. + let holds = 0; + + function releaseSlot() { + const waiter = slotWaiters.shift(); + if (waiter) waiter(); + } + + // A finished turn frees the session for eviction but not the slot: the + // session stays open. Group chats summon several members at once, so without + // this a caller waiting on the cap would never be woken by a turn ending — + // only by an unrelated eviction. + function pumpWaiters() { + if (!slotWaiters.length) return; + const victim = lruIdleEntry(); + if (victim) closeSession(victim).catch(() => {}); + } + + // Evict the least recently used session that is not mid-turn. + function lruIdleEntry() { + let victim = null; + for (const entry of live.values()) { + if (entry.turn || entry.closed) continue; + if (!victim || entry.lastActivity < victim.lastActivity) victim = entry; + } + return victim; + } + + async function acquireSlot() { + // The cap covers every live session the pool owns. When they are all + // mid-turn there is nothing safe to evict, so the caller waits for a slot + // rather than the pool quietly exceeding its own memory budget. + while (live.size >= maxSessions) { + const victim = lruIdleEntry(); + if (victim) { + await closeSession(victim); + continue; + } + await new Promise((resolve) => slotWaiters.push(resolve)); + } + } + + // ---------------------------------------------------------------- transport + + function writeFrame(c, frame) { + if (c.closed) return; + try { + c.child.stdin.write(`${JSON.stringify(frame)}\n`); + } catch (_err) { + // The exit handler tears the connection down. + } + } + + function makeRpc(c) { + return { + agentKey, + caps: c.caps, + request(method, params) { + if (c.closed) return Promise.reject(sessionLostError(agentKey)); + const id = c.nextId++; + return new Promise((resolve, reject) => { + c.pending.set(id, { resolve, reject }); + writeFrame(c, { jsonrpc: '2.0', id, method, params }); + }); + }, + notify(method, params) { + writeFrame(c, { jsonrpc: '2.0', method, params }); + }, + reply(id, result) { + writeFrame(c, { jsonrpc: '2.0', id, result }); + }, + replyError(id, code, message) { + writeFrame(c, { jsonrpc: '2.0', id, error: { code, message } }); + }, + // Drivers route inbound traffic by the agent's own session id. + sessionFor(sessionId) { + return c.sessions.get(sessionId) || null; + }, + }; + } + + function handleFrame(c, line) { + let msg; + try { + msg = JSON.parse(line); + } catch (_err) { + // Agents print the occasional banner or log line to stdout; anything that + // is not a protocol frame is not ours to interpret. + return; + } + if (msg.method === undefined && msg.id !== undefined) { + const pending = c.pending.get(msg.id); + if (!pending) return; + c.pending.delete(msg.id); + if (msg.error) pending.reject(rpcError(msg.error)); + else pending.resolve(msg.result); + return; + } + if (!msg.method) return; + try { + c.driver.handleMessage(msg); + } catch (_err) { + // A driver or rendering failure must not take the connection down. + } + } + + function killChild(c, immediate) { + try { + c.child.stdin.end(); + } catch (_err) { + // Already closed. + } + const hardKill = () => { + try { + // The agent spawns its own helpers; killing the group is what stops + // them too, so an evicted session never leaves orphans behind. + if (c.child.pid && process.platform !== 'win32') { + process.kill(-c.child.pid, 'SIGKILL'); + } else { + c.child.kill('SIGKILL'); + } + } catch (_err) { + try { + c.child.kill('SIGKILL'); + } catch (_err2) { + // Already gone. + } + } + }; + if (immediate) { + hardKill(); + return; + } + const timer = setTimeout(hardKill, killGraceMs); + if (typeof timer.unref === 'function') timer.unref(); + c.child.once('exit', () => clearTimeout(timer)); + } + + function dropConnection(c, err, immediate) { + if (c.closed) return; + c.closed = true; + if (conn === c) conn = null; + for (const pending of c.pending.values()) { + pending.reject(sessionLostError(agentKey, err)); + } + c.pending.clear(); + // Every session on this process went with it. The stored session id stays + // authoritative, so the next turn re-opens by resuming it. + for (const entry of [...c.sessions.values()]) dropEntry(entry, err); + c.sessions.clear(); + killChild(c, immediate); + } + + function openConnection() { + const command = resolveCommand(); + if (!command) { + return Promise.reject(new Error(`${agentKey} is not installed`)); + } + const child = spawn(command.cmd, command.args, { + // Every session carries its own cwd, so the process itself runs somewhere + // stable: a shared process must not die because one chat's work tree was + // renamed or deleted. + cwd: os.homedir(), + env: process.env, + stdio: ['pipe', 'pipe', 'pipe'], + // Group leader, so killChild can take the agent's helpers down with it. + detached: process.platform !== 'win32', + }); + const c = { + child, + pending: new Map(), + sessions: new Map(), + nextId: 1, + caps: {}, + closed: false, + stderr: '', + driver: null, + }; + c.driver = createDriver(makeRpc(c)); + + let buffer = ''; + // Decode as UTF-8 at the stream layer so a multi-byte character straddling + // a chunk boundary is buffered rather than split into replacement chars. + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + buffer += chunk; + let index; + while ((index = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, index).trim(); + buffer = buffer.slice(index + 1); + if (line) handleFrame(c, line); + } + }); + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk) => { + // Agents log freely to stderr; keep only the tail, for error messages. + c.stderr = `${c.stderr}${chunk}`.slice(-MAX_STDERR); + }); + child.on('error', (err) => dropConnection(c, err)); + child.on('exit', (code, signal) => + dropConnection( + c, + new Error( + `${agentKey} exited (${signal ? `signal ${signal}` : `code ${code}`})`, + ), + ), + ); + + return Promise.resolve() + .then(() => c.driver.initialize()) + .then( + () => c, + (err) => { + dropConnection(c, err); + throw err; + }, + ); + } + + function ensureConnection() { + if (conn && !conn.closed) return Promise.resolve(conn); + if (!connPromise) { + connPromise = openConnection().then( + (c) => { + connPromise = null; + conn = c; + return c; + }, + (err) => { + connPromise = null; + throw err; + }, + ); + } + return connPromise; + } + + // ------------------------------------------------------------------ sessions + + function clearIdleTimer(entry) { + if (entry.idleTimer) clearTimeout(entry.idleTimer); + entry.idleTimer = null; + } + + function scheduleIdleClose(entry) { + clearIdleTimer(entry); + if (entry.closed) return; + entry.idleTimer = setTimeout(() => { + if (entry.turn) return; + closeSession(entry).catch(() => {}); + }, idleMs); + if (typeof entry.idleTimer.unref === 'function') entry.idleTimer.unref(); + } + + function settleTurn(entry, settle) { + const turn = entry.turn; + if (!turn) return; + entry.turn = null; + clearTimeout(turn.timer); + if (turn.cancelTimer) clearTimeout(turn.cancelTimer); + if (turn.detachAbort) turn.detachAbort(); + settle(turn); + } + + // Forget a session without talking to the agent — used when the process is + // already gone, or after a graceful close. + function dropEntry(entry, err) { + if (entry.closed) return; + entry.closed = true; + clearIdleTimer(entry); + if (live.get(entry.key) === entry) live.delete(entry.key); + if (entry.sessionId && entry.conn) entry.conn.sessions.delete(entry.sessionId); + settleTurn(entry, (turn) => { + if (turn.cancelled) return turn.reject(cancelledError()); + const lost = sessionLostError(agentKey, err); + // Whether the user already saw part of this turn decides if it can be + // silently re-run. + lost.emitted = turn.emitted; + lost.stderr = (entry.conn && entry.conn.stderr) || ''; + return turn.reject(lost); + }); + releaseSlot(); + // The process exists only to host sessions; the last one leaving is what + // ends the idle life of the agent itself. + closeIdleConnection(); + } + + function closeIdleConnection() { + if (!conn || conn.closed) return; + if (conn.sessions.size > 0 || live.size > 0) return; + // A caller queued on the cap, or swapping one session for another, is about + // to use this process; tearing it down here would fail their turn with a + // lost session — and respawning the CLI is exactly what the pool exists to + // avoid. + if (connPromise || slotWaiters.length || holds > 0) return; + dropConnection(conn, null, shuttingDown); + } + + async function closeSession(entry) { + if (entry.closed) return; + const c = entry.conn; + if (c && !c.closed && entry.sessionId && c.driver.closeSession) { + try { + await c.driver.closeSession(entry); + } catch (_err) { + // Best effort: the session is being dropped either way. + } + } + dropEntry(entry, null); + } + + async function spawnEntry(req, retried) { + // Connect before taking a slot: nothing may await between acquireSlot() and + // registering the entry, or two concurrent spawns both pass the cap check. + const c = await ensureConnection(); + await acquireSlot(); + if (c.closed) { + // Waiting for a slot takes time, and the process can die (or be closed + // for going idle) in it. Reconnect rather than fail a turn that never + // started. + if (retried) throw sessionLostError(agentKey); + return spawnEntry(req, true); + } + const entry = { + key: req.key, + cwd: req.cwd, + conn: c, + sessionId: null, + fixedKey: req.fixedKey || null, + applied: {}, + turn: null, + idleTimer: null, + closed: false, + lastActivity: now(), + }; + live.set(entry.key, entry); + try { + const opened = await c.driver.openSession(req); + entry.sessionId = opened.sessionId; + // The caller had a stored id but the agent could not resume it, so this + // is a different conversation. Reported once, on the turn that finds it. + entry.startedNew = !!opened.startedNew; + c.sessions.set(entry.sessionId, entry); + } catch (err) { + dropEntry(entry, err); + throw err; + } + return entry; + } + + function runTurn(entry, req) { + return new Promise((resolve, reject) => { + const turn = { + onMessage: req.onMessage || (() => {}), + onPermission: req.onPermission || null, + emitted: false, + cancelled: false, + timer: null, + cancelTimer: null, + detachAbort: null, + resolve, + reject, + // The driver-facing pair: settle exactly once, whoever gets there first + // (the agent, the timeout, a cancel, or the process dying). + finish(result) { + settleTurn(entry, (settled) => { + if (settled.cancelled) settled.reject(cancelledError()); + else settled.resolve(result); + }); + }, + fail(err) { + settleTurn(entry, (settled) => { + if (settled.cancelled) return settled.reject(cancelledError()); + const lost = sessionLostError(agentKey, err); + lost.emitted = settled.emitted; + lost.stderr = entry.conn.stderr || ''; + return settled.reject(lost); + }); + }, + }; + entry.turn = turn; + entry.lastActivity = now(); + clearIdleTimer(entry); + + const stop = () => { + if (entry.turn !== turn) return; + turn.cancelled = true; + try { + if (entry.conn.driver.cancelTurn) { + entry.conn.driver.cancelTurn(entry, turn); + } + } catch (_err) { + // Fall through to the grace timer. + } + // Cancel is best-effort: if the agent does not wind the turn down + // promptly, drop the session so cancel is never a hang. + turn.cancelTimer = setTimeout(() => { + if (entry.turn === turn) dropEntry(entry, new Error('cancelled')); + }, cancelGraceMs); + if (typeof turn.cancelTimer.unref === 'function') { + turn.cancelTimer.unref(); + } + }; + + const signal = req.signal; + if (signal) { + // Opening the session took time, and the user may have cancelled in it. + // An already-aborted signal never fires `abort`, so it is checked here + // rather than only listened for. + if (signal.aborted) { + entry.turn = null; + reject(cancelledError()); + scheduleIdleClose(entry); + return; + } + const onAbort = () => stop(); + signal.addEventListener('abort', onAbort, { once: true }); + turn.detachAbort = () => signal.removeEventListener('abort', onAbort); + } + + turn.timer = setTimeout(() => { + settleTurn(entry, (settled) => + settled.resolve({ + stopReason: 'timeout', + message: `Timed out after ${Math.round( + turnTimeoutMs / 60000, + )} minutes and was stopped. Split the task or simplify the prompt.`, + }), + ); + // The session survives a cancel, so a timed-out turn costs the turn, + // not the conversation. + try { + if (entry.conn.driver.cancelTurn) { + entry.conn.driver.cancelTurn(entry, turn); + } + } catch (_err) { + // Nothing more to do; the turn is already settled. + } + }, turnTimeoutMs); + if (typeof turn.timer.unref === 'function') turn.timer.unref(); + + try { + entry.conn.driver.startTurn(entry, req, turn); + } catch (err) { + turn.fail(err); + } + }).then( + (result) => { + entry.lastActivity = now(); + scheduleIdleClose(entry); + pumpWaiters(); + const startedNew = entry.startedNew === true; + entry.startedNew = false; + return { + result, + sessionId: entry.sessionId, + startedNew, + stderr: entry.conn.stderr || '', + }; + }, + (err) => { + entry.lastActivity = now(); + if (!entry.closed) scheduleIdleClose(entry); + pumpWaiters(); + throw err; + }, + ); + } + + // Run one turn on `key`, reusing the live session when there is one. + async function send(req) { + // Checked before anything is opened: a turn cancelled before it started + // must not cost a session slot or a process spawn. + if (req.signal && req.signal.aborted) throw cancelledError(); + let entry = live.get(req.key); + // The live session knows the current id (a brand-new session gets one the + // caller has not stored yet), so prefer it over the caller's. + const resumeId = (entry && entry.sessionId) || req.resumeId || null; + // cwd, and any setting the agent fixes when a session is opened, need a + // fresh session — which still resumes the same conversation. + const stale = + !!entry && + (entry.closed || + entry.conn.closed || + entry.cwd !== req.cwd || + entry.fixedKey !== (req.fixedKey || null)); + const warm = !!entry && !stale; + if (stale) { + holds += 1; + try { + await closeSession(entry); + entry = await spawnEntry({ ...req, resumeId }); + } finally { + holds -= 1; + closeIdleConnection(); + } + } else if (!entry) { + entry = await spawnEntry({ ...req, resumeId }); + } + if (entry.conn.driver.applySettings) { + await entry.conn.driver.applySettings(entry, req); + } + try { + return await runTurn(entry, req); + } catch (err) { + const lost = err && err.code === 'AGENT_SESSION_LOST' && !err.emitted; + if (!warm || !lost) throw err; + // A warm session died before producing anything. Fall back to the cold + // path so a stale pooled session is never worse than no pool at all. + const fresh = await spawnEntry({ ...req, resumeId }); + if (fresh.conn.driver.applySettings) { + await fresh.conn.driver.applySettings(fresh, req); + } + return runTurn(fresh, req); + } + } + + function runDeleteCommand(sessionId, cwd) { + const command = resolveDeleteCommand && resolveDeleteCommand(sessionId); + if (!command) return Promise.resolve(false); + return new Promise((resolve) => { + const child = spawn(command.cmd, command.args, { + // Deleting a chat often means its work tree is gone too, and spawning + // into a missing cwd fails before the CLI ever runs. + cwd: existingDir(cwd) || os.homedir(), + env: process.env, + stdio: 'ignore', + }); + child.on('error', () => resolve(false)); + child.on('exit', (code) => resolve(code === 0)); + }); + } + + // Drop the live session for a scope. With `purge`, also make a best-effort + // request to delete the agent's stored transcript. + async function forget(key, opts = {}) { + const entry = live.get(key); + const sessionId = opts.sessionId || (entry && entry.sessionId) || null; + const cwd = opts.cwd || (entry && entry.cwd) || undefined; + if (entry) await closeSession(entry); + if (!opts.purge || !sessionId) return false; + // Some protocols can delete in-band; the rest shell out to their CLI. + if (conn && !conn.closed && conn.driver.deleteSession) { + try { + return await conn.driver.deleteSession(sessionId); + } catch (_err) { + return false; + } + } + if (resolveDeleteCommand) return runDeleteCommand(sessionId, cwd); + // No live process and nothing to shell out to: open one just to delete. + try { + const c = await ensureConnection(); + const deleted = c.driver.deleteSession + ? await c.driver.deleteSession(sessionId) + : false; + closeIdleConnection(); + return deleted; + } catch (_err) { + return false; + } + } + + async function shutdown() { + shuttingDown = true; + for (const entry of [...live.values()]) dropEntry(entry, null); + live.clear(); + if (conn) dropConnection(conn, null, true); + } + + function stats() { + return { + live: live.size, + maxSessions, + idleMs, + waiting: slotWaiters.length, + connected: !!(conn && !conn.closed), + keys: [...live.keys()], + }; + } + + return { send, forget, shutdown, stats }; +} + +module.exports = { createStdioAgentPool }; diff --git a/server/lib/subscription-store.js b/server/lib/subscription-store.js index 909f667..5fa5ef1 100644 --- a/server/lib/subscription-store.js +++ b/server/lib/subscription-store.js @@ -93,6 +93,7 @@ function createSubscriptionStore({ filePath, key, send, isGone }) { messageZh, scopeWorkdir, category, + tag, }) { const list = loadRecords(); if (list.length === 0) return 0; @@ -111,7 +112,13 @@ function createSubscriptionStore({ filePath, key, send, isGone }) { const body = pickLang(record, message, messageZh, ''); const notificationTitle = pickLang(record, title, titleZh, 'Relay'); try { - await send(record, { title: notificationTitle, body, tag: 'relay' }); + // The tag lets a client collapse this alert with its own copy of the + // same event instead of showing both. + await send(record, { + title: notificationTitle, + body, + tag: tag || 'relay', + }); sent += 1; } catch (err) { if (isGone(err)) gone.push(idOf(record)); diff --git a/server/lib/tokens.js b/server/lib/tokens.js index 8221151..9af7221 100644 --- a/server/lib/tokens.js +++ b/server/lib/tokens.js @@ -5,7 +5,9 @@ const path = require('path'); const { createJsonStore } = require('./json-store'); -const TOKENS_FILE = path.join(__dirname, '..', 'tokens.json'); +const TOKENS_FILE = process.env.RELAY_TOKENS_FILE + ? path.resolve(process.env.RELAY_TOKENS_FILE) + : path.join(__dirname, '..', 'tokens.json'); // Cached, atomic store. The `npm run credential` script writes tokens.json from // a separate process; its write changes the file stamp, so this server's cached @@ -197,6 +199,7 @@ function deleteRevokedTokenById(id) { } module.exports = { + TOKENS_FILE, createToken, deleteRevokedTokenById, hasConfiguredToken, diff --git a/server/lib/usage.js b/server/lib/usage.js index c8abe66..d15db61 100644 --- a/server/lib/usage.js +++ b/server/lib/usage.js @@ -3,33 +3,30 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); -const http = require('http'); const https = require('https'); -const { spawn } = require('child_process'); - -const { AGY_DIR, configuredAgyModel } = require('./agy-paths'); const CLAUDE_CREDS_PATH = path.join(os.homedir(), '.claude', '.credentials.json'); const CLAUDE_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'; const CLAUDE_TOKEN_URL = 'https://platform.claude.com/v1/oauth/token'; +const CLAUDE_MESSAGES_URL = 'https://api.anthropic.com/v1/messages'; const CLAUDE_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'; const CLAUDE_OAUTH_BETA = 'oauth-2025-04-20'; +const CLAUDE_API_VERSION = '2023-06-01'; +// The keepalive ping is billed like any other Claude Code turn, so it uses a +// low-cost default model and the smallest possible completion. Deployments can +// override the model explicitly. +const CLAUDE_KEEPALIVE_MODEL = + process.env.CLAUDE_KEEPALIVE_MODEL || 'claude-haiku-4-5'; +const CLAUDE_CODE_SYSTEM_PROMPT = + "You are Claude Code, Anthropic's official CLI for Claude."; const CODEX_AUTH = path.join(os.homedir(), '.codex', 'auth.json'); const CODEX_CONFIG = path.join(os.homedir(), '.codex', 'config.toml'); const CODEX_URL = 'https://chatgpt.com/backend-api/codex/responses'; const CODEX_TOKEN_URL = 'https://auth.openai.com/oauth/token'; const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'; -const AGY_LOG_DIR = path.join(AGY_DIR, 'log'); -const AGY_QUOTA_RPC_PATH = - '/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary'; -const AGY_PROBE_TIMEOUT_MS = parseInt( - process.env.AGY_QUOTA_PROBE_TIMEOUT_MS || '12000', - 10, -); const CODEX_CACHE_MS = 60_000; const CLAUDE_CACHE_MS = 60_000; -const AGY_CACHE_MS = 60_000; const USAGE_CACHE_FILE = path.join(__dirname, '..', 'usage-cache.json'); const USAGE_BACKOFF_BASE_MS = parseInt( process.env.USAGE_BACKOFF_BASE_MS || '30000', @@ -59,12 +56,10 @@ function attachTimeout(req, url) { let codexCache = { at: 0, fetchedAt: '', value: null, stale: false }; let claudeCache = { at: 0, fetchedAt: '', value: null, stale: false }; -let agyCache = { at: 0, fetchedAt: '', value: null, stale: false }; let persistedUsageCache = null; const usageBackoff = { claude: { until: 0, delayMs: 0, refreshPromise: null, credMtime: 0 }, codex: { until: 0, delayMs: 0, refreshPromise: null, credMtime: 0 }, - agy: { until: 0, delayMs: 0, refreshPromise: null, credMtime: 0 }, }; // Claude/Codex quota credentials are shared with the live CLIs, which rotate @@ -259,9 +254,7 @@ function httpJson(method, url, headers, body) { opts.headers['Content-Type'] = 'application/json'; opts.headers['Content-Length'] = Buffer.byteLength(data); } - const parsedUrl = new URL(url); - const client = parsedUrl.protocol === 'http:' ? http : https; - const req = client.request(parsedUrl, opts, (res) => { + const req = https.request(url, opts, (res) => { let buffer = ''; res.on('data', (chunk) => { buffer += chunk; @@ -362,6 +355,56 @@ async function getClaudeUsage() { }); } +// Claude's five-hour window only exists while it is running: once it lapses the +// usage API reports `resets_at: null` until the next real turn, which the app can +// only show as "unknown". Codex avoids that because its quota probe *is* a live +// request; Claude's is a plain read, so we send the equivalent minimal turn +// ourselves to restart the window. One token on the configured keepalive model, +// using the same OAuth credential and Claude Code identity as the usage query. +async function callClaudeMessages(token) { + return httpJson('POST', CLAUDE_MESSAGES_URL, { + Authorization: `Bearer ${token}`, + 'anthropic-beta': CLAUDE_OAUTH_BETA, + 'anthropic-version': CLAUDE_API_VERSION, + 'User-Agent': 'claude-cli', + Accept: 'application/json', + }, { + model: CLAUDE_KEEPALIVE_MODEL, + max_tokens: 1, + system: [{ type: 'text', text: CLAUDE_CODE_SYSTEM_PROMPT }], + messages: [{ role: 'user', content: 'hi' }], + }); +} + +async function primeClaudeSession() { + let token = await getValidClaudeToken(); + let res = await callClaudeMessages(token); + if (res.status === 401) { + token = await refreshClaudeToken(); + res = await callClaudeMessages(token); + } + // 429 means the quota is already exhausted, which is itself a running window: + // the ping did its job and the caller should not treat it as a failure. + if (res.status !== 200 && res.status !== 429) { + const detail = + (res.body && res.body.error && res.body.error.message) || res.raw || ''; + throw new UsageQueryError( + `Claude keepalive request failed (HTTP ${res.status}). ${detail}`.trim(), + res.status, + ); + } + return { status: res.status, model: CLAUDE_KEEPALIVE_MODEL }; +} + +// Drop the in-memory TTL for one source so the next read re-queries the API. +// Used after the keepalive ping so the fresh `resets_at` is picked up at once +// instead of after the normal cache window. +function invalidateUsageCache(key) { + const caches = { claude: () => claudeCache, codex: () => codexCache }; + const cache = caches[key] && caches[key](); + if (cache) cache.at = 0; +} + function httpHeadersOnly(url, headers, bodyStr) { return new Promise((resolve, reject) => { const opts = { method: 'POST', headers: { ...headers } }; @@ -522,310 +565,6 @@ async function getCodexUsage() { }); } -// agy's quota RPC speaks proto3 JSON (Connect with Accept: application/json), -// where a Timestamp is an RFC3339 string. A numeric epoch is accepted too as a -// defensive fallback; anything else is treated as "unknown" (null). -function parseTimestamp(value) { - if (value == null || value === '') return null; - const epoch = - typeof value === 'number' - ? value - : typeof value === 'string' && /^\d+(?:\.\d+)?$/.test(value.trim()) - ? Number(value) - : null; - if (epoch != null && Number.isFinite(epoch)) { - return new Date(epoch > 10_000_000_000 ? epoch : epoch * 1000).toISOString(); - } - const date = new Date(value); - return Number.isNaN(date.getTime()) ? null : date.toISOString(); -} - -function recentAgyLogFiles() { - try { - return fs - .readdirSync(AGY_LOG_DIR) - .filter((name) => /^cli-.*\.log$/.test(name)) - .map((name) => { - const file = path.join(AGY_LOG_DIR, name); - return { file, mtimeMs: fs.statSync(file).mtimeMs }; - }) - .sort((a, b) => b.mtimeMs - a.mtimeMs) - .slice(0, 12) - .map((entry) => entry.file); - } catch (_err) { - return []; - } -} - -function agyHttpPortsFromText(raw) { - const ports = []; - for (const match of String(raw || '').matchAll( - /Language server listening on random port at (\d+) for HTTP/g, - )) { - const port = Number(match[1]); - if (Number.isInteger(port) && port > 0) ports.push(port); - } - return ports; -} - -async function callAgyQuotaPort(port) { - const res = await httpJson( - 'POST', - `http://127.0.0.1:${port}${AGY_QUOTA_RPC_PATH}`, - { Accept: 'application/json' }, - {}, - ); - if (res.status === 200 && res.body && res.body.response) return res.body; - const message = - (res.body && (res.body.message || (res.body.error && res.body.error.message))) || - `HTTP ${res.status}`; - throw new UsageQueryError(message, res.status); -} - -async function callRecentAgyQuotaSummary() { - // Walk logs newest-first and try each port as it is discovered, returning on - // the first live one. The current run's port is almost always in the newest - // file, so we rarely read more than one log. - const tried = new Set(); - for (const file of recentAgyLogFiles()) { - let raw = ''; - try { - raw = fs.readFileSync(file, 'utf-8'); - } catch (_err) { - continue; - } - for (const port of agyHttpPortsFromText(raw)) { - if (tried.has(port)) continue; - tried.add(port); - try { - return await callAgyQuotaPort(port); - } catch (_err) { - // Stale log port or not-yet-authenticated instance; try the next one. - } - } - } - throw new UsageQueryError( - 'Antigravity quota API is not reachable. Start `agy` once, then retry.', - 503, - ); -} - -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function shellQuote(value) { - return `'${String(value).replace(/'/g, `'\\''`)}'`; -} - -async function stopAgyProbe(child) { - if (!child || !child.pid) return; - try { - process.kill(-child.pid, 'SIGTERM'); - } catch (_err) { - try { - child.kill('SIGTERM'); - } catch (_ignored) { - // Already gone. - } - } - await sleep(250); - try { - process.kill(-child.pid, 'SIGKILL'); - } catch (_err) { - // Already gone. - } -} - -async function callAgyQuotaSummaryViaProbe() { - // The probe forces a PTY with `script -qfec` (util-linux flag syntax) so agy - // writes its startup log even when not attached to a terminal. That syntax is - // Linux-only; on other platforms the recent-port path still works, so fail - // with a clear message instead of a cryptic spawn error. - if (process.platform !== 'linux') { - throw new UsageQueryError( - 'Antigravity quota probe is only supported on Linux. Start `agy` once so a recent port is logged, then retry.', - 503, - ); - } - const logPath = path.join( - os.tmpdir(), - `relay-agy-quota-${process.pid}-${Date.now()}.log`, - ); - const child = spawn( - 'script', - ['-qfec', `agy --log-file ${shellQuote(logPath)}`, '/dev/null'], - { detached: true, stdio: 'ignore' }, - ); - let spawnError = null; - child.once('error', (err) => { - spawnError = err; - }); - - const startedAt = Date.now(); - try { - while (Date.now() - startedAt < AGY_PROBE_TIMEOUT_MS) { - if (spawnError) throw spawnError; - await sleep(250); - let raw = ''; - try { - raw = fs.readFileSync(logPath, 'utf-8'); - } catch (_err) { - continue; - } - const ports = agyHttpPortsFromText(raw).reverse(); - for (const port of ports) { - try { - return await callAgyQuotaPort(port); - } catch (_err) { - // The server starts before auth/model caches are ready; keep polling. - } - } - } - } finally { - await stopAgyProbe(child); - try { - fs.unlinkSync(logPath); - } catch (_err) { - // Best effort cleanup. - } - } - throw new UsageQueryError( - 'Antigravity quota API did not become ready in time. Run `agy models` or `agy` once, then retry.', - 503, - ); -} - -async function callAgyQuotaSummary() { - try { - return await callRecentAgyQuotaSummary(); - } catch (_err) { - return callAgyQuotaSummaryViaProbe(); - } -} - -function agyQuotaGroupKind(modelLabel) { - return /claude|gpt/i.test(modelLabel || '') ? 'third_party' : 'gemini'; -} - -function agyGroupText(group) { - return [ - group.displayName, - group.description, - ...(Array.isArray(group.buckets) - ? group.buckets.map((bucket) => bucket.bucketId || bucket.displayName || '') - : []), - ] - .join(' ') - .toLowerCase(); -} - -function selectAgyQuotaGroup(groups, modelLabel) { - const preferredKind = agyQuotaGroupKind(modelLabel); - const matches = (group) => { - const text = agyGroupText(group); - if (preferredKind === 'third_party') { - return /claude|gpt|\b3p\b|third/.test(text); - } - return /gemini/.test(text); - }; - return groups.find(matches) || groups[0] || null; -} - -function findAgyBucket(group, kind) { - const buckets = Array.isArray(group && group.buckets) ? group.buckets : []; - const matches = (bucket) => { - const text = [ - bucket.bucketId, - bucket.displayName, - bucket.window, - ] - .join(' ') - .toLowerCase(); - return kind === 'five_hour' - ? /five|5h|5.hour/.test(text) - : /week|weekly|7/.test(text); - }; - return buckets.find(matches) || null; -} - -function agyBucketQuota(bucket) { - if (!bucket) return null; - const remainingFraction = Number(bucket.remainingFraction); - if (!Number.isFinite(remainingFraction)) return null; - return { - utilization: clampPercent((1 - remainingFraction) * 100), - resets_at: parseTimestamp(bucket.resetTime), - }; -} - -function compactAgyPlanLabel(value) { - if (value == null || typeof value === 'object') return ''; - const text = String(value).trim(); - if (!text) return ''; - const tier = /\b(pro|max|ultra|free|plus|business|enterprise|teams?)\b/i.exec( - text, - ); - if (tier) { - return tier[1].charAt(0).toUpperCase() + tier[1].slice(1).toLowerCase(); - } - return text.length <= 24 ? text : ''; -} - -function agyPlanLabel(response, group) { - for (const source of [response, group]) { - for (const field of [ - 'subscriptionType', - 'subscriptionTier', - 'subscriptionLevel', - 'planType', - 'planTier', - 'plan', - 'tier', - 'accountTier', - ]) { - const label = compactAgyPlanLabel(source && source[field]); - if (label) return label; - } - } - return compactAgyPlanLabel(group && group.displayName) || ''; -} - -function normalizeAgyQuotaSummary(body, modelLabel = configuredAgyModel()) { - const response = body && body.response ? body.response : body; - const groups = Array.isArray(response && response.groups) ? response.groups : []; - const group = selectAgyQuotaGroup(groups, modelLabel); - if (!group) { - throw new Error('Antigravity quota summary did not include quota groups.'); - } - const fiveHour = agyBucketQuota(findAgyBucket(group, 'five_hour')); - const sevenDay = agyBucketQuota(findAgyBucket(group, 'seven_day')); - if (!fiveHour && !sevenDay) { - throw new Error('Antigravity quota summary did not include quota buckets.'); - } - return { - plan: agyPlanLabel(response, group), - five_hour: fiveHour, - seven_day: sevenDay, - }; -} - -async function fetchAgyUsage() { - return normalizeAgyQuotaSummary(await callAgyQuotaSummary()); -} - -async function getAgyUsage() { - return cachedUsage({ - key: 'agy', - ttlMs: AGY_CACHE_MS, - getMemoryCache: () => agyCache, - setMemoryCache: (cache) => { - agyCache = cache; - }, - fetcher: fetchAgyUsage, - }); -} - function clampPercent(value) { if (value == null || !Number.isFinite(Number(value))) return null; return Math.max(0, Math.min(100, Number(value))); @@ -844,9 +583,8 @@ function quotaItem(key, label, block) { } // A stale cached quota whose reset time has already passed no longer reflects -// reality: the rolling window refreshed while the source was unreachable (most -// often a stopped `agy`, whose quota is only readable from a running instance), -// so the cached utilization is meaningless — the real remaining is back near +// reality: the rolling window refreshed while the source was unreachable, so +// the cached utilization is meaningless — the real remaining is back near // full. Flag those buckets `expired` so the client shows "window reset, awaiting // refresh" instead of a misleading old percentage. Fresh (non-stale) data is // never touched, even if its reset moment just passed, because it was just read. @@ -890,18 +628,6 @@ const USAGE_SOURCES = [ sevenDay: r.seven_day, }), }, - { - key: 'agy', - label: 'Antigravity', - fetch: getAgyUsage, - normalize: (r) => ({ - detail: r.plan || '', - asOf: r.fetchedAt || null, - stale: !!r.stale, - fiveHour: r.five_hour, - sevenDay: r.seven_day, - }), - }, ]; async function buildAgentUsage({ key, label, fetch, normalize, unavailable }) { @@ -946,8 +672,8 @@ async function buildUsageReport() { module.exports = { getClaudeUsage, getCodexUsage, - getAgyUsage, - normalizeAgyQuotaSummary, + invalidateUsageCache, markExpiredQuotas, + primeClaudeSession, buildUsageReport, }; diff --git a/server/package-lock.json b/server/package-lock.json index 26724e2..40d0eff 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,13 +1,14 @@ { "name": "relay-server", - "version": "0.1.4", + "version": "0.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "relay-server", - "version": "0.1.4", + "version": "0.1.5", "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.3.233", "compression": "^1.8.1", "dotenv": "^16.4.7", "express": "^4.21.2", @@ -23,6 +24,178 @@ "node": ">=18" } }, + "node_modules/@anthropic-ai/claude-agent-sdk": { + "version": "0.3.233", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.233.tgz", + "integrity": "sha512-Dy+YqhggwtbezDy3Ap2pb1sK3bOqnI+sLNnsVjB3AUWvR0QlGnjjrjORXY03Y50I+B1eFRNEcYPAZKRYlCkSLQ==", + "license": "SEE LICENSE IN README.md", + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.233", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.233", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.233", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.233", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.233", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.233", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.233", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.233" + }, + "peerDependencies": { + "@anthropic-ai/sdk": ">=0.93.0", + "@modelcontextprotocol/sdk": "^1.29.0", + "zod": "^4.0.0" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { + "version": "0.3.233", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.233.tgz", + "integrity": "sha512-4WDiBZgcrmvTDJjS8RNZwoxGgMz/0EpOM+sYa6EtyjwHTd6It1H/+k5zBckCmBajbgS5/ASCJqdwZzi7dwBl0Q==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { + "version": "0.3.233", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.233.tgz", + "integrity": "sha512-RaaEfNrbqSh77H5NdVF9cJQ0xhAUO92aOv71LSKSdAYModMeUvJN0k22Q7gvmx0TlmqJ+aVyCG8J8gVfgSL9mg==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { + "version": "0.3.233", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.233.tgz", + "integrity": "sha512-Az9HjQthYQqRjJCacBtDIAHX3TRGK9WlACNb/UOGAK3JndNzZMprM2mK/t6YmP2cRLJsGyorxL7HZmR9R9HYaw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { + "version": "0.3.233", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.233.tgz", + "integrity": "sha512-Z3uZdzt6xgJ3f4NIgO6lzBYSELULKSq6AL4OsNLBzuaEpVW0iYs1kUCaD9rcMlMrf3cV+Dk/GA/lTCGMgbucjQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { + "version": "0.3.233", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.233.tgz", + "integrity": "sha512-jpbhV+n9PnxLiyheQ/HjtHIg/E5/jVsk2Vdu132BSoL/3bsObSmMqKgsqoMutzwRZvtpqRs2RPVcjsC8G4A9Zw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { + "version": "0.3.233", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.233.tgz", + "integrity": "sha512-kYBIAQCu2f1YITcGbpUN2jfrkAzs59TVAragAhE2z+GrkIcxcpZwmaRY6heMBtaSY8SuyrwgqbCW9hJALYFnEg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { + "version": "0.3.233", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.233.tgz", + "integrity": "sha512-aO2MaNdmQofyPLKszE4s+Ope/sLJPeI/ZlGdCcjYp7qhji2hgZ4bRWWsOrx5eKjz0gFK5CFFltILkFcNcxCsVg==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { + "version": "0.3.233", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.233.tgz", + "integrity": "sha512-TcAYyWPXS5mREZGUksuCZsLIRQjbo/Vriur2PqIhAmgZ1oiqBZO27a90sX60EUczD7yV8wpwOVhVLhUxO0kAEg==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.117.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.117.1.tgz", + "integrity": "sha512-Yn2QlXfyCiKJ5YGCOOay7ZE78ISvII2XY621WMCiflmG8IYgwx59IBwPExxki3Xk9jKUtnD/Sj6UvplWr0rZxg==", + "license": "MIT", + "peer": true, + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@fastify/busboy": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", @@ -479,6 +652,19 @@ "node": ">=12" } }, + "node_modules/@hono/node-server": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@js-sdsl/ordered-map": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", @@ -490,6 +676,412 @@ "url": "https://opencollective.com/js-sdsl" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "peer": true, + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "peer": true, + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/jose": { + "version": "6.2.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.9.tgz", + "integrity": "sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "peer": true + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "peer": true, + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "peer": true, + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "peer": true, + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/@nodable/entities": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.1.tgz", @@ -586,6 +1178,13 @@ "license": "BSD-3-Clause", "optional": true }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT", + "peer": true + }, "node_modules/@tootallnate/once": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", @@ -690,6 +1289,41 @@ "node": ">= 14" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -988,6 +1622,39 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "peer": true, + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "peer": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -1210,6 +1877,29 @@ "node": ">=6" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "peer": true, + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/express": { "version": "4.22.2", "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", @@ -1295,6 +1985,30 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense", + "peer": true + }, + "node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "peer": true + }, "node_modules/fast-xml-builder": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", @@ -1818,6 +2532,16 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.13.2", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.2.tgz", + "integrity": "sha512-JydRilDRkYBQMt9qR9U92mXxmbGqsqSn/IKOrh4e7/gEbn+0zSr8igTu0obwJoNGN4sez28DIql7FBHWydoJpA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/html-entities": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", @@ -2004,6 +2728,13 @@ "node": ">=8" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT", + "peer": true + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -2017,6 +2748,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC", + "peer": true + }, "node_modules/jose": { "version": "4.15.9", "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", @@ -2035,6 +2773,34 @@ "bignumber.js": "^9.0.0" } }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT", + "peer": true + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause", + "peer": true + }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -2380,6 +3146,16 @@ "node-addon-api": "^7.1.0" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-hash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", @@ -2428,7 +3204,6 @@ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "license": "ISC", - "optional": true, "dependencies": { "wrappy": "1" } @@ -2503,12 +3278,32 @@ "node": ">=14.0.0" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/path-to-regexp": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pngjs": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", @@ -2657,6 +3452,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-main-filename": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", @@ -2688,6 +3493,59 @@ "node": ">=14" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/router/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "peer": true + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -2783,6 +3641,29 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "peer": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -2855,6 +3736,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -3037,6 +3929,13 @@ "license": "MIT", "optional": true }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT", + "peer": true + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -3176,6 +4075,22 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "peer": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/which-module": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", @@ -3200,8 +4115,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/ws": { "version": "8.21.0", @@ -3299,6 +4213,26 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peer": true, + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/server/package.json b/server/package.json index bca0f73..8494a86 100644 --- a/server/package.json +++ b/server/package.json @@ -1,16 +1,17 @@ { "name": "relay-server", - "version": "0.1.4", + "version": "0.1.5", "description": "Local HTTP backend for the Relay Flutter client.", "private": true, "main": "server.js", "scripts": { "start": "node server.js", "dev": "node server.js", - "test": "node --test", + "test": "node scripts/run-tests.js", "credential": "node scripts/create-credential.js" }, "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.3.233", "compression": "^1.8.1", "dotenv": "^16.4.7", "express": "^4.21.2", diff --git a/server/routes/agent-auth.js b/server/routes/agent-auth.js deleted file mode 100644 index b16abcd..0000000 --- a/server/routes/agent-auth.js +++ /dev/null @@ -1,86 +0,0 @@ -'use strict'; - -const express = require('express'); - -const { - clearAgentStatusCache, - getAgentStatuses, -} = require('../lib/agent-status'); -const { createAgentLoginManager } = require('../lib/agent-login'); - -function writeStreamEvent(res, type, payload) { - if (res.destroyed || res.writableEnded) return; - res.write(`event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`); -} - -function sendError(res, status, err) { - return res.status(status).json({ - error: err.message || 'request failed', - code: err.code || 'AGENT_AUTH_ERROR', - }); -} - -module.exports = function createAgentAuthRouter(ctx = {}) { - const router = express.Router(); - const getAgent = ctx.getAgent || (() => null); - const loginManager = ctx.loginManager || createAgentLoginManager(); - - router.get('/api/agent-auth/login/start', (req, res) => { - const agentKey = String(req.query.agent || '').trim(); - const agent = getAgent(agentKey); - if (!agent) return res.status(400).json({ error: 'agent is required' }); - let session; - try { - session = loginManager.start(agent.key); - } catch (err) { - const status = err.code === 'CLI_NOT_INSTALLED' ? 404 : 400; - return sendError(res, status, err); - } - - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache, no-transform', - Connection: 'keep-alive', - 'X-Accel-Buffering': 'no', - }); - let unsubscribe = () => {}; - let terminalEventReplayed = false; - unsubscribe = loginManager.subscribe(session.id, (event) => { - writeStreamEvent(res, event.type, event.data); - if (event.type === 'login_done' || event.type === 'login_error') { - clearAgentStatusCache(); - if (!res.writableEnded) res.end(); - terminalEventReplayed = true; - unsubscribe(); - } - }); - if (terminalEventReplayed) unsubscribe(); - req.on('close', () => unsubscribe()); - return undefined; - }); - - router.post('/api/agent-auth/login/code', (req, res) => { - const body = req.body || {}; - try { - loginManager.submitCode(body.sessionId, body.code); - return res.json({ ok: true }); - } catch (err) { - return sendError(res, err.code === 'LOGIN_SESSION_NOT_FOUND' ? 404 : 409, err); - } - }); - - router.get('/api/agent-auth/login/status', (req, res) => { - const sessionId = String(req.query.sessionId || '').trim(); - const session = sessionId ? loginManager.status(sessionId) : null; - if (!session) { - return res.status(404).json({ - error: 'login session not found', - code: 'LOGIN_SESSION_NOT_FOUND', - }); - } - const agentStatus = getAgentStatuses()[session.agent] || null; - return res.json({ ok: true, session, agentStatus }); - }); - - return router; -}; diff --git a/server/routes/btw.js b/server/routes/btw.js deleted file mode 100644 index d682fe5..0000000 --- a/server/routes/btw.js +++ /dev/null @@ -1,195 +0,0 @@ -'use strict'; - -const express = require('express'); - -// The /btw sidekick. A side question that inherits the main conversation's -// memory but never touches the main task. Each supported CLI forks or clones -// its own native session storage into a dedicated `btw:` scope so the -// side chat never writes back to the main conversation. -const BTW_SUPPORTED = new Set(['claude', 'codex', 'agy']); - -function btwScopeAgent(agentKey) { - return `btw:${agentKey}`; -} - -module.exports = function createBtwRouter(ctx) { - const { - MAX_PROMPT_BYTES, - activeRequests, - agentTurnDependencies, - clearHistory, - clearSession, - createChatResponder, - finalizeStaleStreamingHistory, - normalizeDeviceId, - randomUUID, - readHistory, - resolveAgentScope, - runAgentTurn, - runBtwAgent, - runningScopes, - scopeChains, - scopeKeyFor, - sessionPayload, - } = ctx; - const router = express.Router(); - - // Resolve the main conversation scope plus the derived side-chat scope. - function resolveBtwScope(req, res, { agentKey, sessionId }) { - if (!BTW_SUPPORTED.has(agentKey)) { - res.status(400).json({ - error: `btw is not available for ${agentKey || 'this agent'}`, - code: 'BTW_UNSUPPORTED', - }); - return null; - } - const scope = resolveAgentScope(req, res, { - agentKey, - sessionId, - agentError: (key) => ({ - status: 400, - body: { error: `unknown agent: ${key}` }, - }), - }); - if (!scope) return null; - // The side chat gets its own scope for history + its own fork/session. - const btwScopeKey = scopeKeyFor( - btwScopeAgent(agentKey), - scope.workdir, - scope.session.id, - ); - return { ...scope, mainSessionKey: scope.scopeKey, btwScopeKey }; - } - - router.post('/api/btw', async (req, res) => { - const agentKey = String(req.body.agent || 'claude').trim(); - const requestId = String(req.body.requestId || randomUUID()).trim(); - const prompt = String(req.body.prompt || '').trim(); - const requestedSessionId = String(req.body.sessionId || '').trim(); - const deviceId = normalizeDeviceId(req.get('x-device-id')); - if (!prompt) { - return res.status(400).json({ error: 'prompt is required' }); - } - if (Buffer.byteLength(prompt, 'utf8') > MAX_PROMPT_BYTES) { - return res.status(413).json({ - error: 'prompt exceeds the size limit', - code: 'PROMPT_TOO_LARGE', - }); - } - if (activeRequests.has(requestId)) { - return res - .status(409) - .json({ error: 'request already running', code: 'REQUEST_BUSY' }); - } - const scope = resolveBtwScope(req, res, { - agentKey, - sessionId: requestedSessionId, - }); - if (!scope) return; - const { agent, workdir, contextKey, session, mainSessionKey, btwScopeKey } = - scope; - - const abortController = new AbortController(); - const runState = { - requestId, - agent, - session, - deviceId, - scopeKey: btwScopeKey, - scopeWorkdir: workdir, - recordHistory: true, - historyAssistantId: `${requestId}:assistant`, - cancelled: false, - cancelEventSent: false, - abortController, - }; - activeRequests.set(requestId, runState); - const responder = createChatResponder({ req, res }); - - // Reuse the standard turn pipeline (SSE streaming, segmented history, - // cancellation) but swap the runner for the forking sidekick. The side chat - // is delivered only on this request's SSE stream, never on the shared scope - // stream — otherwise the main chat on this (or another) device would mistake - // the sidekick's events for activity on the main conversation and start - // mirroring it. - const baseDependencies = agentTurnDependencies(); - const dependencies = { - ...baseDependencies, - broadcastScope: () => {}, - runAgent: (_agentKey, p, onEvent, opts) => - runBtwAgent(agentKey, p, onEvent, { - mainSessionKey, - btwSessionKey: opts.sessionKey, - signal: opts.signal, - workdir: opts.workdir, - settings: opts.settings, - }), - }; - - try { - await runAgentTurn({ - agent, - agentKey, - contextKey, - dependencies, - deviceId, - prompt, - recordHistory: true, - requestId, - responder, - runState, - scopeKey: btwScopeKey, - session, - signal: abortController.signal, - workdir, - }); - } finally { - activeRequests.delete(requestId); - } - }); - - // The side conversation for the current main session. - router.get('/api/btw/history', (req, res) => { - const agentKey = String(req.query.agent || 'claude').trim(); - const requestedSessionId = String(req.query.sessionId || '').trim(); - const scope = resolveBtwScope(req, res, { - agentKey, - sessionId: requestedSessionId, - }); - if (!scope) return; - const { session, btwScopeKey } = scope; - if (!runningScopes.has(btwScopeKey) && !scopeChains.has(btwScopeKey)) { - finalizeStaleStreamingHistory(btwScopeKey); - } - return res.json({ - agent: agentKey, - session: sessionPayload(session), - messages: readHistory(btwScopeKey), - }); - }); - - // Reset the side chat: drop its history and forked session so the next - // question forks the main conversation afresh. Resolve the scope the same way - // as /api/btw and /api/btw/history (off the canonical session id) so we always - // clear the exact key those wrote to. - router.post('/api/btw/clear', (req, res) => { - const agentKey = String(req.body.agent || 'claude').trim(); - const requestedSessionId = String(req.body.sessionId || '').trim(); - const scope = resolveBtwScope(req, res, { - agentKey, - sessionId: requestedSessionId, - }); - if (!scope) return; - const { btwScopeKey } = scope; - if (runningScopes.has(btwScopeKey)) { - return res - .status(409) - .json({ error: 'a side question is running', code: 'SESSION_BUSY' }); - } - clearSession(btwScopeKey); - clearHistory(btwScopeKey); - return res.json({ ok: true }); - }); - - return router; -}; diff --git a/server/routes/chat.js b/server/routes/chat.js index 1c9af65..af0f028 100644 --- a/server/routes/chat.js +++ b/server/routes/chat.js @@ -11,7 +11,7 @@ module.exports = function createChatRouter(ctx) { agentTurnDependencies, broadcastScope, clearHistory, - clearSession, + purgeSession, createChatResponder, finalizeStaleStreamingHistory, getAgent, @@ -245,8 +245,9 @@ module.exports = function createChatRouter(ctx) { }); // Clear one chat session's history plus resumable CLI session so the next message - // starts a new machine-side conversation. This does not touch files on disk. - router.post('/api/session/clear', (req, res) => { + // starts a new machine-side conversation. This does not touch project/worktree + // files; Relay state and the CLI transcript are removed on a best-effort basis. + router.post('/api/session/clear', async (req, res) => { const agentKey = String(req.body.agent || '').trim(); const requestedSessionId = String(req.body.sessionId || '').trim(); let workdir; @@ -275,14 +276,11 @@ module.exports = function createChatRouter(ctx) { code: 'SESSION_BUSY', }); } - const cleared = clearSession(scopeKey); + const cleared = await purgeSession(scopeKey, { + agentKey: agent.key, + workdir, + }); clearHistory(scopeKey); - // Drop the /btw side chat derived from this session too (scope agent - // `btw:`, keyed by the same session id) so it never outlives the - // main conversation it forked from. No-op for agents without a side chat. - const btwScopeKey = scopeKeyFor(`btw:${agent.key}`, workdir, chatSession.id); - clearSession(btwScopeKey); - clearHistory(btwScopeKey); touchChatSession(contextKey, chatSession.id); return res.json({ ok: true, @@ -297,12 +295,10 @@ module.exports = function createChatRouter(ctx) { const contextKey = sessionContextKeyFor(agent.key, workdir); for (const chatSession of listChatSessions(contextKey).sessions) { const scopeKey = scopeKeyFor(agent.key, workdir, chatSession.id); - if (clearSession(scopeKey)) cleared += 1; + if (await purgeSession(scopeKey, { agentKey: agent.key, workdir })) { + cleared += 1; + } clearHistory(scopeKey); - // Also clear the derived /btw side chat (see single-session path above). - const btwScopeKey = scopeKeyFor(`btw:${agent.key}`, workdir, chatSession.id); - clearSession(btwScopeKey); - clearHistory(btwScopeKey); } } return res.json({ ok: true, workdir, cleared }); diff --git a/server/routes/group.js b/server/routes/group.js index 63eb467..9b59d02 100644 --- a/server/routes/group.js +++ b/server/routes/group.js @@ -18,15 +18,26 @@ const { const { normalizeSettings } = require('../lib/agent-options'); // Multi-agent group chat: one human, several agents, one canonical transcript. -// The orchestrator reuses the single-agent turn pipeline (runAgentTurn) once per -// summoned member, serialized on the group's scope so exactly one agent holds the -// floor at a time. Each member runs against its OWN resumable CLI session (its -// private memory) and is fed only the delta since it last spoke (see -// docs/group-chat.md, "plan B"). The group transcript lives under a dedicated -// scope agent key so it never mixes with any member's solo conversation. +// The orchestrator reuses the single-agent turn pipeline (runAgentTurn) once for +// every summoned member. Members in one wave run concurrently from the same +// transcript snapshot, each against its OWN resumable CLI session and unseen +// transcript delta (see docs/handbook.md, "Swarms"). The canonical transcript +// uses a dedicated scope agent key so it never mixes with solo conversations. const GROUP_SCOPE_PREFIX = 'group:'; const HUMAN_AUTHOR = 'human'; +// A member's reply can summon other members, so one human message can run +// several waves of turns. Cap how many of those agent-driven waves follow the +// human's, since two members that keep naming each other would otherwise talk +// (and bill) forever. 0 restores human-only summoning. +const DEFAULT_MAX_MENTION_HOPS = 3; + +function maxMentionHops(env = process.env) { + const raw = Number.parseInt(env.RELAY_SWARM_MAX_HOPS ?? '', 10); + if (!Number.isFinite(raw) || raw < 0) return DEFAULT_MAX_MENTION_HOPS; + return Math.min(raw, 20); +} + function wantsStream(req) { return String(req.get('accept') || '') .toLowerCase() @@ -96,7 +107,7 @@ module.exports = function createGroupRouter(ctx) { activeRequests, agentTurnDependencies, clearHistory, - clearSession, + purgeSession, finalizeStaleStreamingHistory, getAgent, normalizeDeviceId, @@ -115,6 +126,7 @@ module.exports = function createGroupRouter(ctx) { validateWorkdir, } = ctx; const router = express.Router(); + const maxHops = maxMentionHops(); const groupScopeKeyFor = (workdir, groupId) => sessionContextKeyFor(`${GROUP_SCOPE_PREFIX}${groupId}`, workdir); @@ -229,7 +241,7 @@ module.exports = function createGroupRouter(ctx) { return res.json({ ok: true, workdir, group, groups: listGroups(workdir) }); }); - router.post('/api/groups/delete', (req, res) => { + router.post('/api/groups/delete', async (req, res) => { const workdir = resolveWorkdir(req, res); if (workdir === null) return undefined; const groupId = String(req.body.groupId || '').trim(); @@ -245,7 +257,10 @@ module.exports = function createGroupRouter(ctx) { deleteGroup(workdir, group.id); clearHistory(scopeKey); for (const memberKey of group.members) { - clearSession(memberSessionKeyFor(runWorkdir, group.id, memberKey)); + await purgeSession(memberSessionKeyFor(runWorkdir, group.id, memberKey), { + agentKey: memberKey, + workdir: runWorkdir, + }); } return res.json({ ok: true, workdir, groups: listGroups(workdir) }); }); @@ -267,7 +282,7 @@ module.exports = function createGroupRouter(ctx) { // Reset a swarm's transcript and every member's forked CLI session, keeping the // swarm itself. The next message starts the conversation afresh. - router.post('/api/group/clear', (req, res) => { + router.post('/api/group/clear', async (req, res) => { const workdir = resolveWorkdir(req, res); if (workdir === null) return undefined; const groupId = String(req.body.groupId || '').trim(); @@ -282,7 +297,10 @@ module.exports = function createGroupRouter(ctx) { } clearHistory(scopeKey); for (const memberKey of group.members) { - clearSession(memberSessionKeyFor(runWorkdir, group.id, memberKey)); + await purgeSession(memberSessionKeyFor(runWorkdir, group.id, memberKey), { + agentKey: memberKey, + workdir: runWorkdir, + }); } return res.json({ ok: true, workdir, group }); }); @@ -406,44 +424,87 @@ module.exports = function createGroupRouter(ctx) { }); const base = agentTurnDependencies(); - // Snapshot the transcript once — after the human message, before any member - // reply. Every member summoned in THIS message is fed the same delta, so a - // batch of @mentions runs in parallel without any member seeing a sibling's - // in-flight reply. (A later message still sees the earlier replies, because it - // snapshots after they were recorded — cross-round stays collaborative.) - const snapshot = readHistory(scopeKey); // Mark the group scope busy for the whole round. Members serialize on their // own session keys (so they run concurrently), so this group scope key is what // the delete/clear/history routes consult to tell the round is still running. runningScopes.add(scopeKey); - // Freeze every summoned member's prompt up front, from the one snapshot, so a - // member's prompt can never absorb a sibling's reply even as those replies - // stream into the shared transcript once the turns start running. - const plans = mentions - .map((memberKey, index) => { - const memberAgent = getAgent(memberKey); - if (!memberAgent) return null; - const memberSessionKey = memberSessionKeyFor(runWorkdir, group.id, memberKey); - // Plan B: feed this member only what happened since it last spoke, each - // line labeled with its speaker. Its own resumable session has the rest. - const delta = deltaSince(snapshot, memberKey); - const memberConfig = group.memberConfigs[memberKey] || {}; - const groupPrompt = buildGroupPrompt({ - selfLabel: groupLabelFor(memberKey), - persona: memberConfig.prompt, - delta, - labelFor: groupLabelFor, - maxBytes: Math.max(1024, MAX_PROMPT_BYTES - 1024), - }); - return { memberKey, memberAgent, memberSessionKey, groupPrompt, index }; - }) - .filter(Boolean); + // Everyone this member is allowed to hand the floor to. Empty when + // agent-to-agent summoning is off, so the prompt never offers what the + // orchestrator would then ignore. + const rosterFor = (memberKey) => + maxHops === 0 + ? [] + : group.members + .filter((key) => key !== memberKey) + .map((key) => ({ key, label: groupLabelFor(key) })); + + // Freeze every summoned member's prompt up front, from one snapshot taken at + // the start of the wave, so a member's prompt can never absorb a sibling's + // reply even as those replies stream into the shared transcript once the + // turns start running. (The next wave snapshots again, so it does see them.) + const planWave = (summons, wave) => { + const snapshot = readHistory(scopeKey); + return summons + .map(({ memberKey, summonedBy }, index) => { + const memberAgent = getAgent(memberKey); + if (!memberAgent) return null; + const memberSessionKey = memberSessionKeyFor(runWorkdir, group.id, memberKey); + // Plan B: feed this member only what happened since it last spoke, each + // line labeled with its speaker. Its own resumable session has the rest. + const delta = deltaSince(snapshot, memberKey); + const memberConfig = group.memberConfigs[memberKey] || {}; + const groupPrompt = buildGroupPrompt({ + selfLabel: groupLabelFor(memberKey), + persona: memberConfig.prompt, + delta, + labelFor: groupLabelFor, + roster: rosterFor(memberKey), + maxBytes: Math.max(1024, MAX_PROMPT_BYTES - 1024), + }); + return { + memberKey, + memberAgent, + memberSessionKey, + groupPrompt, + summonedBy, + turnRequestId: `${requestId}.${wave}.${index}.${memberKey}`, + }; + }) + .filter(Boolean); + }; + + // Who the replies of a finished wave handed the floor to. A member never + // summons itself (that would never terminate), and is summoned once per wave + // however many siblings named it — it sees all of them in its next delta. + // Failed and cancelled turns are skipped: their recorded content is an error + // message, not something an agent chose to say. + const nextSummons = (results) => { + const seen = new Set(); + const out = []; + for (const result of results) { + if (!result || result.status !== 'done') continue; + const summoned = parseMentions(result.content, group.members, groupLabelFor); + for (const memberKey of summoned) { + if (memberKey === result.agent || seen.has(memberKey)) continue; + seen.add(memberKey); + out.push({ memberKey, summonedBy: result.agent }); + } + } + return out; + }; // One summoned member's turn. The assistant placeholder is recorded // synchronously (before the first await), so kicking these off in mention // order keeps the transcript ordered even though replies arrive in parallel. - const runMember = async ({ memberKey, memberAgent, memberSessionKey, groupPrompt, index }) => { + const runMember = async ({ + memberKey, + memberAgent, + memberSessionKey, + groupPrompt, + summonedBy, + turnRequestId, + }) => { const dependencies = { ...base, // Tag every shared-stream event with the group so only clients viewing @@ -474,7 +535,7 @@ module.exports = function createGroupRouter(ctx) { // Record the swarm-scoped display name so the transcript attributes the // reply to the member's nickname (falls back to the agent label). agentLabel: groupLabelFor(memberKey), - summonedBy: HUMAN_AUTHOR, + summonedBy, groupId: group.id, groupName: group.name, }, @@ -482,7 +543,7 @@ module.exports = function createGroupRouter(ctx) { prompt: groupPrompt, recordHistory: true, recordUserMessage: false, - requestId: `${requestId}.${index}.${memberKey}`, + requestId: turnRequestId, responder, runState, scopeKey, @@ -490,15 +551,31 @@ module.exports = function createGroupRouter(ctx) { signal: abortController.signal, workdir: runWorkdir, }); - return { agent: memberKey, status: result && result.status }; + return { + agent: memberKey, + status: result && result.status, + summonedBy, + content: (result && result.content) || '', + }; }; - let turns = []; + const turns = []; try { - const settled = runState.cancelled - ? [] - : await Promise.all(plans.map((plan) => runMember(plan))); - turns = settled.filter(Boolean); + // The human's mentions open the round; each wave's replies can summon the + // next, up to maxHops waves after the human's. + let summons = mentions.map((memberKey) => ({ + memberKey, + summonedBy: HUMAN_AUTHOR, + })); + for (let wave = 0; summons.length > 0 && !runState.cancelled; wave += 1) { + const settled = await Promise.all( + planWave(summons, wave).map((plan) => runMember(plan)), + ); + for (const result of settled) { + if (result) turns.push({ agent: result.agent, status: result.status }); + } + summons = wave >= maxHops ? [] : nextSummons(settled); + } } finally { runningScopes.delete(scopeKey); activeRequests.delete(requestId); diff --git a/server/routes/meta.js b/server/routes/meta.js index 1e2b401..4310434 100644 --- a/server/routes/meta.js +++ b/server/routes/meta.js @@ -65,7 +65,13 @@ module.exports = function createMetaRouter(ctx) { installed, authed, authKind: status.authKind || 'unknown', - // claude/codex/agy (oauth) gate on login; hermes/opencode are managed + // Epoch ms at which the stored OAuth credential runs out, so the app + // can say how many days are left before a login on the host is due. + // null for agents whose credential carries no expiry. + credentialExpiresAt: Number.isFinite(status.credentialExpiresAt) + ? status.credentialExpiresAt + : null, + // claude/codex (oauth) gate on login; hermes/opencode are managed // out-of-band (the user sets up their key on the host), so they are // usable whenever installed and never gate on a key Relay can't see. usable: @@ -100,12 +106,23 @@ module.exports = function createMetaRouter(ctx) { }); } + // The model and effort pages show the installed CLI version, so this ran a + // subprocess every time one of them opened. The version only moves when the + // binary is replaced, so remember it and re-run at most once a minute; the + // updater below overwrites the entry with the version it just installed. + const VERSION_TTL_MS = 60_000; + const versionCache = new Map(); + async function cliVersion(agentKey) { const cli = CLI[agentKey]; if (!cli) return ''; + const cached = versionCache.get(agentKey); + if (cached && Date.now() - cached.at < VERSION_TTL_MS) return cached.version; const result = await runCliCommand(cli.bin, cli.versionArgs, 15000); // Versions print as e.g. "2.1.161 (Claude Code)" / "codex-cli 0.132.0". - return result.ok ? result.text.split('\n')[0].trim() : ''; + const version = result.ok ? result.text.split('\n')[0].trim() : ''; + versionCache.set(agentKey, { version, at: Date.now() }); + return version; } // Catalog of selectable model/effort/permission/fast options for one agent. Model @@ -181,6 +198,7 @@ module.exports = function createMetaRouter(ctx) { const before = await cliVersion(agent.key); const result = await runCliCommand(cli.bin, cli.updateArgs, 180000); clearModelDiscoveryCache(agent.key); + versionCache.delete(agent.key); const after = await cliVersion(agent.key); return res.json({ ok: result.ok, @@ -195,7 +213,7 @@ module.exports = function createMetaRouter(ctx) { // Best-effort login state per agent so the app can warn before sending a // message. loggedIn is true/false when detectable from on-disk credentials, - // or null when it cannot be determined without running the CLI (e.g. agy). + // or null when it cannot be determined without running the CLI. router.get('/api/auth/status', (_req, res) => { res.json({ agents: listAgents().map((agent) => ({ diff --git a/server/routes/sessions.js b/server/routes/sessions.js index d52e883..ebf0efa 100644 --- a/server/routes/sessions.js +++ b/server/routes/sessions.js @@ -9,7 +9,7 @@ module.exports = function createSessionsRouter(ctx) { agentPayload, agentRequiredOrUnknownError, clearHistory, - clearSession, + purgeSession, createChatSession, deleteChatSession, listChatSessions, @@ -89,7 +89,7 @@ module.exports = function createSessionsRouter(ctx) { }); }); - router.post('/api/sessions/delete', (req, res) => { + router.post('/api/sessions/delete', async (req, res) => { const agentKey = String(req.body.agent || '').trim(); const sessionId = String(req.body.sessionId || '').trim(); const scope = resolveAgentScope(req, res, { @@ -127,7 +127,7 @@ module.exports = function createSessionsRouter(ctx) { }); } const result = deleteChatSession(contextKey, sessionId); - clearSession(scopeKey); + await purgeSession(scopeKey, { agentKey: agent.key, workdir }); clearHistory(scopeKey); return res.json({ ok: true, diff --git a/server/scripts/run-tests.js b/server/scripts/run-tests.js new file mode 100644 index 0000000..df24dc3 --- /dev/null +++ b/server/scripts/run-tests.js @@ -0,0 +1,23 @@ +const { readdirSync } = require('node:fs'); +const { join } = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const serverDir = join(__dirname, '..'); +const testDir = join(serverDir, 'test'); +const testFiles = readdirSync(testDir) + .filter((name) => name.endsWith('.test.js')) + .sort() + .map((name) => join('test', name)); + +if (testFiles.length === 0) { + console.error('No backend test files found.'); + process.exit(1); +} + +const result = spawnSync(process.execPath, ['--test', ...testFiles], { + cwd: serverDir, + stdio: 'inherit', +}); + +if (result.error) throw result.error; +process.exit(result.status ?? 1); diff --git a/server/server.js b/server/server.js index a9b663a..580924e 100644 --- a/server/server.js +++ b/server/server.js @@ -17,9 +17,8 @@ const { getAgent, listAgents, runAgent, - runBtw, - runBtwAgent, - clearSession, + purgeSession, + shutdownPools, } = require('./lib/agents'); const { WorkdirError, @@ -55,6 +54,7 @@ const push = require('./lib/push'); const fcm = require('./lib/fcm'); const { notifyAll } = require('./lib/notify'); const { startQuotaWatch } = require('./lib/quota-watch'); +const { startClaudeQuotaKeepalive } = require('./lib/quota-keepalive'); const { authStatus } = require('./lib/auth-status'); const { buildDiagnostics } = require('./lib/diagnostics'); const { @@ -103,15 +103,14 @@ const createSessionsRouter = require('./routes/sessions'); const createQuotaRouter = require('./routes/quota'); const createPushRouter = require('./routes/push'); const createMetaRouter = require('./routes/meta'); -const createBtwRouter = require('./routes/btw'); const createGroupRouter = require('./routes/group'); -const createAgentAuthRouter = require('./routes/agent-auth'); const createTerminalRouter = require('./routes/terminal'); const PORT = parseInt(process.env.PORT || '8787', 10); const HOST = process.env.HOST || '127.0.0.1'; const PUBLIC_BASE_URL = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''); const ENABLE_QUOTA_WATCH = process.env.ENABLE_QUOTA_WATCH !== 'false'; +const ENABLE_CLAUDE_KEEPALIVE = process.env.ENABLE_CLAUDE_KEEPALIVE !== 'false'; const WEB_BUILD_DIR = path.join(__dirname, '..', 'build', 'web'); // Hard cap on a single download (file, or the uncompressed total behind a zip). // Public tunnels can relay slowly or enforce throughput limits, so we refuse @@ -127,10 +126,9 @@ const MAX_UPLOAD_BYTES = parseInt( process.env.UPLOAD_MAX_BYTES || String(100 * 1024 * 1024), 10, ); -// Cap a single chat prompt. The prompt travels to the CLI as one argv token and -// Linux limits a single argument to ~128KB (MAX_ARG_STRLEN), so anything larger -// could never reach the agent — fail it with a clear error instead of a -// confusing spawn failure. Override with PROMPT_MAX_BYTES. +// Cap a single chat prompt before it enters an SDK or JSON-RPC session. Swarm +// prompt construction uses the same budget so an accumulated transcript cannot +// grow without bound. Override with PROMPT_MAX_BYTES. const MAX_PROMPT_BYTES = parseInt( process.env.PROMPT_MAX_BYTES || String(100 * 1024), 10, @@ -159,10 +157,8 @@ function isStreamingApiPath(req) { case '/events': case '/chat': case '/group/chat': - case '/btw': case '/fs/download': case '/fs/upload': - case '/agent-auth/login/start': return true; default: return false; @@ -250,10 +246,10 @@ function streamUploadToFile(req, targetPath, maxBytes) { }); } -// Compress responses before they cross the tunnel. The web bundle is the bulk of -// first-load bytes (main.dart.js ~3.6MB + canvaskit.wasm ~7MB); gzip cuts it ~60%, -// turning a multi-minute first load into seconds. `compressible` does not flag -// application/wasm, so allow it explicitly. Streaming/SSE responses set +// Compress responses before they cross the tunnel. The JavaScript and CanvasKit +// bundles dominate first-load bytes, so compression materially reduces startup +// time. `compressible` does not flag application/wasm, so allow it explicitly. +// Streaming/SSE responses set // `Cache-Control: no-transform`, which compression honors by skipping them. app.use( compression({ @@ -828,7 +824,7 @@ const routeContext = { buildUsageReport, cancelQuotaSchedule, clearHistory, - clearSession, + purgeSession, createChatResponder, createChatSession, createQuotaSchedule, @@ -868,8 +864,6 @@ const routeContext = { resolveUploadTarget, revokeTokenById, runAgentTurn, - runBtw, - runBtwAgent, runningScopes, safeDownloadName, scopeChains, @@ -898,9 +892,7 @@ app.use(createMetaRouter(routeContext)); app.use(createPushRouter(routeContext)); app.use(createFsRouter(routeContext)); app.use(createChatRouter(routeContext)); -app.use(createBtwRouter(routeContext)); app.use(createGroupRouter(routeContext)); -app.use(createAgentAuthRouter(routeContext)); app.use(createSessionsRouter(routeContext)); app.use(createQuotaRouter(routeContext)); app.use(createTerminalRouter(routeContext)); @@ -912,7 +904,7 @@ if (fs.existsSync(path.join(WEB_BUILD_DIR, 'index.html'))) { setHeaders(res, filePath) { const rel = path.relative(WEB_BUILD_DIR, filePath); // CanvasKit is pinned to the Flutter engine revision and is effectively - // immutable between SDK upgrades; cache the 7MB wasm hard so it downloads + // immutable between SDK upgrades; cache the wasm hard so it downloads // once and is then served from the browser cache with no request at all. if (rel.split(path.sep)[0] === 'canvaskit') { res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); @@ -921,7 +913,7 @@ if (fs.existsSync(path.join(WEB_BUILD_DIR, 'index.html'))) { // Other files (index.html, *.js, assets) keep their filenames across builds, // so allow caching but always revalidate: a matching ETag returns a tiny 304 // instead of re-sending the bytes. Never `no-store` — that re-downloaded the - // whole ~11MB bundle on every load, which is what made the web take minutes. + // whole Web bundle on every load, which can make startup very slow. res.setHeader('Cache-Control', 'no-cache'); }, })); @@ -950,6 +942,9 @@ process.on('exit', flushHistoryForShutdown); for (const signal of ['SIGINT', 'SIGTERM']) { process.once(signal, () => { terminalManager.closeAll(); + // Live agent sessions are children of this process; close them explicitly + // so a restart never leaves orphaned CLI processes holding memory. + shutdownPools().catch(() => {}); flushHistoryForShutdown(); process.exit(exitCodeForSignal(signal)); }); @@ -958,10 +953,10 @@ for (const signal of ['SIGINT', 'SIGTERM']) { const server = app.listen(PORT, HOST, () => { console.log(`Relay server listening on http://${HOST}:${PORT}`); // Warm the model-discovery cache off the request path. The first scan/spawn - // per agent is synchronous (agy even shells out to `agy models`), so priming - // it now keeps the first chat turn and options fetch fast. + // per agent is synchronous, so priming it now keeps the first chat turn and + // options fetch fast. setImmediate(() => { - for (const agent of ['claude', 'codex', 'agy']) { + for (const agent of ['claude', 'codex']) { try { describeAgent(agent); } catch (_err) { @@ -1008,6 +1003,9 @@ const server = app.listen(PORT, HOST, () => { message, messageZh: info && info.messageZh, category: 'quota', + // Matches the tag an open client uses for the event-stream copy, so a + // browser that shows both collapses them into one notification. + tag: 'quota', }); processDueQuotaSchedules(info).catch((err) => { console.error(`[quota:${info && info.key}] scheduled message runner failed: ${err.message}`); @@ -1015,5 +1013,8 @@ const server = app.listen(PORT, HOST, () => { }, }); } + if (ENABLE_CLAUDE_KEEPALIVE) { + startClaudeQuotaKeepalive(); + } }); terminalManager.attachServer(server); diff --git a/server/test/acp-session-pool.test.js b/server/test/acp-session-pool.test.js new file mode 100644 index 0000000..fdc5544 --- /dev/null +++ b/server/test/acp-session-pool.test.js @@ -0,0 +1,389 @@ +'use strict'; + +const { test, after } = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { createAcpSessionPool } = require('../lib/acp-session-pool'); + +const AGENT = path.join(__dirname, 'fixtures', 'fake-acp-agent.js'); + +// A failing assertion skips the test's own cleanup, and a live agent process +// keeps the runner from exiting — which hides the failure behind a hang. +const opened = []; +after(async () => { + for (const cleanup of opened) await cleanup(); +}); + +// Each pool gets its own state file, so the assertions below read exactly what +// this test's agent process saw. +function makePool(options = {}) { + const statePath = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), 'relay-acp-')), + 'state.log', + ); + fs.writeFileSync(statePath, ''); + const childEnv = { FAKE_ACP_STATE: statePath, ...(options.agentEnv || {}) }; + const pool = createAcpSessionPool({ + agentKey: 'fake', + env: {}, + command: () => ({ + cmd: process.execPath, + args: [AGENT], + // The fixture reads its flags from the environment it inherits. + }), + ...options, + }); + // The pool spawns with process.env, so the flags have to live there. They are + // restored when the pool shuts down. + const previous = {}; + for (const [key, value] of Object.entries(childEnv)) { + previous[key] = process.env[key]; + process.env[key] = value; + } + const done = async () => { + await pool.shutdown(); + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }; + opened.push(done); + return { + pool, + state: () => + fs + .readFileSync(statePath, 'utf8') + .split('\n') + .filter(Boolean), + done, + }; +} + +function send(pool, key, prompt, extra = {}) { + return pool.send({ + key, + prompt, + cwd: '/w', + onMessage: () => {}, + ...extra, + }); +} + +function textOf(updates) { + return updates + .filter((u) => u.sessionUpdate === 'agent_message_chunk') + .map((u) => u.content.text) + .join(''); +} + +// Wait for a condition the agent process reports asynchronously. +async function waitFor(check, timeoutMs = 2000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (check()) return true; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + return false; +} + +test('a second turn reuses the live session instead of opening another', async () => { + const { pool, state, done } = makePool(); + const updates = []; + const first = await send(pool, 'a', 'one', { + onMessage: (u) => updates.push(u), + }); + const second = await send(pool, 'a', 'two'); + assert.equal(state().filter((l) => l.startsWith('spawn ')).length, 1); + assert.equal(state().filter((l) => l.startsWith('new ')).length, 1); + assert.equal(first.sessionId, second.sessionId); + assert.equal(textOf(updates), 'echo:one'); + assert.equal(second.result.stopReason, 'end_turn'); + await done(); +}); + +test('several scopes share one agent process', async () => { + // This is the whole point of ACP over the Claude SDK: opencode costs ~360MB + // to boot, and that is paid once rather than once per chat. + const { pool, state, done } = makePool(); + const a = await send(pool, 'a', 'one', { cwd: '/w1' }); + const b = await send(pool, 'b', 'one', { cwd: '/w2' }); + assert.notEqual(a.sessionId, b.sessionId, 'independent sessions'); + assert.equal(state().filter((l) => l.startsWith('spawn ')).length, 1); + // cwd is per session, so two work trees still share the process. + assert.ok(state().includes(`new ${a.sessionId} /w1`)); + assert.ok(state().includes(`new ${b.sessionId} /w2`)); + assert.equal(pool.stats().live, 2); + await done(); +}); + +test('a cold start loads the stored session id', async () => { + const { pool, state, done } = makePool(); + const result = await send(pool, 'a', 'one', { resumeId: 'stored-id' }); + assert.ok(state().includes('load stored-id /w')); + assert.equal(result.sessionId, 'stored-id', 'keeps the caller session'); + assert.equal(result.startedNew, false); + await done(); +}); + +test('an unloadable stored session falls back to a new one and says so', async () => { + const { pool, state, done } = makePool({ agentEnv: { FAKE_ACP_NO_LOAD: '1' } }); + const result = await send(pool, 'a', 'one', { resumeId: 'gone' }); + assert.notEqual(result.sessionId, 'gone'); + assert.equal(result.startedNew, true, 'the caller can tell the user'); + assert.equal(result.result.stopReason, 'end_turn'); + assert.ok(state().some((l) => l.startsWith('new '))); + await done(); +}); + +test('the model is applied over the protocol, once per session', async () => { + const { pool, state, done } = makePool(); + await send(pool, 'a', 'one', { modelId: 'prov/model-x' }); + await send(pool, 'a', 'two', { modelId: 'prov/model-x' }); + const models = state().filter((l) => l.startsWith('model ')); + assert.equal(models.length, 1, 'unchanged settings do not re-set the model'); + assert.ok(models[0].endsWith('prov/model-x')); + + // A changed model switches live — no restart, unlike the Claude pool. + await send(pool, 'a', 'three', { modelId: 'prov/model-y' }); + assert.equal(state().filter((l) => l.startsWith('model ')).length, 2); + assert.equal(state().filter((l) => l.startsWith('spawn ')).length, 1); + assert.equal(state().filter((l) => l.startsWith('new ')).length, 1); + await done(); +}); + +test('the permission mode is applied like the model, and only on change', async () => { + const { pool, state, done } = makePool(); + await send(pool, 'a', 'one', { modeId: 'dont_ask' }); + await send(pool, 'a', 'two', { modeId: 'dont_ask' }); + assert.equal(state().filter((l) => l.startsWith('mode ')).length, 1); + await send(pool, 'a', 'three', { modeId: 'default' }); + const modes = state().filter((l) => l.startsWith('mode ')); + assert.equal(modes.length, 2); + assert.ok(modes[1].endsWith('default')); + assert.equal(state().filter((l) => l.startsWith('spawn ')).length, 1); + await done(); +}); + +test('an agent without session/close still drops evicted sessions', async () => { + // Hermes advertises no close capability: the pool must not send one, and the + // session still leaves the pool so the cap is honoured. + const { pool, state, done } = makePool({ + maxSessions: 1, + agentEnv: { FAKE_ACP_NO_CLOSE: '1' }, + }); + await send(pool, 'a', 'one'); + await send(pool, 'b', 'one'); + assert.equal(pool.stats().live, 1); + assert.equal(state().filter((l) => l.startsWith('close ')).length, 0); + assert.deepEqual(pool.stats().keys, ['b']); + await done(); +}); + +test('the session cap evicts the least recently used idle session', async () => { + const { pool, state, done } = makePool({ maxSessions: 2 }); + const a = await send(pool, 'a', 'one'); + await send(pool, 'b', 'one'); + assert.equal(pool.stats().live, 2); + await send(pool, 'c', 'one'); + assert.equal(pool.stats().live, 2, 'never exceeds the cap'); + assert.ok( + state().includes(`close ${a.sessionId}`), + 'the oldest idle session was closed on the agent too', + ); + await done(); +}); + +test('a turn blocked on the cap runs once another turn finishes', async () => { + // Group chats summon several members at once, so more concurrent turns than + // session slots is a normal state, not an error. + const { pool, state, done } = makePool({ maxSessions: 1 }); + const controller = new AbortController(); + const busy = send(pool, 'a', 'hang', { signal: controller.signal }); + await waitFor(() => state().some((l) => l.endsWith('hang'))); + + let blockedDone = false; + const blocked = send(pool, 'b', 'queued').then((value) => { + blockedDone = true; + return value; + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal(blockedDone, false, 'waits while the only slot is busy'); + assert.equal(pool.stats().waiting, 1); + + // Freeing the slot is what wakes the waiter — the finished turn's session is + // evictable again. + controller.abort(); + await assert.rejects(busy, (err) => err.code === 'AGENT_CANCELLED'); + const result = await blocked; + assert.equal(result.result.stopReason, 'end_turn'); + assert.equal(pool.stats().live, 1, 'still within the cap'); + await done(); +}); + +test('cancelling a turn interrupts it and leaves the session usable', async () => { + const { pool, state, done } = makePool(); + const controller = new AbortController(); + const pending = send(pool, 'a', 'hang', { signal: controller.signal }); + await waitFor(() => state().some((l) => l.endsWith('hang'))); + controller.abort(); + await assert.rejects(pending, (err) => err.code === 'AGENT_CANCELLED'); + assert.ok(state().some((l) => l.startsWith('cancel ')), 'cancel, not kill'); + + // The session survives: the next turn lands on the same one. + const next = await send(pool, 'a', 'after'); + assert.equal(state().filter((l) => l.startsWith('new ')).length, 1); + assert.equal(next.result.stopReason, 'end_turn'); + await done(); +}); + +test('an already-aborted signal rejects without prompting', async () => { + const { pool, state, done } = makePool(); + const controller = new AbortController(); + controller.abort(); + await assert.rejects( + send(pool, 'a', 'never', { signal: controller.signal }), + (err) => err.code === 'AGENT_CANCELLED', + ); + assert.equal(state().filter((l) => l.startsWith('prompt ')).length, 0); + await done(); +}); + +test('permission requests are answered by the caller policy', async () => { + const { pool, state, done } = makePool(); + const updates = []; + await send(pool, 'a', 'perm', { + onMessage: (u) => updates.push(u), + // The runner answers yes or no; picking the matching option is the + // driver's job, so the policy never touches protocol vocabulary. + onPermission: () => true, + }); + assert.ok(state().includes('permission yes')); + assert.equal(textOf(updates), 'permission:yes'); + + // No policy at all must not hang the agent: it gets an explicit answer. + await send(pool, 'b', 'perm'); + assert.ok(state().includes('permission no'), 'refused, not left hanging'); + await done(); +}); + +test('an idle session is closed, and the process goes with the last one', async () => { + const { pool, state, done } = makePool({ idleMs: 40 }); + const first = await send(pool, 'a', 'one'); + assert.equal(pool.stats().live, 1); + assert.equal(pool.stats().connected, true); + await waitFor(() => pool.stats().live === 0); + assert.equal(pool.stats().live, 0, 'evicted once idle'); + assert.ok(state().includes(`close ${first.sessionId}`)); + // The process exists only to host sessions. + assert.ok(await waitFor(() => pool.stats().connected === false)); + + // The conversation is unaffected: the next turn cold-starts and loads. + const second = await send(pool, 'a', 'two', { resumeId: first.sessionId }); + assert.equal(state().filter((l) => l.startsWith('spawn ')).length, 2); + assert.equal(second.sessionId, first.sessionId); + await done(); +}); + +test('a warm session that dies before output is retried cold', async () => { + const { pool, state, done } = makePool(); + const first = await send(pool, 'a', 'one'); + const second = await send(pool, 'a', 'die-quiet', { + resumeId: first.sessionId, + }); + assert.equal(state().filter((l) => l.startsWith('spawn ')).length, 2); + assert.equal(second.result.stopReason, 'end_turn'); + await done(); +}); + +test('a warm session that dies mid-reply is not silently re-run', async () => { + // Retrying would replay text the user already saw. + const { pool, state, done } = makePool(); + await send(pool, 'a', 'one'); + await assert.rejects( + send(pool, 'a', 'die'), + (err) => err.code === 'AGENT_SESSION_LOST' && err.emitted === true, + ); + assert.equal(state().filter((l) => l.startsWith('spawn ')).length, 1); + await done(); +}); + +test('every update in a turn reaches the caller, result excluded', async () => { + const { pool, done } = makePool(); + const updates = []; + await send(pool, 'a', 'two', { onMessage: (u) => updates.push(u) }); + assert.deepEqual( + updates.map((u) => u.messageId), + ['first', 'second'], + 'both assistant messages, so the runner can split them into segments', + ); + await done(); +}); + +test('forget without purge drops the session but keeps the transcript', async () => { + const deleted = []; + const { pool, state, done } = makePool({ + deleteCommand: (sessionId) => { + deleted.push(sessionId); + return { cmd: process.execPath, args: ['-e', ''] }; + }, + }); + const first = await send(pool, 'a', 'one'); + await pool.forget('a'); + assert.ok(state().includes(`close ${first.sessionId}`)); + assert.deepEqual(deleted, []); + assert.equal(pool.stats().live, 0); + await done(); +}); + +test('forget with purge runs the delete command for the session', async () => { + const deleted = []; + const { pool, done } = makePool({ + deleteCommand: (sessionId) => { + deleted.push(sessionId); + return { cmd: process.execPath, args: ['-e', ''] }; + }, + }); + await send(pool, 'a', 'one'); + const purged = await pool.forget('a', { purge: true, sessionId: 'sess-x' }); + assert.equal(purged, true, 'the CLI reported success'); + assert.deepEqual(deleted, ['sess-x']); + await done(); +}); + +test('shutdown closes every session and kills the process', async () => { + const { pool, state, done } = makePool(); + await send(pool, 'a', 'one'); + await send(pool, 'b', 'one'); + const pid = Number( + state() + .find((line) => line.startsWith('spawn ')) + .split(' ')[1], + ); + await pool.shutdown(); + assert.equal(pool.stats().live, 0); + assert.equal(pool.stats().connected, false); + // A restart must not leave the agent behind holding memory. + const gone = await waitFor(() => { + try { + process.kill(pid, 0); + return false; + } catch (_err) { + return true; + } + }); + assert.ok(gone, 'the agent process is really gone'); + await done(); +}); + +test('a missing binary fails the turn instead of the server', async () => { + const pool = createAcpSessionPool({ + agentKey: 'fake', + env: {}, + command: () => null, + }); + await assert.rejects(send(pool, 'a', 'one'), /not installed/); + await pool.shutdown(); +}); diff --git a/server/test/agent-auth-route.test.js b/server/test/agent-auth-route.test.js deleted file mode 100644 index 96562fa..0000000 --- a/server/test/agent-auth-route.test.js +++ /dev/null @@ -1,102 +0,0 @@ -'use strict'; - -const assert = require('node:assert/strict'); -const { after, before, test } = require('node:test'); -const express = require('express'); - -const createAgentAuthRouter = require('../routes/agent-auth'); - -let server; -let base; -const submitted = []; -let unsubscribeCount = 0; - -const loginManager = { - start(agent) { - return { id: 'session-1', agent }; - }, - subscribe(_sessionId, listener) { - listener({ - type: 'login_started', - data: { sessionId: 'session-1', agent: 'codex' }, - }); - listener({ - type: 'login_url', - data: { - sessionId: 'session-1', - agent: 'codex', - url: 'https://example.test/login', - }, - }); - listener({ - type: 'login_done', - data: { sessionId: 'session-1', agent: 'codex' }, - }); - let unsubscribed = false; - return () => { - if (unsubscribed) return; - unsubscribed = true; - unsubscribeCount += 1; - }; - }, - submitCode(sessionId, code) { - submitted.push({ sessionId, code }); - }, - status(sessionId) { - return { - sessionId, - agent: 'codex', - status: 'done', - url: 'https://example.test/login', - error: '', - }; - }, -}; - -before(async () => { - const app = express(); - app.use(express.json()); - app.use( - createAgentAuthRouter({ - getAgent: (key) => ({ key, label: key }), - loginManager, - }), - ); - await new Promise((resolve) => { - server = app.listen(0, '127.0.0.1', resolve); - }); - const { port } = server.address(); - base = `http://127.0.0.1:${port}`; -}); - -after(() => { - if (server) server.close(); -}); - -test('login start streams SSE events from the login manager', async () => { - unsubscribeCount = 0; - const response = await fetch(`${base}/api/agent-auth/login/start?agent=codex`); - assert.equal(response.status, 200); - assert.match(response.headers.get('content-type'), /text\/event-stream/); - const text = await response.text(); - assert.match(text, /event: login_started/); - assert.match(text, /event: login_url/); - assert.match(text, /https:\/\/example\.test\/login/); - assert.match(text, /event: login_done/); - assert.equal(unsubscribeCount, 1); -}); - -test('submit code forwards the code to the login manager without echoing it', async () => { - const response = await fetch(`${base}/api/agent-auth/login/code`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId: 'session-1', code: 'secret-code' }), - }); - assert.equal(response.status, 200); - const body = await response.json(); - assert.equal(body.ok, true); - assert.deepEqual(submitted[0], { - sessionId: 'session-1', - code: 'secret-code', - }); -}); diff --git a/server/test/agent-login.test.js b/server/test/agent-login.test.js deleted file mode 100644 index 408e655..0000000 --- a/server/test/agent-login.test.js +++ /dev/null @@ -1,195 +0,0 @@ -'use strict'; - -const assert = require('node:assert/strict'); -const { EventEmitter } = require('node:events'); -const fs = require('node:fs'); -const os = require('node:os'); -const path = require('node:path'); -const { test } = require('node:test'); - -const { - agyTokenPath, - createAgentLoginManager, - selectLoginUrl, - scriptCommand, -} = require('../lib/agent-login'); - -function fakeChild() { - const child = new EventEmitter(); - child.stdout = new EventEmitter(); - child.stderr = new EventEmitter(); - child.stdin = { - writable: true, - writes: [], - write(value) { - this.writes.push(value); - }, - }; - child.killedSignal = ''; - child.kill = (signal) => { - child.killedSignal = signal; - }; - return child; -} - -test('scriptCommand quotes login args for script -qfec', () => { - assert.equal( - scriptCommand(['codex', 'login', '--device-auth']), - "'codex' 'login' '--device-auth'", - ); -}); - -test('selectLoginUrl prefers auth URLs over incidental links', () => { - assert.deepEqual( - selectLoginUrl('codex', [ - 'https://docs.example.test/setup', - 'https://auth.openai.com/oauth/authorize?client_id=codex', - ]).url, - 'https://auth.openai.com/oauth/authorize?client_id=codex', - ); - assert.deepEqual( - selectLoginUrl('agy', [ - 'https://example.test/help', - 'https://accounts.google.com/o/oauth2/auth?client_id=agy.', - ]).url, - 'https://accounts.google.com/o/oauth2/auth?client_id=agy', - ); -}); - -test('login manager streams URL events and writes submitted code to PTY stdin', () => { - let spawned; - const child = fakeChild(); - const manager = createAgentLoginManager({ - commandExists: () => true, - randomUUID: () => 'login-1', - spawn(command, args, options) { - spawned = { command, args, options }; - return child; - }, - }); - - const session = manager.start('codex'); - const events = []; - manager.subscribe(session.id, (event) => events.push(event)); - - child.stdout.emit( - 'data', - 'Docs https://example.test/docs Open https://auth.openai.com/oauth/authorize?client_id=codex to continue\n', - ); - child.stderr.emit('data', 'Troubleshooting: https://example.test/help\n'); - manager.submitCode(session.id, 'abc123'); - child.emit('exit', 0); - - assert.equal(spawned.command, 'script'); - assert.deepEqual(spawned.args, [ - '-qfec', - "'codex' 'login' '--device-auth'", - '/dev/null', - ]); - assert.equal(spawned.options.stdio[0], 'pipe'); - assert.equal(child.stdin.writes[0], 'abc123\n'); - assert.ok(events.some((event) => event.type === 'login_started')); - assert.deepEqual( - events.find((event) => event.type === 'login_url').data.url, - 'https://auth.openai.com/oauth/authorize?client_id=codex', - ); - assert.equal( - events.filter((event) => event.type === 'login_url').at(-1).data.url, - 'https://auth.openai.com/oauth/authorize?client_id=codex', - ); - assert.ok(events.some((event) => event.type === 'login_done')); -}); - -test('agy login completes when the browser OAuth token file appears', async () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-agy-login-')); - const child = fakeChild(); - const manager = createAgentLoginManager({ - commandExists: () => true, - fs, - homeDir: home, - pollIntervalMs: 5, - randomUUID: () => 'agy-login-1', - spawn() { - return child; - }, - }); - const events = []; - const session = manager.start('agy'); - manager.subscribe(session.id, (event) => events.push(event)); - - child.stdout.emit( - 'data', - 'Open https://accounts.google.com/o/oauth2/auth?client_id=agy to continue\n', - ); - assert.equal( - events.find((event) => event.type === 'login_started').data.requiresCode, - false, - ); - assert.throws( - () => manager.submitCode(session.id, 'unused-code'), - /does not accept/, - ); - - fs.mkdirSync(path.dirname(agyTokenPath(home)), { recursive: true }); - fs.writeFileSync(agyTokenPath(home), 'oauth-token\n'); - await new Promise((resolve) => setTimeout(resolve, 20)); - - assert.deepEqual( - events.find((event) => event.type === 'login_url').data.url, - 'https://accounts.google.com/o/oauth2/auth?client_id=agy', - ); - assert.ok(events.some((event) => event.type === 'login_done')); - fs.rmSync(home, { recursive: true, force: true }); -}); - -test('login manager keeps a running login alive when the last listener disconnects', () => { - const child = fakeChild(); - const manager = createAgentLoginManager({ - commandExists: () => true, - randomUUID: () => 'login-disconnect', - spawn() { - return child; - }, - }); - - const session = manager.start('codex'); - const unsubscribe = manager.subscribe(session.id, () => {}); - unsubscribe(); - - // Disconnecting (e.g. the app backgrounded to authorize in a browser) must not - // kill the login; the CLI finishes the OAuth flow on its own. The session is - // only reaped later by the maxRunningMs timeout. - const status = manager.status(session.id); - assert.equal(child.killedSignal, ''); - assert.equal(status.status, 'running'); -}); - -test('login manager cleanup reaps expired running sessions', () => { - let now = 1000; - const child = fakeChild(); - const manager = createAgentLoginManager({ - commandExists: () => true, - maxRunningMs: 50, - now: () => now, - randomUUID: () => 'login-timeout', - spawn() { - return child; - }, - }); - - const session = manager.start('codex'); - now += 51; - manager.cleanup(); - - const status = manager.status(session.id); - assert.equal(child.killedSignal, 'SIGTERM'); - assert.equal(status.status, 'error'); - assert.match(status.error, /timed out/i); -}); - -test('login manager rejects unsupported and missing CLIs clearly', () => { - const manager = createAgentLoginManager({ commandExists: () => false }); - - assert.throws(() => manager.start('hermes'), /not supported/); - assert.throws(() => manager.start('codex'), /not installed/); -}); diff --git a/server/test/agent-options.test.js b/server/test/agent-options.test.js index 5d887bc..e877ed0 100644 --- a/server/test/agent-options.test.js +++ b/server/test/agent-options.test.js @@ -7,12 +7,15 @@ process.env.RELAY_MODEL_DISCOVERY = '0'; const assert = require('node:assert/strict'); const { test } = require('node:test'); +const fs = require('node:fs'); +const path = require('node:path'); const { buildArgs, normalizeSettings, describeAgent, defaultsFor, + acpSessionOptions, } = require('../lib/agent-options'); // --- defaultsFor ------------------------------------------------------------ @@ -26,13 +29,6 @@ test('defaultsFor derives the model from the newest catalog entry', () => { }); }); -test('defaultsFor derives the agy model from its catalog', () => { - assert.deepEqual(defaultsFor('agy'), { - model: 'gemini-3-5-flash-medium', - permission: 'sandbox', - }); -}); - test('Codex static fallback keeps effort choices valid for its pinned models', () => { assert.deepEqual(defaultsFor('codex'), { effort: 'medium', @@ -101,13 +97,6 @@ test('buildArgs falls back to defaults for forged/unknown option ids (no arg inj ]); }); -test('buildArgs maps agy model and permission to its CLI flags', () => { - assert.deepEqual(buildArgs('agy', {}), [ - '--model', 'Gemini 3.5 Flash (Medium)', - '--sandbox', - ]); -}); - test('buildArgs maps opencode model/effort/permission to its CLI flags', () => { // Default: model from the catalog + bypass permission (effort is opt-in). assert.deepEqual(buildArgs('opencode', {}), [ @@ -135,6 +124,64 @@ test('buildArgs returns [] for an unknown agent', () => { assert.deepEqual(buildArgs('nope', { permission: 'bypass' }), []); }); +// --- acpSessionOptions ------------------------------------------------------ +// opencode and hermes run as persistent ACP sessions, so their settings are +// resolved into protocol values instead of the argv above (which the two +// buildArgs cases still cover as the shared option table). + +test('acpSessionOptions resolves opencode defaults to a model and auto-approval', () => { + assert.deepEqual(acpSessionOptions('opencode', {}), { + modelId: 'opencode/big-pickle', + // opencode has no session mode matching its tiers. + modeId: null, + approve: true, + }); + assert.deepEqual( + acpSessionOptions('opencode', { model: 'opencode/mimo-v2.5-free', permission: 'ask' }), + { modelId: 'opencode/mimo-v2.5-free', modeId: null, approve: false }, + ); +}); + +test('acpSessionOptions maps hermes tiers to session modes', () => { + // yolo is the default tier: auto-approve, and the matching hermes mode. + assert.deepEqual(acpSessionOptions('hermes', {}), { + modelId: null, + modeId: 'dont_ask', + approve: true, + }); + assert.deepEqual(acpSessionOptions('hermes', { permission: 'cautious' }), { + modelId: null, + modeId: 'default', + approve: false, + }); +}); + +test('acpSessionOptions translates a pinned hermes model id to ACP form', () => { + // Hermes has no built-in catalog, so models-extra.json is the only way to + // pin one — and it is written in the CLI's `provider/model` form. + const extraFile = path.join(__dirname, '..', 'models-extra.json'); + const had = fs.existsSync(extraFile); + const backup = had ? fs.readFileSync(extraFile) : null; + try { + fs.writeFileSync( + extraFile, + JSON.stringify({ hermes: [{ id: 'anthropic/claude-sonnet-4' }] }), + ); + assert.equal( + acpSessionOptions('hermes', { model: 'anthropic/claude-sonnet-4' }).modelId, + 'anthropic:claude-sonnet-4', + ); + } finally { + if (had) fs.writeFileSync(extraFile, backup); + else fs.rmSync(extraFile, { force: true }); + } + // opencode ids are already ACP ids and must not be rewritten. + assert.equal( + acpSessionOptions('opencode', { model: 'opencode/big-pickle' }).modelId, + 'opencode/big-pickle', + ); +}); + // --- normalizeSettings ------------------------------------------------------ test('normalizeSettings keeps valid ids and repairs invalid ones to defaults', () => { @@ -144,11 +191,17 @@ test('normalizeSettings keeps valid ids and repairs invalid ones to defaults', ( ); }); -test('normalizeSettings keeps agy model and drops unsupported effort', () => { - const out = normalizeSettings('agy', { model: 'whatever', permission: 'sandbox' }); - assert.equal(out.model, 'gemini-3-5-flash-medium'); +test('normalizeSettings drops groups an agent does not support', () => { + // Hermes pins its model in host config and has no reasoning-effort flag, so + // only the permission tier survives normalization. + const out = normalizeSettings('hermes', { + model: 'whatever', + effort: 'high', + permission: 'yolo', + }); + assert.equal('model' in out, false); assert.equal('effort' in out, false); - assert.equal(out.permission, 'sandbox'); + assert.equal(out.permission, 'yolo'); }); // --- describeAgent ---------------------------------------------------------- @@ -169,9 +222,9 @@ test('describeAgent advertises supported groups and strips internal args', () => } } - const agy = describeAgent('agy'); - assert.deepEqual(agy.supports, { - model: true, + const hermes = describeAgent('hermes'); + assert.deepEqual(hermes.supports, { + model: false, effort: false, permission: true, fast: false, @@ -186,5 +239,5 @@ test('fast mode is explicit and limited to Claude Code and Codex', () => { assert.deepEqual(codexOn.slice(-2), ['-c', 'service_tier="fast"']); const codexOff = buildArgs('codex', { fast: 'off' }); assert.deepEqual(codexOff.slice(-2), ['-c', 'service_tier="default"']); - assert.equal(normalizeSettings('agy', { fast: 'on' }).fast, undefined); + assert.equal(normalizeSettings('opencode', { fast: 'on' }).fast, undefined); }); diff --git a/server/test/agent-status.test.js b/server/test/agent-status.test.js index 6b7cbe6..5180065 100644 --- a/server/test/agent-status.test.js +++ b/server/test/agent-status.test.js @@ -45,6 +45,14 @@ after(() => { } }); +// A JWT with only the `exp` claim, which is all the status reader decodes. +function jwt(expSeconds) { + const payload = Buffer.from(JSON.stringify({ exp: expSeconds })).toString( + 'base64url', + ); + return `header.${payload}.signature`; +} + test('detects installed CLI agents and credential files without exposing values', () => { const home = makeHome(); writeJson(path.join(home, '.claude', '.credentials.json'), { @@ -56,15 +64,6 @@ test('detects installed CLI agents and credential files without exposing values' writeJson(path.join(home, '.codex', 'auth.json'), { tokens: { access_token: 'codex-token' }, }); - writeText( - path.join( - home, - '.gemini', - 'antigravity-cli', - 'antigravity-oauth-token', - ), - 'agy-token\n', - ); writeJson(path.join(home, '.hermes', 'auth.json'), { provider: 'openai', apiKey: 'hermes-key', @@ -72,36 +71,75 @@ test('detects installed CLI agents and credential files without exposing values' const result = statuses( home, - new Set(['claude', 'codex', 'agy', 'opencode', 'hermes']), + new Set(['claude', 'codex', 'opencode', 'hermes']), ); assert.deepEqual(result.claude, { installed: true, authed: true, authKind: 'oauth', + credentialExpiresAt: null, }); assert.deepEqual(result.codex, { installed: true, authed: true, authKind: 'oauth', - }); - assert.deepEqual(result.agy, { - installed: true, - authed: true, - authKind: 'oauth', + credentialExpiresAt: null, }); assert.deepEqual(result.hermes, { installed: true, authed: true, authKind: 'apiKey', + credentialExpiresAt: null, }); assert.deepEqual(result.opencode, { installed: true, authed: true, authKind: 'apiKeyOptional', + credentialExpiresAt: null, }); }); +test('reports the OAuth credential expiry for claude and codex', () => { + const home = makeHome(); + writeJson(path.join(home, '.claude', '.credentials.json'), { + claudeAiOauth: { + accessToken: 'access-value', + refreshToken: 'refresh-value', + expiresAt: 1893456000000, + }, + }); + writeJson(path.join(home, '.codex', 'auth.json'), { + tokens: { access_token: 'codex-token', id_token: jwt(1893456789) }, + }); + + const result = statuses(home, new Set(['claude', 'codex'])); + + assert.equal(result.claude.credentialExpiresAt, 1893456000000); + assert.equal(result.codex.credentialExpiresAt, 1893456789000); +}); + +test('reports a null expiry when the credential carries no usable timestamp', () => { + const home = makeHome(); + writeJson(path.join(home, '.claude', '.credentials.json'), { + claudeAiOauth: { + accessToken: 'access-value', + refreshToken: 'refresh-value', + expiresAt: 'not-a-number', + }, + }); + writeJson(path.join(home, '.codex', 'auth.json'), { + tokens: { access_token: 'codex-token', id_token: 'not-a-jwt' }, + }); + + const result = statuses(home, new Set(['claude', 'codex'])); + + assert.equal(result.claude.authed, true); + assert.equal(result.claude.credentialExpiresAt, null); + assert.equal(result.codex.authed, true); + assert.equal(result.codex.credentialExpiresAt, null); +}); + test('requires the expected credential shape for each agent', () => { const home = makeHome(); writeJson(path.join(home, '.claude', '.credentials.json'), { @@ -110,27 +148,17 @@ test('requires the expected credential shape for each agent', () => { writeJson(path.join(home, '.codex', 'auth.json'), { tokens: {}, }); - writeText( - path.join( - home, - '.gemini', - 'antigravity-cli', - 'antigravity-oauth-token', - ), - ' ', - ); writeJson(path.join(home, '.hermes', 'auth.json'), { provider: 'openai', }); const result = statuses( home, - new Set(['claude', 'codex', 'agy', 'opencode', 'hermes']), + new Set(['claude', 'codex', 'opencode', 'hermes']), ); assert.equal(result.claude.authed, false); assert.equal(result.codex.authed, false); - assert.equal(result.agy.authed, false); assert.equal(result.hermes.authed, false); assert.equal(result.opencode.authed, true); }); diff --git a/server/test/agy-args.test.js b/server/test/agy-args.test.js deleted file mode 100644 index bb61e3b..0000000 --- a/server/test/agy-args.test.js +++ /dev/null @@ -1,98 +0,0 @@ -'use strict'; - -// Pin discovery off so requiring the module never shells out to the `agy` -// binary while building args. -process.env.RELAY_MODEL_DISCOVERY = '0'; - -const assert = require('node:assert/strict'); -const { test } = require('node:test'); - -const { buildAgyArgs } = require('../lib/agents'); - -const DEFAULT_MODEL = ['--model', 'Gemini 3.5 Flash (Medium)']; - -// The single invariant that the agy reply bug came down to: the prompt must be -// the VALUE of one `--print=` token, never a bare positional that agy ignores -// (and a bare `--print` must never exist, or it would swallow the next flag). -function assertPromptCarriedSafely(args, prompt) { - // Exactly one --print token, and it is the `=` form. - const printTokens = args.filter((a) => a === '--print' || a.startsWith('--print=')); - assert.deepEqual(printTokens, [`--print=${prompt}`], 'prompt must ride as a single --print= token'); - // No bare `--print` (which would consume the following flag as the prompt). - assert.equal(args.includes('--print'), false, 'no bare --print that could swallow the next flag'); - // It is the last token, so no later flag can be misparsed after it. - assert.equal(args[args.length - 1], `--print=${prompt}`, 'the --print= token must come last'); - // Round-trips: everything after the first '=' is the exact prompt. - const value = args[args.length - 1].slice('--print='.length); - assert.equal(value, prompt); -} - -test('default turn: prompt is the --print= value, --sandbox stays its own flag', () => { - const prompt = 'what is 17 plus 25?'; - const args = buildAgyArgs({ settings: {}, cwd: '/repo', conversationId: null, prompt }); - - assert.deepEqual(args, [ - ...DEFAULT_MODEL, - '--sandbox', - '--add-dir', - '/repo', - `--print=${prompt}`, - ]); - // The permission flag is intact and was NOT consumed as the prompt. - assert.ok(args.includes('--sandbox')); - assertPromptCarriedSafely(args, prompt); -}); - -test('resume turn: --conversation is present and the prompt still rides --print=', () => { - const prompt = 'continue please'; - const args = buildAgyArgs({ - settings: {}, - cwd: '/repo', - conversationId: 'conv-123', - prompt, - }); - - assert.deepEqual(args, [ - ...DEFAULT_MODEL, - '--sandbox', - '--add-dir', - '/repo', - '--conversation', - 'conv-123', - `--print=${prompt}`, - ]); - assertPromptCarriedSafely(args, prompt); -}); - -test('no conversationId: no --conversation flag is added', () => { - const args = buildAgyArgs({ settings: {}, cwd: '/repo', conversationId: null, prompt: 'hi' }); - assert.equal(args.includes('--conversation'), false); -}); - -test('a prompt starting with - stays inside the value, never parsed as a flag', () => { - const prompt = '--help me understand this repo'; - const args = buildAgyArgs({ settings: {}, cwd: '/repo', conversationId: null, prompt }); - - // The leading-dash prompt is one token, not a separate --help flag. - assert.equal(args.includes('--help'), false); - assertPromptCarriedSafely(args, prompt); -}); - -test('prompts with spaces, newlines, and = are preserved verbatim', () => { - const prompt = 'line one\nset x = 1 && echo "done"'; - const args = buildAgyArgs({ settings: {}, cwd: '/repo', conversationId: null, prompt }); - assertPromptCarriedSafely(args, prompt); -}); - -test('permission setting flows through buildArgs (bypass instead of sandbox)', () => { - const args = buildAgyArgs({ - settings: { permission: 'bypass' }, - cwd: '/repo', - conversationId: null, - prompt: 'go', - }); - assert.ok(args.includes('--dangerously-skip-permissions')); - assert.equal(args.includes('--sandbox'), false); - assert.deepEqual(args.slice(0, 2), DEFAULT_MODEL); - assertPromptCarriedSafely(args, 'go'); -}); diff --git a/server/test/agy-transcript.test.js b/server/test/agy-transcript.test.js deleted file mode 100644 index 369cc2e..0000000 --- a/server/test/agy-transcript.test.js +++ /dev/null @@ -1,113 +0,0 @@ -'use strict'; - -const assert = require('node:assert/strict'); -const { test } = require('node:test'); - -const { agyReplyFromTranscript } = require('../lib/agents'); - -function line(obj) { - return JSON.stringify(obj); -} - -test('agy transcript parsing returns the reply after the current prompt', () => { - const lines = [ - line({ - source: 'USER_EXPLICIT', - type: 'USER_INPUT', - content: 'old prompt', - }), - line({ - source: 'MODEL', - type: 'PLANNER_RESPONSE', - content: 'old answer', - }), - line({ - source: 'USER_EXPLICIT', - type: 'USER_INPUT', - content: 'current prompt', - }), - line({ - source: 'MODEL', - type: 'PLANNER_RESPONSE', - tool_calls: [{ name: 'LIST_DIRECTORY' }], - }), - line({ - source: 'MODEL', - type: 'PLANNER_RESPONSE', - content: 'current answer', - }), - ]; - - assert.equal(agyReplyFromTranscript(lines, 'current prompt'), 'current answer'); -}); - -test('agy transcript parsing does not return stale replies for a missing prompt', () => { - const lines = [ - line({ - source: 'USER_EXPLICIT', - type: 'USER_INPUT', - content: 'old prompt', - }), - line({ - source: 'MODEL', - type: 'PLANNER_RESPONSE', - content: 'old answer', - }), - ]; - - assert.equal(agyReplyFromTranscript(lines, 'current prompt'), ''); -}); - -test('agy transcript parsing falls back when the current prompt has no text reply', () => { - const lines = [ - line({ - source: 'USER_EXPLICIT', - type: 'USER_INPUT', - content: 'old prompt', - }), - line({ - source: 'MODEL', - type: 'PLANNER_RESPONSE', - content: 'old answer', - }), - line({ - source: 'USER_EXPLICIT', - type: 'USER_INPUT', - content: 'current prompt', - }), - line({ - source: 'MODEL', - type: 'PLANNER_RESPONSE', - tool_calls: [{ name: 'GREP_SEARCH' }], - }), - ]; - - assert.equal(agyReplyFromTranscript(lines, 'current prompt'), ''); -}); - -test('agy transcript parsing uses the latest matching user input', () => { - const lines = [ - line({ - source: 'USER_EXPLICIT', - type: 'USER_INPUT', - content: 'repeat', - }), - line({ - source: 'MODEL', - type: 'PLANNER_RESPONSE', - content: 'first answer', - }), - line({ - source: 'USER_EXPLICIT', - type: 'USER_INPUT', - content: 'repeat', - }), - line({ - source: 'MODEL', - type: 'PLANNER_RESPONSE', - content: 'second answer', - }), - ]; - - assert.equal(agyReplyFromTranscript(lines, 'repeat'), 'second answer'); -}); diff --git a/server/test/btw.test.js b/server/test/btw.test.js deleted file mode 100644 index 52bef6f..0000000 --- a/server/test/btw.test.js +++ /dev/null @@ -1,278 +0,0 @@ -'use strict'; - -const assert = require('node:assert/strict'); -const { test } = require('node:test'); - -const createBtwRouter = require('../routes/btw'); - -// Deterministic scope-key shape so tests can assert exact keys. -const scopeKeyFor = (agentKey, workdir, sessionId) => - `${agentKey}|${workdir}|${sessionId}`; - -// A minimal Express-style response that records status/json. -function fakeResponse() { - return { - statusCode: 200, - jsonBody: null, - status(code) { - this.statusCode = code; - return this; - }, - json(body) { - this.jsonBody = body; - return this; - }, - }; -} - -// Pull a single route handler out of the router's layer stack. -function handlerFor(router, method, path) { - const layer = router.stack.find( - (l) => l.route && l.route.path === path && l.route.methods[method], - ); - if (!layer) throw new Error(`no ${method} ${path} route`); - return layer.route.stack[0].handle; -} - -// Builds a ctx whose resolveAgentScope canonicalizes the requested session id to -// a *different* id, so a route that keys off the raw request id (the old bug) -// would target a different scope than resolveAgentScope produced. -function makeCtx(overrides = {}) { - const cleared = { sessions: [], histories: [] }; - const reads = []; - const finalized = []; - const ctx = { - runningScopes: overrides.runningScopes || new Set(), - scopeChains: overrides.scopeChains || new Map(), - scopeKeyFor, - resolveAgentScope: (req, res, { agentKey }) => ({ - agent: { key: agentKey }, - workdir: '/repo', - contextKey: `ctx:${agentKey}:/repo`, - // Canonical id differs from whatever the client requested. - session: { id: 'sess-canonical' }, - scopeKey: scopeKeyFor(agentKey, '/repo', 'sess-canonical'), - }), - MAX_PROMPT_BYTES: 1024 * 1024, - activeRequests: new Map(), - agentTurnDependencies: () => ({ - runAgent() { - throw new Error('base runAgent should not be called by /api/btw'); - }, - }), - createChatResponder: () => ({}), - clearSession: (key) => { - cleared.sessions.push(key); - return true; - }, - clearHistory: (key) => { - cleared.histories.push(key); - }, - finalizeStaleStreamingHistory: (key) => finalized.push(key), - normalizeDeviceId: () => 'device-test', - randomUUID: () => 'request-generated', - readHistory: (key) => { - reads.push(key); - return []; - }, - runAgentTurn: async () => {}, - runBtwAgent() { - throw new Error('runBtwAgent was not stubbed'); - }, - sessionPayload: (session) => session, - ...overrides.ctx, - }; - return { ctx, cleared, reads, finalized }; -} - -test('btw clear targets the canonical session scope, not the raw requested id', async () => { - const { ctx, cleared } = makeCtx(); - const router = createBtwRouter(ctx); - const clear = handlerFor(router, 'post', '/api/btw/clear'); - - const res = fakeResponse(); - // Client sends a session id that resolveAgentScope canonicalizes differently. - await clear({ body: { agent: 'claude', sessionId: 'sess-requested' } }, res); - - assert.equal(res.jsonBody.ok, true); - const expectedKey = scopeKeyFor('btw:claude', '/repo', 'sess-canonical'); - assert.deepEqual(cleared.sessions, [expectedKey]); - assert.deepEqual(cleared.histories, [expectedKey]); - // Regression guard: never key off the raw request id. - assert.ok( - !cleared.sessions.some((k) => k.includes('sess-requested')), - 'clear must not use the raw requested session id', - ); -}); - -test('btw clear and history resolve to the same side-chat scope key', async () => { - const { ctx, cleared, reads } = makeCtx(); - const router = createBtwRouter(ctx); - const clear = handlerFor(router, 'post', '/api/btw/clear'); - const history = handlerFor(router, 'get', '/api/btw/history'); - - await clear({ body: { agent: 'claude', sessionId: 'sess-requested' } }, fakeResponse()); - await history( - { query: { agent: 'claude', sessionId: 'sess-requested' } }, - fakeResponse(), - ); - - // The key history read from must equal the key clear wiped. - assert.equal(cleared.sessions.length, 1); - assert.equal(reads.length, 1); - assert.equal(reads[0], cleared.sessions[0]); -}); - -test('btw clear uses an agent-specific side scope for codex', async () => { - const { ctx, cleared } = makeCtx(); - const router = createBtwRouter(ctx); - const clear = handlerFor(router, 'post', '/api/btw/clear'); - - const res = fakeResponse(); - await clear({ body: { agent: 'codex', sessionId: 'sess-requested' } }, res); - - assert.equal(res.jsonBody.ok, true); - assert.deepEqual(cleared.sessions, [ - scopeKeyFor('btw:codex', '/repo', 'sess-canonical'), - ]); -}); - -test('btw post uses the native agent side runner for codex without transcript seeding', async () => { - const calls = []; - const { ctx } = makeCtx({ - ctx: { - runBtwAgent: (agentKey, prompt, _onEvent, options) => { - calls.push({ agentKey, prompt, options }); - return 'side answer'; - }, - runAgentTurn: async (options) => { - await options.dependencies.runAgent( - options.agentKey, - options.prompt, - () => {}, - { - sessionKey: options.scopeKey, - signal: options.signal, - workdir: options.workdir, - settings: { permission: 'workspace-write' }, - }, - ); - }, - }, - }); - const router = createBtwRouter(ctx); - const post = handlerFor(router, 'post', '/api/btw'); - - await post( - { - body: { - agent: 'codex', - prompt: 'what did the main task decide?', - requestId: 'req-1', - sessionId: 'sess-requested', - }, - get: () => '', - }, - fakeResponse(), - ); - - assert.equal(calls.length, 1); - assert.equal(calls[0].agentKey, 'codex'); - assert.equal(calls[0].prompt, 'what did the main task decide?'); - assert.equal( - calls[0].options.mainSessionKey, - scopeKeyFor('codex', '/repo', 'sess-canonical'), - ); - assert.equal( - calls[0].options.btwSessionKey, - scopeKeyFor('btw:codex', '/repo', 'sess-canonical'), - ); - assert.equal(calls[0].options.settings.permission, 'workspace-write'); - assert.ok( - !calls[0].prompt.includes('Main chat transcript'), - 'Codex BTW must not be seeded with a Relay transcript prompt', - ); -}); - -test('btw post uses the native agent side runner for agy without transcript seeding', async () => { - const calls = []; - const { ctx } = makeCtx({ - ctx: { - runBtwAgent: (agentKey, prompt, _onEvent, options) => { - calls.push({ agentKey, prompt, options }); - return 'side answer'; - }, - runAgentTurn: async (options) => { - await options.dependencies.runAgent( - options.agentKey, - options.prompt, - () => {}, - { - sessionKey: options.scopeKey, - signal: options.signal, - workdir: options.workdir, - settings: { permission: 'sandbox' }, - }, - ); - }, - }, - }); - const router = createBtwRouter(ctx); - const post = handlerFor(router, 'post', '/api/btw'); - - await post( - { - body: { - agent: 'agy', - prompt: 'side question', - requestId: 'req-agy', - sessionId: 'sess-requested', - }, - get: () => '', - }, - fakeResponse(), - ); - - assert.equal(calls.length, 1); - assert.equal(calls[0].agentKey, 'agy'); - assert.equal(calls[0].prompt, 'side question'); - assert.equal( - calls[0].options.mainSessionKey, - scopeKeyFor('agy', '/repo', 'sess-canonical'), - ); - assert.equal( - calls[0].options.btwSessionKey, - scopeKeyFor('btw:agy', '/repo', 'sess-canonical'), - ); - assert.equal(calls[0].options.settings.permission, 'sandbox'); - assert.ok(!calls[0].prompt.includes('Main chat transcript')); -}); - -test('btw clear refuses while a side question is running', async () => { - const runningScopes = new Set([ - scopeKeyFor('btw:claude', '/repo', 'sess-canonical'), - ]); - const { ctx, cleared } = makeCtx({ runningScopes }); - const router = createBtwRouter(ctx); - const clear = handlerFor(router, 'post', '/api/btw/clear'); - - const res = fakeResponse(); - await clear({ body: { agent: 'claude', sessionId: 'sess-requested' } }, res); - - assert.equal(res.statusCode, 409); - assert.equal(res.jsonBody.code, 'SESSION_BUSY'); - assert.deepEqual(cleared.sessions, [], 'nothing cleared while running'); -}); - -test('btw clear rejects unsupported agents before touching any scope', async () => { - const { ctx, cleared } = makeCtx(); - const router = createBtwRouter(ctx); - const clear = handlerFor(router, 'post', '/api/btw/clear'); - - const res = fakeResponse(); - await clear({ body: { agent: 'opencode', sessionId: 'sess-requested' } }, res); - - assert.equal(res.statusCode, 400); - assert.equal(res.jsonBody.code, 'BTW_UNSUPPORTED'); - assert.deepEqual(cleared.sessions, []); -}); diff --git a/server/test/claude-session-pool.test.js b/server/test/claude-session-pool.test.js new file mode 100644 index 0000000..63a60a5 --- /dev/null +++ b/server/test/claude-session-pool.test.js @@ -0,0 +1,318 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); + +const { createClaudeSessionPool } = require('../lib/claude-session-pool'); + +// A stand-in for the Agent SDK: one fake process per query(), echoing each +// prompt pushed into the streaming input. +function fakeSdk() { + const spawned = []; + const deleted = []; + let hangNext = false; + let hangAll = false; + + function query({ prompt, options }) { + const session = { + options, + prompts: [], + closed: false, + interrupted: 0, + hang: hangNext || hangAll, + sessionId: options.resume || `sess-${spawned.length + 1}`, + }; + hangNext = false; + spawned.push(session); + const iterator = (async function* run() { + for await (const message of prompt) { + const text = String(message.message.content); + session.prompts.push(text); + yield { type: 'system', subtype: 'init', session_id: session.sessionId }; + if (session.hang) { + // A long turn that only finishes when interrupted, like the CLI + // winding down on ESC. + await new Promise((resolve) => { + session.release = resolve; + }); + } + yield { + type: 'assistant', + session_id: session.sessionId, + message: { id: 'm1', content: [{ type: 'text', text: `echo:${text}` }] }, + }; + if (session.dieAfterAssistant) throw new Error('cli exited'); + yield { + type: 'result', + subtype: 'success', + session_id: session.sessionId, + result: `echo:${text}`, + }; + } + })(); + return { + [Symbol.asyncIterator]: () => iterator, + close() { + session.closed = true; + iterator.return(); + }, + async interrupt() { + session.interrupted += 1; + session.hang = false; + if (session.release) session.release(); + }, + }; + } + + return { + spawned, + deleted, + hangNextTurn() { + hangNext = true; + }, + hangEveryTurn() { + hangAll = true; + }, + releaseAll() { + hangAll = false; + for (const session of spawned) { + session.hang = false; + if (session.release) session.release(); + } + }, + query, + async deleteSession(id, opts) { + deleted.push({ id, dir: opts && opts.dir }); + }, + }; +} + +function makePool(sdk, options = {}) { + return createClaudeSessionPool({ sdk, env: {}, ...options }); +} + +function send(pool, key, prompt, extra = {}) { + return pool.send({ + key, + prompt, + cwd: '/w', + sdkOptions: { model: 'm' }, + optionsKey: 'k1', + onMessage: () => {}, + ...extra, + }); +} + +test('a second turn reuses the live session instead of spawning again', async () => { + const sdk = fakeSdk(); + const pool = makePool(sdk); + const first = await send(pool, 'a', 'one'); + const second = await send(pool, 'a', 'two'); + assert.equal(sdk.spawned.length, 1, 'one process for both turns'); + assert.deepEqual(sdk.spawned[0].prompts, ['one', 'two']); + assert.equal(first.result.result, 'echo:one'); + assert.equal(second.result.result, 'echo:two'); + assert.equal(second.sessionId, 'sess-1'); + await pool.shutdown(); +}); + +test('changed settings restart the process and resume the same conversation', async () => { + const sdk = fakeSdk(); + const pool = makePool(sdk); + await send(pool, 'a', 'one'); + await send(pool, 'a', 'two', { optionsKey: 'k2', sdkOptions: { model: 'other' } }); + assert.equal(sdk.spawned.length, 2); + assert.equal(sdk.spawned[0].closed, true, 'old process closed'); + assert.equal( + sdk.spawned[1].options.resume, + 'sess-1', + 'restart resumes the conversation the user was in', + ); + await pool.shutdown(); +}); + +test('a cold start resumes the stored session id', async () => { + const sdk = fakeSdk(); + const pool = makePool(sdk); + await send(pool, 'a', 'one', { resumeId: 'stored-id' }); + assert.equal(sdk.spawned[0].options.resume, 'stored-id'); + await pool.shutdown(); +}); + +test('the live-process cap evicts the least recently used idle session', async () => { + const sdk = fakeSdk(); + const pool = makePool(sdk, { maxLive: 2 }); + await send(pool, 'a', 'one'); + await send(pool, 'b', 'one'); + assert.equal(pool.stats().live, 2); + await send(pool, 'c', 'one'); + assert.equal(pool.stats().live, 2, 'never exceeds the cap'); + assert.equal(sdk.spawned[0].closed, true, 'oldest idle session evicted'); + assert.equal(sdk.spawned[1].closed, false); + await pool.shutdown(); +}); + +test('a turn blocked on the cap runs once another turn finishes', async () => { + // Group chats summon several members at once, so more concurrent turns than + // live slots is a normal state, not an error. + const sdk = fakeSdk(); + const pool = makePool(sdk, { maxLive: 1 }); + sdk.hangNextTurn(); + const busy = send(pool, 'a', 'slow'); + await new Promise((resolve) => setTimeout(resolve, 10)); + + let blockedDone = false; + const blocked = send(pool, 'b', 'queued').then((value) => { + blockedDone = true; + return value; + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(blockedDone, false, 'waits while the only slot is busy'); + assert.equal(pool.stats().waiting, 1); + + sdk.spawned[0].release(); + await busy; + const result = await blocked; + assert.equal(result.result.result, 'echo:queued'); + assert.equal(sdk.spawned[0].closed, true, 'the finished session made room'); + assert.equal(pool.stats().live, 1); + await pool.shutdown(); +}); + +test('an idle session is closed after the idle timeout', async () => { + const sdk = fakeSdk(); + const pool = makePool(sdk, { idleMs: 40 }); + await send(pool, 'a', 'one'); + assert.equal(pool.stats().live, 1); + await new Promise((resolve) => setTimeout(resolve, 15)); + assert.equal(pool.stats().live, 1, 'still live before the timeout'); + await new Promise((resolve) => setTimeout(resolve, 60)); + assert.equal(pool.stats().live, 0, 'evicted once idle'); + assert.equal(sdk.spawned[0].closed, true); + + // The conversation is unaffected: the next turn cold-starts and resumes. + await send(pool, 'a', 'two', { resumeId: 'sess-1' }); + assert.equal(sdk.spawned.length, 2); + assert.equal(sdk.spawned[1].options.resume, 'sess-1'); + await pool.shutdown(); +}); + +test('cancelling a turn interrupts it and rejects, leaving the session usable', async () => { + const sdk = fakeSdk(); + const pool = makePool(sdk, { interruptGraceMs: 10_000 }); + const controller = new AbortController(); + sdk.hangNextTurn(); + const pending = send(pool, 'a', 'slow', { signal: controller.signal }); + await new Promise((resolve) => setTimeout(resolve, 10)); + controller.abort(); + await assert.rejects(pending, (err) => err.code === 'AGENT_CANCELLED'); + assert.equal(sdk.spawned[0].interrupted, 1, 'interrupt, not kill'); + assert.equal(sdk.spawned[0].closed, false, 'session survives the cancel'); + await pool.shutdown(); +}); + +test('an already-aborted signal rejects without sending a prompt', async () => { + const sdk = fakeSdk(); + const pool = makePool(sdk); + const controller = new AbortController(); + controller.abort(); + await assert.rejects( + send(pool, 'a', 'never', { signal: controller.signal }), + (err) => err.code === 'AGENT_CANCELLED', + ); + assert.deepEqual(sdk.spawned[0].prompts, []); + await pool.shutdown(); +}); + +test('forget with purge closes the session and deletes its transcript', async () => { + const sdk = fakeSdk(); + const pool = makePool(sdk); + await send(pool, 'a', 'one'); + const purged = await pool.forget('a', { + purge: true, + sessionId: 'sess-1', + cwd: '/w', + }); + assert.equal(purged, true); + assert.equal(sdk.spawned[0].closed, true); + assert.deepEqual(sdk.deleted, [{ id: 'sess-1', dir: '/w' }]); + assert.equal(pool.stats().live, 0); +}); + +test('forget without purge drops the process but keeps the transcript', async () => { + const sdk = fakeSdk(); + const pool = makePool(sdk); + await send(pool, 'a', 'one'); + await pool.forget('a'); + assert.equal(sdk.spawned[0].closed, true); + assert.deepEqual(sdk.deleted, []); +}); + +test('result messages settle the turn and are not replayed as progress', async () => { + const sdk = fakeSdk(); + const pool = makePool(sdk); + const seen = []; + await send(pool, 'a', 'one', { onMessage: (m) => seen.push(m.type) }); + assert.deepEqual(seen, ['system', 'assistant']); + await pool.shutdown(); +}); + +test('a dead warm session is retried cold rather than failing the turn', async () => { + const sdk = fakeSdk(); + const pool = makePool(sdk); + await send(pool, 'a', 'one'); + // Simulate the CLI exiting between turns: the reader loop ends and the pool + // drops the entry, so the next turn has to cold-start. + sdk.spawned[0].closed = true; + await pool.forget('a'); + const second = await send(pool, 'a', 'two', { resumeId: 'sess-1' }); + assert.equal(sdk.spawned.length, 2); + assert.equal(second.result.result, 'echo:two'); + await pool.shutdown(); +}); + +test('concurrent cold starts never exceed the live-process cap', async () => { + const sdk = fakeSdk(); + const pool = makePool(sdk, { maxLive: 2 }); + sdk.hangEveryTurn(); + const a = send(pool, 'a', 'one'); + const b = send(pool, 'b', 'two'); + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(pool.stats().live, 2); + assert.equal(sdk.spawned.length, 2, 'the third caller did not slip past the cap'); + + const blocked = send(pool, 'c', 'three'); + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(sdk.spawned.length, 2); + assert.equal(pool.stats().waiting, 1); + + sdk.releaseAll(); + await Promise.all([a, b, blocked]); + assert.equal(sdk.spawned.length, 3, 'the blocked caller ran after a slot freed'); + assert.ok(pool.stats().live <= 2, 'still within the cap'); + await pool.shutdown(); +}); + +test('a warm session that dies mid-reply is not silently re-run', async () => { + // Retrying would replay text the user already saw. + const sdk = fakeSdk(); + const pool = makePool(sdk); + await send(pool, 'a', 'one'); + sdk.spawned[0].dieAfterAssistant = true; + await assert.rejects( + send(pool, 'a', 'two'), + (err) => err.code === 'CLAUDE_SESSION_LOST' && err.emitted === true, + ); + assert.equal(sdk.spawned.length, 1, 'no hidden retry'); + await pool.shutdown(); +}); + +test('shutdown closes every live session', async () => { + const sdk = fakeSdk(); + const pool = makePool(sdk); + await send(pool, 'a', 'one'); + await send(pool, 'b', 'one'); + await pool.shutdown(); + assert.equal(pool.stats().live, 0); + assert.ok(sdk.spawned.every((s) => s.closed)); +}); diff --git a/server/test/codex-session-pool.test.js b/server/test/codex-session-pool.test.js new file mode 100644 index 0000000..eaa055c --- /dev/null +++ b/server/test/codex-session-pool.test.js @@ -0,0 +1,245 @@ +'use strict'; + +// The pool mechanics (cap, idle eviction, waiters, process lifecycle) live in +// stdio-agent-pool.js and are covered by acp-session-pool.test.js. This file +// covers what is specific to codex's app-server protocol. +const { test, after } = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { createCodexSessionPool } = require('../lib/codex-session-pool'); + +const AGENT = path.join(__dirname, 'fixtures', 'fake-codex-agent.js'); + +// A failing assertion skips the test's own cleanup, and a live agent process +// keeps the runner from exiting — which hides the failure behind a hang. +const opened = []; +after(async () => { + for (const cleanup of opened) await cleanup(); +}); + +function makePool(options = {}) { + const statePath = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), 'relay-codex-')), + 'state.log', + ); + fs.writeFileSync(statePath, ''); + const childEnv = { FAKE_CODEX_STATE: statePath, ...(options.agentEnv || {}) }; + const pool = createCodexSessionPool({ + agentKey: 'fake-codex', + env: {}, + command: () => ({ cmd: process.execPath, args: [AGENT] }), + ...options, + }); + const previous = {}; + for (const [key, value] of Object.entries(childEnv)) { + previous[key] = process.env[key]; + process.env[key] = value; + } + const done = async () => { + await pool.shutdown(); + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }; + opened.push(done); + return { + pool, + state: () => fs.readFileSync(statePath, 'utf8').split('\n').filter(Boolean), + done, + }; +} + +function send(pool, key, prompt, extra = {}) { + return pool.send({ key, prompt, cwd: '/w', onMessage: () => {}, ...extra }); +} + +function textOf(events) { + return events + .filter((e) => e.type === 'delta') + .map((e) => e.text) + .join(''); +} + +async function waitFor(check, timeoutMs = 2000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (check()) return true; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + return false; +} + +test('a turn is settled by turn/completed, not by the turn/start response', async () => { + // This is the shape that separates codex from ACP: turn/start returns as soon + // as the turn is accepted, so the reply arrives afterwards on the stream. + const { pool, state, done } = makePool(); + const events = []; + const first = await send(pool, 'a', 'one', { onMessage: (e) => events.push(e) }); + assert.equal(first.result.stopReason, 'completed'); + assert.equal(textOf(events), 'echo:one'); + // The command item reaches the runner so it can render a progress label. + assert.deepEqual( + events.filter((e) => e.type === 'item').map((e) => e.item.type), + ['commandExecution'], + ); + + const second = await send(pool, 'a', 'two'); + assert.equal(second.sessionId, first.sessionId, 'same thread'); + assert.equal(state().filter((l) => l.startsWith('start ')).length, 1); + await done(); +}); + +test('a cold start resumes the stored thread id', async () => { + const { pool, state, done } = makePool(); + const result = await send(pool, 'a', 'one', { resumeId: 'stored-thread' }); + assert.ok(state().some((l) => l.startsWith('resume stored-thread /w'))); + assert.equal(result.sessionId, 'stored-thread'); + assert.equal(result.startedNew, false); + await done(); +}); + +test('an unresumable thread falls back to a new one and says so', async () => { + const { pool, state, done } = makePool({ + agentEnv: { FAKE_CODEX_NO_RESUME: '1' }, + }); + const result = await send(pool, 'a', 'one', { resumeId: 'gone' }); + assert.notEqual(result.sessionId, 'gone'); + assert.equal(result.startedNew, true); + assert.ok(state().some((l) => l.startsWith('start '))); + await done(); +}); + +test('model and effort ride along on every turn, with no reopen', async () => { + const { pool, state, done } = makePool(); + await send(pool, 'a', 'one', { model: 'gpt-x', effort: 'high' }); + await send(pool, 'a', 'two', { model: 'gpt-y', effort: 'low' }); + const turns = state().filter((l) => l.startsWith('turn ')); + assert.ok(turns[0].endsWith('model=gpt-x effort=high')); + assert.ok(turns[1].endsWith('model=gpt-y effort=low')); + // Changing them costs nothing: one thread, one process. + assert.equal(state().filter((l) => l.startsWith('start ')).length, 1); + assert.equal(state().filter((l) => l.startsWith('spawn ')).length, 1); + await done(); +}); + +test('a sandbox change reopens the thread but resumes the same conversation', async () => { + // The sandbox is fixed when a thread opens, so it is the one setting the + // runner passes as fixedKey. + const { pool, state, done } = makePool(); + const first = await send(pool, 'a', 'one', { + sandbox: 'read-only', + fixedKey: 'read-only', + }); + const second = await send(pool, 'a', 'two', { + sandbox: 'workspace-write', + fixedKey: 'workspace-write', + }); + assert.equal(second.sessionId, first.sessionId, 'same conversation'); + assert.ok(state().includes(`unsubscribe ${first.sessionId}`)); + assert.ok( + state().some((l) => + l.startsWith(`resume ${first.sessionId} /w sandbox=workspace-write`), + ), + 'reopened with the new sandbox', + ); + assert.equal(state().filter((l) => l.startsWith('spawn ')).length, 1); + await done(); +}); + +test('cancelling interrupts the accepted turn and leaves the thread usable', async () => { + const { pool, state, done } = makePool(); + const controller = new AbortController(); + const pending = send(pool, 'a', 'hang', { signal: controller.signal }); + // The turn id only exists once the turn is accepted, and it is the only + // thing that can be interrupted. + await waitFor(() => state().some((l) => l.startsWith('accepted '))); + controller.abort(); + await assert.rejects(pending, (err) => err.code === 'AGENT_CANCELLED'); + assert.ok(state().some((l) => l.startsWith('interrupt '))); + + const next = await send(pool, 'a', 'after'); + assert.equal(next.result.stopReason, 'completed'); + assert.equal(state().filter((l) => l.startsWith('start ')).length, 1); + await done(); +}); + +test('cancelling before the turn is accepted still interrupts it', async () => { + // turn/start returns asynchronously, so a fast cancel lands while there is + // nothing to interrupt yet. Doing nothing would leave codex running the turn + // until the pool gave up and dropped the whole session. + const { pool, state, done } = makePool({ cancelGraceMs: 30_000 }); + const controller = new AbortController(); + const pending = send(pool, 'a', 'hang-slow', { signal: controller.signal }); + await waitFor(() => state().some((l) => l.includes(' hang-slow'))); + assert.ok( + !state().some((l) => l.startsWith('accepted ')), + 'cancelled before acceptance', + ); + controller.abort(); + await assert.rejects(pending, (err) => err.code === 'AGENT_CANCELLED'); + assert.ok( + await waitFor(() => state().some((l) => l.startsWith('interrupt '))), + 'interrupted as soon as the turn id arrived', + ); + await done(); +}); + +test('a retryable error is not treated as a failed turn', async () => { + const { pool, done } = makePool(); + const events = []; + const result = await send(pool, 'a', 'retry', { + onMessage: (e) => events.push(e), + }); + assert.equal(result.result.stopReason, 'completed'); + assert.equal(textOf(events), 'recovered'); + await done(); +}); + +test('a non-retryable error fails the turn', async () => { + const { pool, done } = makePool(); + await assert.rejects(send(pool, 'a', 'boom'), /codex blew up/); + await done(); +}); + +test('approval requests are answered with codex vocabulary', async () => { + const { pool, state, done } = makePool(); + const approved = []; + await send(pool, 'a', 'perm', { + onMessage: (e) => approved.push(e), + onPermission: () => true, + }); + assert.ok(state().includes('approval accept'), 'not the ACP or legacy token'); + assert.equal(textOf(approved), 'approval:accept'); + + // Refusing uses the decline token, and no policy at all still answers. + await send(pool, 'b', 'perm', { onPermission: () => false }); + assert.ok(state().includes('approval decline')); + await send(pool, 'c', 'perm'); + assert.equal(state().filter((l) => l === 'approval decline').length, 2); + await done(); +}); + +test('purge deletes the thread in-protocol, with no CLI to shell out to', async () => { + const { pool, state, done } = makePool(); + const first = await send(pool, 'a', 'one'); + const purged = await pool.forget('a', { purge: true, sessionId: first.sessionId }); + assert.equal(purged, true); + assert.ok(state().includes(`delete ${first.sessionId}`)); + assert.equal(pool.stats().live, 0); + await done(); +}); + +test('purge works with no live process, opening one just to delete', async () => { + const { pool, state, done } = makePool(); + assert.equal(pool.stats().connected, false); + const purged = await pool.forget('gone', { purge: true, sessionId: 'th-old' }); + assert.equal(purged, true); + assert.ok(state().includes('delete th-old')); + // And it does not leave the process behind afterwards. + assert.equal(pool.stats().connected, false); + await done(); +}); diff --git a/server/test/filesystem.test.js b/server/test/filesystem.test.js new file mode 100644 index 0000000..2c5ee50 --- /dev/null +++ b/server/test/filesystem.test.js @@ -0,0 +1,344 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { after, test } = require('node:test'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +// The file API is the boundary SECURITY.md promises: a leaked device token must +// not be able to read tokens/CLI credentials through it, and RELAY_FS_ROOTS must +// actually narrow the reachable filesystem. + +const modulePath = require.resolve('../lib/filesystem'); +// realpath so comparisons hold on hosts where the temp dir is itself a link +// (macOS /var -> /private/var). +const scratchRoot = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'relay-fs-test-')), +); + +after(() => fs.rmSync(scratchRoot, { recursive: true, force: true })); + +// filesystem.js reads RELAY_FS_ROOTS and the home directory once, at load time, +// so each policy variant needs its own freshly loaded copy of the module. +function loadFilesystem({ roots, home } = {}) { + const prev = { + roots: process.env.RELAY_FS_ROOTS, + home: process.env.HOME, + userProfile: process.env.USERPROFILE, + }; + const restore = (key, value) => { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + }; + if (roots === undefined) delete process.env.RELAY_FS_ROOTS; + else process.env.RELAY_FS_ROOTS = roots; + if (home !== undefined) { + process.env.HOME = home; + process.env.USERPROFILE = home; + } + delete require.cache[modulePath]; + try { + return require('../lib/filesystem'); + } finally { + delete require.cache[modulePath]; + restore('RELAY_FS_ROOTS', prev.roots); + restore('HOME', prev.home); + restore('USERPROFILE', prev.userProfile); + } +} + +let caseCounter = 0; +function scratchCase() { + caseCounter += 1; + const dir = path.join(scratchRoot, `case-${caseCounter}`); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +// A stand-in home directory holding the credential files the denylist names. +function fakeHome() { + const home = path.join(scratchCase(), 'home'); + fs.mkdirSync(path.join(home, '.ssh'), { recursive: true }); + fs.writeFileSync(path.join(home, '.ssh', 'id_ed25519'), 'private-key'); + fs.mkdirSync(path.join(home, '.claude'), { recursive: true }); + fs.writeFileSync(path.join(home, '.claude', '.credentials.json'), '{}'); + fs.mkdirSync(path.join(home, '.codex'), { recursive: true }); + fs.writeFileSync(path.join(home, '.codex', 'auth.json'), '{}'); + return home; +} + +// Run fn, asserting it rejects, and return the thrown error. +async function rejects(fn) { + try { + await fn(); + } catch (err) { + return err; + } + throw new assert.AssertionError({ message: 'expected the call to reject' }); +} + +// --- denylist: listing, download, and upload alike --------------------------- + +test('listing a denied directory is refused', async () => { + const home = fakeHome(); + const { listAbsoluteDirectory } = loadFilesystem({ home }); + const err = await rejects(() => listAbsoluteDirectory(path.join(home, '.ssh'))); + assert.equal(err.code, 'FS_PATH_RESTRICTED'); + assert.equal(err.status, 403); +}); + +test('downloading a denied CLI credential file is refused', async () => { + const home = fakeHome(); + const { prepareDownloadAbsolute } = loadFilesystem({ home }); + for (const denied of [ + path.join(home, '.claude', '.credentials.json'), + path.join(home, '.codex', 'auth.json'), + path.join(home, '.ssh', 'id_ed25519'), + ]) { + const err = await rejects(() => prepareDownloadAbsolute(denied)); + assert.equal(err.code, 'FS_PATH_RESTRICTED', denied); + } +}); + +test('the atomic-write temp file beside a denied path is refused too', async () => { + const home = fakeHome(); + const tmpTwin = path.join(home, '.codex', 'auth.json.tmp'); + fs.writeFileSync(tmpTwin, '{}'); + const { prepareDownloadAbsolute } = loadFilesystem({ home }); + const err = await rejects(() => prepareDownloadAbsolute(tmpTwin)); + assert.equal(err.code, 'FS_PATH_RESTRICTED'); +}); + +test('uploading into a denied directory is refused', () => { + const home = fakeHome(); + const { resolveAbsoluteUploadTarget } = loadFilesystem({ home }); + assert.throws( + () => resolveAbsoluteUploadTarget(path.join(home, '.ssh'), 'authorized_keys'), + (err) => err.code === 'FS_PATH_RESTRICTED', + ); +}); + +test('a directory download containing a denied path is refused', async () => { + const home = fakeHome(); + const { prepareDownloadAbsolute } = loadFilesystem({ home }); + // ~/.codex itself is not on the denylist, but zipping it would carry + // auth.json out with it. + const err = await rejects(() => prepareDownloadAbsolute(path.join(home, '.codex'))); + assert.equal(err.code, 'FS_PATH_RESTRICTED'); +}); + +test('ordinary paths outside the denylist stay reachable', async () => { + const home = fakeHome(); + const project = path.join(home, 'project'); + fs.mkdirSync(project); + fs.writeFileSync(path.join(project, 'notes.md'), 'hello'); + const { listAbsoluteDirectory, prepareDownloadAbsolute } = loadFilesystem({ home }); + + const listing = await listAbsoluteDirectory(project); + assert.deepEqual( + listing.entries.map((entry) => entry.name), + ['notes.md'], + ); + + const download = await prepareDownloadAbsolute(path.join(project, 'notes.md')); + assert.equal(download.isDirectory, false); + assert.equal(download.filename, 'notes.md'); + assert.equal(download.totalBytes, 5); +}); + +// --- RELAY_FS_ROOTS ---------------------------------------------------------- + +test('RELAY_FS_ROOTS refuses paths outside the configured roots', async () => { + const base = scratchCase(); + const allowed = path.join(base, 'allowed'); + const outside = path.join(base, 'outside'); + fs.mkdirSync(allowed); + fs.mkdirSync(outside); + const { listAbsoluteDirectory } = loadFilesystem({ roots: allowed }); + + const listed = await listAbsoluteDirectory(allowed); + assert.equal(listed.path, allowed); + + const err = await rejects(() => listAbsoluteDirectory(outside)); + assert.equal(err.code, 'FS_PATH_OUTSIDE_ROOTS'); + assert.equal(err.status, 403); +}); + +test('RELAY_FS_ROOTS accepts a comma-separated list and ignores blank entries', async () => { + const base = scratchCase(); + const first = path.join(base, 'first'); + const second = path.join(base, 'second'); + fs.mkdirSync(first); + fs.mkdirSync(path.join(second, 'nested'), { recursive: true }); + const { listAbsoluteDirectory } = loadFilesystem({ + roots: ` ${first} , , ${second} `, + }); + + assert.equal((await listAbsoluteDirectory(first)).path, first); + // Nested paths under a root are inside it. + assert.equal( + (await listAbsoluteDirectory(path.join(second, 'nested'))).path, + path.join(second, 'nested'), + ); + assert.equal( + (await rejects(() => listAbsoluteDirectory(base))).code, + 'FS_PATH_OUTSIDE_ROOTS', + ); +}); + +test('a sibling whose name merely starts with a root name is outside it', async () => { + const base = scratchCase(); + const allowed = path.join(base, 'data'); + const lookalike = path.join(base, 'data-backup'); + fs.mkdirSync(allowed); + fs.mkdirSync(lookalike); + const { listAbsoluteDirectory } = loadFilesystem({ roots: allowed }); + assert.equal( + (await rejects(() => listAbsoluteDirectory(lookalike))).code, + 'FS_PATH_OUTSIDE_ROOTS', + ); +}); + +// --- workdir-relative confinement ------------------------------------------- + +test('relative download paths cannot climb out of the workdir', async () => { + const base = scratchCase(); + const workdir = path.join(base, 'work'); + fs.mkdirSync(workdir); + fs.writeFileSync(path.join(base, 'secret.txt'), 'nope'); + const { prepareDownload } = loadFilesystem(); + + const err = await rejects(() => prepareDownload('../secret.txt', workdir)); + assert.equal(err.code, 'FS_PATH_OUTSIDE_WORKDIR'); + assert.equal(err.status, 403); +}); + +test('the relative browser refuses an absolute path, and the absolute one refuses a relative path', async () => { + const workdir = scratchCase(); + const { prepareDownload, listAbsoluteDirectory } = loadFilesystem(); + + assert.equal( + (await rejects(() => prepareDownload(workdir, workdir))).code, + 'FS_PATH_MUST_BE_RELATIVE', + ); + assert.equal( + (await rejects(() => listAbsoluteDirectory('relative/dir'))).code, + 'FS_PATH_MUST_BE_ABSOLUTE', + ); +}); + +test('a missing path reports not-found rather than leaking a policy decision', async () => { + const { listAbsoluteDirectory, prepareDownloadAbsolute } = loadFilesystem(); + const missing = path.join(scratchCase(), 'nope'); + assert.equal( + (await rejects(() => listAbsoluteDirectory(missing))).status, + 404, + ); + assert.equal( + (await rejects(() => prepareDownloadAbsolute(missing))).status, + 404, + ); +}); + +// --- upload naming ----------------------------------------------------------- + +test('upload file names are reduced to a bare basename', () => { + const workdir = scratchCase(); + const { resolveAbsoluteUploadTarget } = loadFilesystem(); + + for (const bad of ['../escape.txt', 'nested/file.txt', 'nested\\file.txt', '..', '.', '']) { + assert.throws( + () => resolveAbsoluteUploadTarget(workdir, bad), + (err) => err.code === 'FS_INVALID_FILE_NAME', + `expected ${JSON.stringify(bad)} to be refused`, + ); + } + + const ok = resolveAbsoluteUploadTarget(workdir, 'report.pdf'); + assert.equal(ok.target, path.join(workdir, 'report.pdf')); + assert.equal(ok.name, 'report.pdf'); +}); + +test('uploading requires an existing absolute directory', () => { + const workdir = scratchCase(); + const file = path.join(workdir, 'a.txt'); + fs.writeFileSync(file, 'a'); + const { resolveAbsoluteUploadTarget } = loadFilesystem(); + + assert.throws( + () => resolveAbsoluteUploadTarget('relative', 'a.txt'), + (err) => err.code === 'FS_PATH_MUST_BE_ABSOLUTE', + ); + assert.throws( + () => resolveAbsoluteUploadTarget(file, 'a.txt'), + (err) => err.code === 'FS_PATH_NOT_DIRECTORY', + ); +}); + +// --- size caps --------------------------------------------------------------- + +test('an oversized file download is refused before any bytes are streamed', async () => { + const dir = scratchCase(); + const file = path.join(dir, 'big.bin'); + fs.writeFileSync(file, Buffer.alloc(2048)); + const { prepareDownloadAbsolute } = loadFilesystem(); + + const err = await rejects(() => prepareDownloadAbsolute(file, { maxBytes: 1024 })); + assert.equal(err.code, 'FS_DOWNLOAD_TOO_LARGE'); + assert.equal(err.status, 413); + + // Exactly at the cap is still allowed. + const ok = await prepareDownloadAbsolute(file, { maxBytes: 2048 }); + assert.equal(ok.totalBytes, 2048); +}); + +test('a directory download is measured by its uncompressed total', async () => { + const dir = path.join(scratchCase(), 'tree'); + fs.mkdirSync(path.join(dir, 'nested'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'a.bin'), Buffer.alloc(600)); + fs.writeFileSync(path.join(dir, 'nested', 'b.bin'), Buffer.alloc(600)); + const { prepareDownloadAbsolute } = loadFilesystem(); + + const err = await rejects(() => prepareDownloadAbsolute(dir, { maxBytes: 1000 })); + assert.equal(err.code, 'FS_DOWNLOAD_TOO_LARGE'); + + const ok = await prepareDownloadAbsolute(dir, { maxBytes: 4096 }); + assert.equal(ok.isDirectory, true); + assert.equal(ok.totalBytes, 1200); + assert.equal(ok.filename, 'tree.zip'); + assert.equal(ok.zipEntryName, 'tree'); +}); + +// --- listing shape ----------------------------------------------------------- + +test('hidden entries are listed only when asked for', async () => { + const dir = scratchCase(); + fs.writeFileSync(path.join(dir, 'visible.txt'), 'a'); + fs.writeFileSync(path.join(dir, '.hidden'), 'b'); + const { listAbsoluteDirectory } = loadFilesystem(); + + const plain = await listAbsoluteDirectory(dir); + assert.deepEqual(plain.entries.map((entry) => entry.name), ['visible.txt']); + + const hidden = await listAbsoluteDirectory(dir, { showHidden: true }); + assert.deepEqual( + hidden.entries.map((entry) => entry.name).sort(), + ['.hidden', 'visible.txt'], + ); +}); + +test('directories sort ahead of files and carry absolute paths', async () => { + const dir = scratchCase(); + fs.mkdirSync(path.join(dir, 'zeta')); + fs.writeFileSync(path.join(dir, 'alpha.txt'), 'a'); + const { listAbsoluteDirectory } = loadFilesystem(); + + const listing = await listAbsoluteDirectory(dir); + assert.deepEqual( + listing.entries.map((entry) => [entry.name, entry.type]), + [['zeta', 'directory'], ['alpha.txt', 'file']], + ); + assert.equal(listing.entries[0].absolutePath, path.join(dir, 'zeta')); + assert.equal(listing.parentPath, path.dirname(dir)); +}); diff --git a/server/test/fixtures/fake-acp-agent.js b/server/test/fixtures/fake-acp-agent.js new file mode 100644 index 0000000..7ead4ee --- /dev/null +++ b/server/test/fixtures/fake-acp-agent.js @@ -0,0 +1,240 @@ +'use strict'; + +// A stand-in ACP agent: speaks the real JSON-RPC-over-stdio protocol so the +// pool's transport, framing and process lifecycle are exercised for real. +// Behaviour is driven by magic prompts (`hang`, `die`, `perm`, `mem`) and by +// env flags, and every notable event is appended to FAKE_ACP_STATE so a test +// can assert what the agent actually saw. +const fs = require('fs'); + +const statePath = process.env.FAKE_ACP_STATE || ''; +const noLoad = process.env.FAKE_ACP_NO_LOAD === '1'; +const noClose = process.env.FAKE_ACP_NO_CLOSE === '1'; + +function record(line) { + if (!statePath) return; + try { + fs.appendFileSync(statePath, `${line}\n`); + } catch (_err) { + // The test may have torn the scratch dir down already. + } +} + +function write(frame) { + process.stdout.write(`${JSON.stringify(frame)}\n`); +} + +const sessions = new Map(); +let counter = 0; +const hanging = new Map(); + +record(`spawn ${process.pid}`); + +function update(sessionId, payload) { + write({ + jsonrpc: '2.0', + method: 'session/update', + params: { sessionId, update: payload }, + }); +} + +function finishPrompt(id, sessionId, text) { + update(sessionId, { + sessionUpdate: 'agent_message_chunk', + messageId: `m${counter}`, + content: { type: 'text', text }, + }); + write({ jsonrpc: '2.0', id, result: { stopReason: 'end_turn' } }); +} + +function handlePrompt(msg) { + const { sessionId, prompt } = msg.params; + const text = prompt.map((part) => part.text).join(''); + const session = sessions.get(sessionId); + if (!session) { + write({ + jsonrpc: '2.0', + id: msg.id, + error: { code: -32602, message: `unknown session ${sessionId}` }, + }); + return; + } + session.prompts.push(text); + record(`prompt ${sessionId} ${text}`); + + if (text === 'hang') { + hanging.set(sessionId, msg.id); + return; + } + if (text === 'die') { + update(sessionId, { + sessionUpdate: 'agent_message_chunk', + messageId: 'm-die', + content: { type: 'text', text: 'partial' }, + }); + process.exit(3); + } + if (text === 'die-quiet') { + // Dies once, so a retry on a fresh process can succeed — the pool's cold + // fallback is only useful if the next attempt is allowed to work. + let already = false; + try { + already = fs.readFileSync(statePath, 'utf8').includes('died'); + } catch (_err) { + already = false; + } + if (!already) { + record('died'); + process.exit(4); + } + finishPrompt(msg.id, sessionId, 'recovered'); + return; + } + if (text === 'perm') { + // Ask, then report which option the client picked so the test can assert + // the tier policy end to end. + const id = 1000 + counter++; + write({ + jsonrpc: '2.0', + id, + method: 'session/request_permission', + params: { + sessionId, + toolCall: { toolCallId: 'tc1', title: 'write /tmp/x', kind: 'edit' }, + options: [ + { optionId: 'yes', name: 'Allow', kind: 'allow_once' }, + { optionId: 'no', name: 'Deny', kind: 'reject_once' }, + ], + }, + }); + session.pendingPermission = { promptId: msg.id, sessionId }; + return; + } + if (text === 'mem') { + finishPrompt(msg.id, sessionId, session.prompts.join(',')); + return; + } + if (text === 'two') { + // Two assistant messages in one turn: the pool must forward both, and the + // runner turns the messageId change into a segment boundary. + update(sessionId, { + sessionUpdate: 'agent_message_chunk', + messageId: 'first', + content: { type: 'text', text: 'one' }, + }); + update(sessionId, { + sessionUpdate: 'agent_message_chunk', + messageId: 'second', + content: { type: 'text', text: 'two' }, + }); + write({ jsonrpc: '2.0', id: msg.id, result: { stopReason: 'end_turn' } }); + return; + } + update(sessionId, { sessionUpdate: 'tool_call', title: 'ls', kind: 'read' }); + finishPrompt(msg.id, sessionId, `echo:${text}`); +} + +function handle(msg) { + if (msg.method === undefined && msg.id !== undefined) { + // A reply to our permission request. + for (const session of sessions.values()) { + const pending = session.pendingPermission; + if (!pending) continue; + session.pendingPermission = null; + const outcome = (msg.result && msg.result.outcome) || {}; + const picked = + outcome.outcome === 'selected' ? outcome.optionId : outcome.outcome; + record(`permission ${picked}`); + finishPrompt(pending.promptId, pending.sessionId, `permission:${picked}`); + return; + } + return; + } + + const reply = (result) => write({ jsonrpc: '2.0', id: msg.id, result }); + const fail = (message) => + write({ jsonrpc: '2.0', id: msg.id, error: { code: -32603, message } }); + + switch (msg.method) { + case 'initialize': + reply({ + protocolVersion: 1, + agentInfo: { name: 'fake', version: '1' }, + agentCapabilities: { + loadSession: !noLoad, + sessionCapabilities: noClose ? {} : { close: {} }, + }, + }); + return; + case 'session/new': { + counter += 1; + const sessionId = `sess-${process.pid}-${counter}`; + sessions.set(sessionId, { prompts: [], cwd: msg.params.cwd }); + record(`new ${sessionId} ${msg.params.cwd}`); + reply({ sessionId }); + return; + } + case 'session/load': { + const { sessionId } = msg.params; + if (noLoad) { + record(`load-failed ${sessionId}`); + fail('session not found'); + return; + } + sessions.set(sessionId, { prompts: [], cwd: msg.params.cwd }); + record(`load ${sessionId} ${msg.params.cwd}`); + reply({}); + return; + } + case 'session/prompt': + handlePrompt(msg); + return; + case 'session/cancel': { + const { sessionId } = msg.params; + record(`cancel ${sessionId}`); + const promptId = hanging.get(sessionId); + if (promptId !== undefined) { + hanging.delete(sessionId); + write({ jsonrpc: '2.0', id: promptId, result: { stopReason: 'cancelled' } }); + } + return; + } + case 'session/set_model': + record(`model ${msg.params.sessionId} ${msg.params.modelId}`); + reply({}); + return; + case 'session/set_mode': + record(`mode ${msg.params.sessionId} ${msg.params.modeId}`); + reply({}); + return; + case 'session/close': + record(`close ${msg.params.sessionId}`); + sessions.delete(msg.params.sessionId); + reply({}); + return; + default: + fail(`unsupported method: ${msg.method}`); + } +} + +let buffer = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + buffer += chunk; + let index; + while ((index = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, index).trim(); + buffer = buffer.slice(index + 1); + if (!line) continue; + try { + handle(JSON.parse(line)); + } catch (_err) { + // Malformed input is not this fixture's problem. + } + } +}); +// Closing stdin is how the pool asks an agent to exit. +process.stdin.on('end', () => { + record(`exit ${process.pid}`); + process.exit(0); +}); diff --git a/server/test/fixtures/fake-codex-agent.js b/server/test/fixtures/fake-codex-agent.js new file mode 100644 index 0000000..af7289f --- /dev/null +++ b/server/test/fixtures/fake-codex-agent.js @@ -0,0 +1,215 @@ +'use strict'; + +// A stand-in `codex app-server`: speaks the real app-server protocol so the +// codex driver's distinct behaviour — a turn that completes on a notification +// rather than on the response — is exercised for real. Behaviour is driven by +// magic prompts (`hang`, `die`, `perm`, `retry`) and every notable event is +// appended to FAKE_CODEX_STATE for the test to assert on. +const fs = require('fs'); + +const statePath = process.env.FAKE_CODEX_STATE || ''; +const noResume = process.env.FAKE_CODEX_NO_RESUME === '1'; + +function record(line) { + if (!statePath) return; + try { + fs.appendFileSync(statePath, `${line}\n`); + } catch (_err) { + // The test may have torn the scratch dir down already. + } +} + +function write(frame) { + process.stdout.write(`${JSON.stringify(frame)}\n`); +} + +function notify(method, params) { + write({ jsonrpc: '2.0', method, params }); +} + +const threads = new Map(); +let counter = 0; +const running = new Map(); + +record(`spawn ${process.pid}`); + +function finishTurn(threadId, turnId, status) { + running.delete(threadId); + notify('turn/completed', { threadId, turn: { id: turnId, status } }); +} + +function handleTurnStart(msg) { + const { threadId, input } = msg.params; + const text = input.map((part) => part.text).join(''); + if (!threads.has(threadId)) { + write({ + jsonrpc: '2.0', + id: msg.id, + error: { code: -32602, message: `unknown thread ${threadId}` }, + }); + return; + } + counter += 1; + const turnId = `turn-${counter}`; + record( + `turn ${threadId} ${turnId} ${text} model=${msg.params.model || '-'} effort=${ + msg.params.effort || '-' + }`, + ); + // The response only accepts the turn; completion comes later. + const accept = () => { + write({ + jsonrpc: '2.0', + id: msg.id, + result: { turn: { id: turnId, status: 'inProgress' } }, + }); + record(`accepted ${turnId}`); + running.set(threadId, turnId); + }; + + if (text === 'hang-slow') { + // Accepted late, so a client can cancel before it has an id to interrupt. + setTimeout(accept, 150); + return; + } + accept(); + + if (text === 'hang') return; + if (text === 'die') { + notify('item/agentMessage/delta', { threadId, turnId, itemId: 'i1', delta: 'partial' }); + process.exit(3); + } + if (text === 'retry') { + // A retryable error is codex saying it is still working: the pool must not + // fail the turn on it. + notify('error', { threadId, turnId, willRetry: true, error: { message: 'transient' } }); + notify('item/agentMessage/delta', { threadId, turnId, itemId: 'i1', delta: 'recovered' }); + finishTurn(threadId, turnId, 'completed'); + return; + } + if (text === 'boom') { + notify('error', { threadId, turnId, willRetry: false, error: { message: 'codex blew up' } }); + return; + } + if (text === 'perm') { + write({ + jsonrpc: '2.0', + id: 9000 + counter, + method: 'item/fileChange/requestApproval', + params: { threadId, turnId, itemId: 'edit-1' }, + }); + running.set(threadId, turnId); + threads.get(threadId).pendingApproval = turnId; + return; + } + if (text === 'two') { + notify('item/agentMessage/delta', { threadId, turnId, itemId: 'first', delta: 'one' }); + notify('item/agentMessage/delta', { threadId, turnId, itemId: 'second', delta: 'two' }); + finishTurn(threadId, turnId, 'completed'); + return; + } + notify('item/completed', { + threadId, + turnId, + completedAtMs: 0, + item: { id: 'c1', type: 'commandExecution', command: ['ls', '-la'] }, + }); + notify('item/agentMessage/delta', { threadId, turnId, itemId: 'i1', delta: `echo:${text}` }); + finishTurn(threadId, turnId, 'completed'); +} + +function handle(msg) { + if (msg.method === undefined && msg.id !== undefined) { + // A reply to our approval request. + for (const [threadId, thread] of threads) { + if (!thread.pendingApproval) continue; + const turnId = thread.pendingApproval; + thread.pendingApproval = null; + const decision = (msg.result && msg.result.decision) || 'none'; + record(`approval ${decision}`); + notify('item/agentMessage/delta', { + threadId, + turnId, + itemId: 'i1', + delta: `approval:${decision}`, + }); + finishTurn(threadId, turnId, 'completed'); + return; + } + return; + } + + const reply = (result) => write({ jsonrpc: '2.0', id: msg.id, result }); + const fail = (message) => + write({ jsonrpc: '2.0', id: msg.id, error: { code: -32603, message } }); + const params = msg.params || {}; + + switch (msg.method) { + case 'initialize': + reply({ userAgent: 'fake-codex/0' }); + return; + case 'thread/start': { + counter += 1; + const id = `th-${process.pid}-${counter}`; + threads.set(id, { cwd: params.cwd }); + record(`start ${id} ${params.cwd} sandbox=${params.sandbox || '-'}`); + reply({ thread: { id } }); + return; + } + case 'thread/resume': { + if (noResume) { + record(`resume-failed ${params.threadId}`); + fail('thread not found'); + return; + } + threads.set(params.threadId, { cwd: params.cwd }); + record(`resume ${params.threadId} ${params.cwd} sandbox=${params.sandbox || '-'}`); + reply({ thread: { id: params.threadId } }); + return; + } + case 'turn/start': + handleTurnStart(msg); + return; + case 'turn/interrupt': { + record(`interrupt ${params.threadId} ${params.turnId}`); + if (running.get(params.threadId) === params.turnId) { + finishTurn(params.threadId, params.turnId, 'interrupted'); + } + reply({}); + return; + } + case 'thread/unsubscribe': + record(`unsubscribe ${params.threadId}`); + threads.delete(params.threadId); + reply({}); + return; + case 'thread/delete': + record(`delete ${params.threadId}`); + threads.delete(params.threadId); + reply({}); + return; + default: + fail(`unsupported method: ${msg.method}`); + } +} + +let buffer = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + buffer += chunk; + let index; + while ((index = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, index).trim(); + buffer = buffer.slice(index + 1); + if (!line) continue; + try { + handle(JSON.parse(line)); + } catch (_err) { + // Malformed input is not this fixture's problem. + } + } +}); +process.stdin.on('end', () => { + record(`exit ${process.pid}`); + process.exit(0); +}); diff --git a/server/test/group-route.test.js b/server/test/group-route.test.js index 603eae5..ebc85b2 100644 --- a/server/test/group-route.test.js +++ b/server/test/group-route.test.js @@ -12,6 +12,9 @@ const express = require('express'); const scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-group-route-')); process.env.RELAY_GROUPS_FILE = path.join(scratchDir, 'groups.json'); process.env.RELAY_HISTORY_FILE = path.join(scratchDir, 'history.json'); +// Two agent-driven waves after the human's, so the chain and its cap are both +// observable without running a long conversation. +process.env.RELAY_SWARM_MAX_HOPS = '2'; const history = require('../lib/history'); const { sessionScopeKey } = require('../lib/chat-sessions'); @@ -32,6 +35,9 @@ const runCalls = []; // Events broadcast on the shared stream, so the test can assert the round's // lifecycle signals (group_message, group_done) reach other devices. const sentEvents = []; +// Per-agent reply text, so a test can make one member @mention another. Empty +// means the default "reply from ", which mentions nobody. +const scriptedReplies = new Map(); function buildContext() { const sessionContextKeyFor = (agentKey, workdir) => `${workdir}${SEP}${agentKey}`; @@ -59,8 +65,9 @@ function buildContext() { workdir: opts.workdir, settings: opts.settings, }); - onEvent({ type: 'delta', text: `reply from ${agentKey}` }); - return `reply from ${agentKey}`; + const reply = scriptedReplies.get(agentKey) || `reply from ${agentKey}`; + onEvent({ type: 'delta', text: reply }); + return reply; }, }); @@ -69,7 +76,7 @@ function buildContext() { activeRequests: new Map(), agentTurnDependencies, clearHistory: history.clearHistory, - clearSession: () => {}, + purgeSession: async () => true, finalizeStaleStreamingHistory: history.finalizeStaleStreamingHistory, getAgent: (key) => AGENTS[key] || null, normalizeDeviceId: () => '', @@ -188,6 +195,89 @@ test('same-message mentions run in parallel off one snapshot, not seeing each ot assert.ok(!claudeCall.prompt.includes('reply from codex')); }); +test('a member @mentioned by another member takes the next turn', async () => { + const created = await (await api('POST', '/api/groups', { + name: 'Relay Chain', + members: ['claude', 'codex'], + })).json(); + const group = created.group; + scriptedReplies.set('claude', 'I mapped it out, @codex take the lexer'); + scriptedReplies.set('codex', 'lexer done, nothing else needed'); + const before = runCalls.length; + + const round = await (await api('POST', '/api/group/chat', { + groupId: group.id, + prompt: '@claude start us off', + })).json(); + scriptedReplies.clear(); + + // The human summoned one member; its reply summoned the other. + assert.deepEqual( + round.turns.map((t) => t.agent), + ['claude', 'codex'], + ); + const codexCall = runCalls.slice(before).find((c) => c.agentKey === 'codex'); + assert.ok(codexCall); + // The second wave snapshots again, so Codex is fed the reply that summoned it. + assert.match(codexCall.prompt, /Claude Code: I mapped it out, @codex take the lexer/); + // And it is told who else it can hand the floor to. + assert.match(codexCall.prompt, /Other members of this swarm: Claude Code \(@claude\)/); + + const messages = (await (await api( + 'GET', + `/api/group/history?groupId=${group.id}`, + )).json()).messages; + const assistants = messages.filter((m) => m.role === 'assistant'); + assert.equal(assistants.length, 2); + assert.equal(assistants[0].metadata.summonedBy, 'human'); + // The transcript records which member summoned the follow-up turn. + assert.equal(assistants[1].metadata.author, 'codex'); + assert.equal(assistants[1].metadata.summonedBy, 'claude'); +}); + +test('two members mentioning each other stop at the hop cap', async () => { + const created = await (await api('POST', '/api/groups', { + name: 'Relay Pingpong', + members: ['claude', 'codex'], + })).json(); + const group = created.group; + // Each reply summons the other, forever, if nothing bounds the round. + scriptedReplies.set('claude', 'over to you @codex'); + scriptedReplies.set('codex', 'back to you @claude'); + + const round = await (await api('POST', '/api/group/chat', { + groupId: group.id, + prompt: '@claude begin', + })).json(); + scriptedReplies.clear(); + + // RELAY_SWARM_MAX_HOPS=2: the human's wave plus two agent-driven ones. + assert.deepEqual( + round.turns.map((t) => t.agent), + ['claude', 'codex', 'claude'], + ); +}); + +test('a member mentioning itself does not summon another turn', async () => { + const created = await (await api('POST', '/api/groups', { + name: 'Relay Solo', + members: ['claude', 'codex'], + })).json(); + const group = created.group; + scriptedReplies.set('claude', 'noting for myself, @claude follow up later'); + + const round = await (await api('POST', '/api/group/chat', { + groupId: group.id, + prompt: '@claude think out loud', + })).json(); + scriptedReplies.clear(); + + assert.deepEqual( + round.turns.map((t) => t.agent), + ['claude'], + ); +}); + test('a later message still sees earlier replies, so cross-round stays collaborative', async () => { const created = await (await api('POST', '/api/groups', { name: 'Relay Builders', diff --git a/server/test/group-turn.test.js b/server/test/group-turn.test.js index 65147b3..1a65bd2 100644 --- a/server/test/group-turn.test.js +++ b/server/test/group-turn.test.js @@ -13,7 +13,7 @@ const { const LABELS = { claude: 'Claude Code', codex: 'Codex', - agy: 'Antigravity', + opencode: 'OpenCode', }; const labelFor = (key) => LABELS[key] || key; @@ -33,7 +33,7 @@ test('authorOf prefers explicit metadata, then role/agent', () => { }); test('parseMentions returns summoned members in order, de-duplicated', () => { - const members = ['claude', 'codex', 'agy']; + const members = ['claude', 'codex', 'opencode']; assert.deepEqual( parseMentions('hey @codex and @claude, then @codex again', members, labelFor), ['codex', 'claude'], @@ -42,8 +42,8 @@ test('parseMentions returns summoned members in order, de-duplicated', () => { test('parseMentions only matches current members and ignores email-like tokens', () => { const members = ['claude', 'codex']; - // @agy is not a member; foo@codex is an email-like token (preceded by a word char). - assert.deepEqual(parseMentions('ping @agy please', members, labelFor), []); + // @opencode is not a member; foo@codex is an email-like token (preceded by a word char). + assert.deepEqual(parseMentions('ping @opencode please', members, labelFor), []); assert.deepEqual(parseMentions('mail foo@codex now', members, labelFor), []); assert.deepEqual(parseMentions('@claude go', members, labelFor), ['claude']); }); @@ -54,7 +54,7 @@ test('parseMentions matches by label slug as well as agent key', () => { }); test('parseMentions ignores broad @all / @everyone aliases', () => { - const members = ['claude', 'codex', 'agy']; + const members = ['claude', 'codex', 'opencode']; assert.deepEqual(parseMentions('@all huddle up', members, labelFor), []); assert.deepEqual(parseMentions('@everyone huddle up', members, labelFor), []); }); @@ -107,6 +107,28 @@ test('buildGroupPrompt injects the member persona when given', () => { assert.doesNotMatch(plain, /Your role in this swarm/); }); +test('buildGroupPrompt lists the members this one can summon', () => { + const delta = [human('start')]; + const prompt = buildGroupPrompt({ + selfLabel: 'Claude Code', + delta, + labelFor, + roster: [ + { key: 'codex', label: 'Codex' }, + { key: 'opencode', label: 'Schema owner' }, + ], + }); + // The @key form is what parseMentions always resolves, so it is what the + // agent is shown — a multi-word nickname alone would not parse. + assert.match( + prompt, + /Other members of this swarm: Codex \(@codex\), Schema owner \(@opencode\)\./, + ); + // Without a roster (agent-to-agent summoning off) the prompt promises nothing. + const alone = buildGroupPrompt({ selfLabel: 'Claude Code', delta, labelFor }); + assert.doesNotMatch(alone, /Other members of this swarm/); +}); + test('buildGroupPrompt bounds the prompt and notes omitted history', () => { const big = 'x'.repeat(2000); const delta = []; diff --git a/server/test/groups.test.js b/server/test/groups.test.js index bb7c686..9f30ada 100644 --- a/server/test/groups.test.js +++ b/server/test/groups.test.js @@ -56,9 +56,9 @@ test('createGroup returns null when there are no valid members', () => { test('setGroupMembers replaces the roster and keeps the id', () => { const workdir = '/tmp/wd-set'; const group = groups.createGroup(workdir, 'Team', ['claude']); - const updated = groups.setGroupMembers(workdir, group.id, ['codex', 'agy']); + const updated = groups.setGroupMembers(workdir, group.id, ['codex', 'opencode']); assert.equal(updated.id, group.id); - assert.deepEqual(updated.members, ['codex', 'agy']); + assert.deepEqual(updated.members, ['codex', 'opencode']); assert.equal(groups.setGroupMembers(workdir, 'missing', ['codex']), null); assert.equal(groups.setGroupMembers(workdir, group.id, []), null); }); diff --git a/server/test/meta-agents.test.js b/server/test/meta-agents.test.js index 2d007c5..3cbb7be 100644 --- a/server/test/meta-agents.test.js +++ b/server/test/meta-agents.test.js @@ -15,9 +15,18 @@ before(async () => { createMetaRouter({ DEFAULT_AGENT: 'claude', getAgentStatuses: () => ({ - claude: { installed: true, authed: true, authKind: 'oauth' }, - codex: { installed: true, authed: false, authKind: 'oauth' }, - agy: { installed: false, authed: false, authKind: 'oauth' }, + claude: { + installed: true, + authed: true, + authKind: 'oauth', + credentialExpiresAt: 1893456000000, + }, + codex: { + installed: true, + authed: false, + authKind: 'oauth', + credentialExpiresAt: null, + }, opencode: { installed: true, authed: true, @@ -28,7 +37,6 @@ before(async () => { listAgents: () => [ { key: 'claude', label: 'Claude Code', description: 'Claude CLI' }, { key: 'codex', label: 'Codex', description: 'Codex CLI' }, - { key: 'agy', label: 'Antigravity', description: 'Agy CLI' }, { key: 'opencode', label: 'OpenCode', description: 'OpenCode CLI' }, { key: 'hermes', label: 'Hermes', description: 'Hermes CLI' }, ], @@ -53,7 +61,7 @@ test('/api/agents returns every agent with install/auth usability fields', async assert.equal(body.defaultAgent, 'claude'); assert.deepEqual( body.agents.map((agent) => agent.key), - ['claude', 'codex', 'agy', 'opencode', 'hermes'], + ['claude', 'codex', 'opencode', 'hermes'], ); const byKey = Object.fromEntries( @@ -68,6 +76,7 @@ test('/api/agents returns every agent with install/auth usability fields', async authed: byKey.claude.authed, authKind: byKey.claude.authKind, usable: byKey.claude.usable, + credentialExpiresAt: byKey.claude.credentialExpiresAt, }, { key: 'claude', @@ -77,10 +86,12 @@ test('/api/agents returns every agent with install/auth usability fields', async authed: true, authKind: 'oauth', usable: true, + credentialExpiresAt: 1893456000000, }, ); assert.equal(byKey.codex.usable, false); - assert.equal(byKey.agy.usable, false); + assert.equal(byKey.codex.credentialExpiresAt, null); + assert.equal(byKey.opencode.credentialExpiresAt, null); assert.equal(byKey.opencode.usable, true); assert.equal(byKey.hermes.authKind, 'apiKey'); // hermes is managed out-of-band, so it is usable once installed even with no diff --git a/server/test/quota-keepalive.test.js b/server/test/quota-keepalive.test.js new file mode 100644 index 0000000..5d5fd72 --- /dev/null +++ b/server/test/quota-keepalive.test.js @@ -0,0 +1,65 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { test } = require('node:test'); + +const { planKeepalive } = require('../lib/quota-keepalive'); + +const NOW = Date.parse('2026-07-26T04:00:00.000Z'); + +test('waits until just after the running window lapses', () => { + const plan = planKeepalive({ + usage: { resetsAt: '2026-07-26T06:00:00.000Z', stale: false }, + now: NOW, + nextPrimeAllowedAt: 0, + }); + assert.equal(plan.action, 'wait'); + assert.equal(plan.waitMs, 2 * 60 * 60 * 1000 + 30_000); +}); + +test('pings when the window is idle', () => { + const plan = planKeepalive({ + usage: { resetsAt: null, stale: false }, + now: NOW, + nextPrimeAllowedAt: 0, + }); + assert.equal(plan.action, 'prime'); +}); + +test('pings when the reported reset moment has already passed', () => { + const plan = planKeepalive({ + usage: { resetsAt: '2026-07-26T03:59:00.000Z', stale: false }, + now: NOW, + nextPrimeAllowedAt: 0, + }); + assert.equal(plan.action, 'prime'); +}); + +test('never pings on stale usage data', () => { + const plan = planKeepalive({ + usage: { resetsAt: null, stale: true }, + now: NOW, + nextPrimeAllowedAt: 0, + }); + assert.equal(plan.action, 'wait'); +}); + +test('respects the minimum interval between two pings', () => { + const plan = planKeepalive({ + usage: { resetsAt: null, stale: false }, + now: NOW, + nextPrimeAllowedAt: NOW + 300_000, + }); + assert.equal(plan.action, 'wait'); + assert.equal(plan.waitMs, 300_000); +}); + +test('clamps an implausibly distant reset to the maximum sleep', () => { + const plan = planKeepalive({ + usage: { resetsAt: '2027-01-01T00:00:00.000Z', stale: false }, + now: NOW, + nextPrimeAllowedAt: 0, + }); + assert.equal(plan.action, 'wait'); + assert.equal(plan.waitMs, 6 * 60 * 60 * 1000); +}); diff --git a/server/test/quota-schedules.test.js b/server/test/quota-schedules.test.js new file mode 100644 index 0000000..bcea030 --- /dev/null +++ b/server/test/quota-schedules.test.js @@ -0,0 +1,228 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { afterEach, after, test } = require('node:test'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +// A schedule holds one queued prompt per quota source until the next five-hour +// reset, so the invariants that matter are: one pending message per source and +// workspace, sane status transitions, and a bounded file. + +const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-schedules-test-')); +const schedulesFile = path.join(tempDir, 'quota-schedules.json'); +process.env.RELAY_QUOTA_SCHEDULES_FILE = schedulesFile; + +const { + createQuotaSchedule, + cancelQuotaSchedule, + dueQuotaSchedulesForReset, + listQuotaSchedules, + markQuotaScheduleFailed, + markQuotaScheduleRunning, + markQuotaScheduleSent, + reconcileRunningSchedules, +} = require('../lib/quota-schedules'); + +afterEach(() => { + fs.rmSync(schedulesFile, { force: true }); +}); + +after(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + delete process.env.RELAY_QUOTA_SCHEDULES_FILE; +}); + +function makeSchedule(overrides = {}) { + return createQuotaSchedule({ + sourceKey: 'claude', + agentKey: 'claude', + sessionId: 'main', + sessionName: 'Main', + workdir: '/work/app', + prompt: 'continue the refactor', + ...overrides, + }); +} + +function caught(fn) { + try { + fn(); + } catch (err) { + return err; + } + throw new assert.AssertionError({ message: 'expected the call to throw' }); +} + +// --- creation ---------------------------------------------------------------- + +test('a new schedule starts pending and keeps its scope', () => { + const schedule = makeSchedule({ targetResetsAt: '2026-07-27T15:00:00.000Z' }); + + assert.equal(schedule.status, 'pending'); + assert.equal(schedule.sourceKey, 'claude'); + assert.equal(schedule.workdir, '/work/app'); + assert.equal(schedule.prompt, 'continue the refactor'); + assert.equal(schedule.targetResetsAt, '2026-07-27T15:00:00.000Z'); + assert.ok(schedule.id); + // The stored prompt is never exposed with the raw record's internals missing: + // the public shape is what the API returns. + assert.equal('error' in schedule, true); +}); + +test('an unparsable reset time degrades to "as soon as it resets"', () => { + assert.equal(makeSchedule({ targetResetsAt: 'tomorrow-ish' }).targetResetsAt, null); + assert.equal(makeSchedule({ workdir: '/other', targetResetsAt: '' }).targetResetsAt, null); +}); + +test('a prompt is required and bounded', () => { + assert.equal(caught(() => makeSchedule({ prompt: ' ' })).code, 'PROMPT_REQUIRED'); + assert.equal( + caught(() => makeSchedule({ prompt: 'x'.repeat(12001) })).code, + 'PROMPT_TOO_LONG', + ); + assert.equal(makeSchedule({ prompt: 'x'.repeat(12000) }).prompt.length, 12000); +}); + +// --- one pending message per source and workspace --------------------------- + +test('a second pending message for the same source and workspace is refused', () => { + makeSchedule(); + const err = caught(() => makeSchedule({ prompt: 'something else' })); + assert.equal(err.code, 'SCHEDULE_EXISTS'); + assert.equal(listQuotaSchedules().length, 1); +}); + +test('replaceExisting updates the pending message in place', () => { + const first = makeSchedule(); + const replaced = makeSchedule({ + prompt: 'do this instead', + sessionId: 'review', + targetResetsAt: '2026-07-27T20:00:00.000Z', + replaceExisting: true, + }); + + assert.equal(replaced.id, first.id); + assert.equal(replaced.prompt, 'do this instead'); + assert.equal(replaced.sessionId, 'review'); + assert.equal(replaced.targetResetsAt, '2026-07-27T20:00:00.000Z'); + assert.equal(listQuotaSchedules().length, 1); +}); + +test('other sources and workspaces keep their own pending message', () => { + makeSchedule(); + makeSchedule({ sourceKey: 'codex', agentKey: 'codex' }); + makeSchedule({ workdir: '/work/other' }); + assert.equal(listQuotaSchedules().length, 3); +}); + +test('a cancelled message frees the slot for a new one', () => { + const first = makeSchedule(); + cancelQuotaSchedule(first.id); + const second = makeSchedule({ prompt: 'a fresh plan' }); + assert.notEqual(second.id, first.id); + assert.equal(second.status, 'pending'); +}); + +// --- listing ----------------------------------------------------------------- + +test('listing can hide finished records and filter by workspace', () => { + const finished = makeSchedule(); + cancelQuotaSchedule(finished.id); + makeSchedule({ workdir: '/work/other' }); + + assert.equal(listQuotaSchedules().length, 2); + assert.equal(listQuotaSchedules({ includeFinished: false }).length, 1); + assert.equal(listQuotaSchedules({ workdir: '/work/app' }).length, 1); + assert.equal(listQuotaSchedules({ workdir: '/nowhere' }).length, 0); +}); + +// --- status transitions ------------------------------------------------------ + +test('a schedule runs, then reports sent', () => { + const schedule = makeSchedule(); + + const running = markQuotaScheduleRunning(schedule.id); + assert.equal(running.status, 'running'); + assert.ok(Date.parse(running.startedAt)); + + const sent = markQuotaScheduleSent(schedule.id); + assert.equal(sent.status, 'sent'); + assert.ok(Date.parse(sent.sentAt)); + assert.equal(sent.error, null); +}); + +test('a failure keeps its reason', () => { + const schedule = makeSchedule(); + const failed = markQuotaScheduleFailed(schedule.id, new Error('agent exited 1')); + assert.equal(failed.status, 'failed'); + assert.match(failed.error, /agent exited 1/); + + assert.equal(markQuotaScheduleFailed('unknown-id', 'x'), null); +}); + +test('only a pending message can be cancelled', () => { + const schedule = makeSchedule(); + markQuotaScheduleRunning(schedule.id); + + assert.equal(caught(() => cancelQuotaSchedule(schedule.id)).code, 'SCHEDULE_NOT_PENDING'); + assert.equal(cancelQuotaSchedule('unknown-id'), null); +}); + +test('schedules left running by a stopped server are failed on startup', () => { + const running = makeSchedule(); + const pending = makeSchedule({ workdir: '/work/other' }); + markQuotaScheduleRunning(running.id); + + assert.equal(reconcileRunningSchedules(), 1); + // A second pass has nothing left to do. + assert.equal(reconcileRunningSchedules(), 0); + + const byId = new Map(listQuotaSchedules().map((item) => [item.id, item])); + assert.equal(byId.get(running.id).status, 'failed'); + assert.match(byId.get(running.id).error, /server stopped/); + assert.equal(byId.get(pending.id).status, 'pending'); +}); + +// --- due detection ----------------------------------------------------------- + +test('due detection matches the source, the grace window, and pending only', () => { + const now = new Date('2026-07-27T12:00:00.000Z'); + const soon = makeSchedule({ + targetResetsAt: new Date(now.getTime() + 5 * 60 * 1000).toISOString(), + }); + const later = makeSchedule({ + workdir: '/work/later', + targetResetsAt: new Date(now.getTime() + 60 * 60 * 1000).toISOString(), + }); + const untargeted = makeSchedule({ workdir: '/work/untargeted' }); + const otherSource = makeSchedule({ sourceKey: 'codex', agentKey: 'codex' }); + + const due = dueQuotaSchedulesForReset('claude', now).map((item) => item.id); + // Inside the 10-minute grace window, and "next reset, whenever it is". + assert.ok(due.includes(soon.id)); + assert.ok(due.includes(untargeted.id)); + assert.ok(!due.includes(later.id)); + assert.ok(!due.includes(otherSource.id)); + + // Once it is running it is no longer due. + markQuotaScheduleRunning(soon.id); + assert.ok(!dueQuotaSchedulesForReset('claude', now).some((item) => item.id === soon.id)); + + assert.deepEqual(dueQuotaSchedulesForReset('', now), []); +}); + +// --- file growth ------------------------------------------------------------- + +test('finished records are capped while live ones are always kept', () => { + for (let i = 0; i < 60; i += 1) { + const schedule = makeSchedule({ workdir: `/work/w${i}` }); + cancelQuotaSchedule(schedule.id); + } + const pending = makeSchedule({ workdir: '/work/live' }); + + const onDisk = JSON.parse(fs.readFileSync(schedulesFile, 'utf-8')); + assert.equal(onDisk.filter((item) => item.status === 'cancelled').length, 50); + assert.equal(onDisk.filter((item) => item.id === pending.id).length, 1); +}); diff --git a/server/test/tokens.test.js b/server/test/tokens.test.js new file mode 100644 index 0000000..d98f970 --- /dev/null +++ b/server/test/tokens.test.js @@ -0,0 +1,276 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { afterEach, after, test } = require('node:test'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +// Every /api/* route is gated by this module, so its acceptance rules are the +// authentication boundary described in SECURITY.md. + +const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-tokens-test-')); +const tokensFile = path.join(tempDir, 'tokens.json'); +process.env.RELAY_TOKENS_FILE = tokensFile; + +const { + TOKENS_FILE, + createToken, + deleteRevokedTokenById, + hasConfiguredToken, + isTokenAllowed, + isTokenIdAllowed, + listTokenSummaries, + markTokenUsed, + revokeToken, + revokeTokenById, + tokenRecordForToken, +} = require('../lib/tokens'); + +afterEach(() => { + fs.rmSync(tokensFile, { force: true }); +}); + +after(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + delete process.env.RELAY_TOKENS_FILE; +}); + +function readFile() { + return JSON.parse(fs.readFileSync(tokensFile, 'utf-8')); +} + +test('RELAY_TOKENS_FILE redirects the store', () => { + assert.equal(TOKENS_FILE, tokensFile); +}); + +// --- creation ---------------------------------------------------------------- + +test('no configured token until one is created', () => { + assert.equal(hasConfiguredToken(), false); + createToken({ label: 'Phone' }); + assert.equal(hasConfiguredToken(), true); +}); + +test('a created token has a random secret and an unrevoked record', () => { + const first = createToken({ label: 'Phone' }); + const second = createToken({ label: 'Laptop' }); + + assert.notEqual(first.token, second.token); + // 32 random bytes, base64url encoded. + assert.match(first.token, /^[A-Za-z0-9_-]{43}$/); + assert.notEqual(first.id, second.id); + assert.equal(first.revoked, false); + assert.ok(Date.parse(first.createdAt)); + assert.equal(readFile().length, 2); +}); + +test('a blank label falls back to a placeholder', () => { + assert.equal(createToken({ label: ' ' }).label, 'Unnamed device'); + assert.equal(createToken({}).label, 'Unnamed device'); +}); + +test('the token file is written owner-only', { skip: process.platform === 'win32' }, () => { + createToken({ label: 'Phone' }); + assert.equal(fs.statSync(tokensFile).mode & 0o077, 0); +}); + +// --- acceptance -------------------------------------------------------------- + +test('only the exact token value is accepted', () => { + const record = createToken({ label: 'Phone' }); + + assert.equal(isTokenAllowed(record.token), true); + // Surrounding whitespace from a header is tolerated. + assert.equal(isTokenAllowed(` ${record.token} `), true); + + assert.equal(isTokenAllowed(''), false); + assert.equal(isTokenAllowed(null), false); + assert.equal(isTokenAllowed(undefined), false); + // A prefix must not pass: the digest comparison is what makes a partial match + // worthless rather than a step towards guessing the rest. + assert.equal(isTokenAllowed(record.token.slice(0, -1)), false); + assert.equal(isTokenAllowed(`${record.token}x`), false); + assert.equal(isTokenAllowed(record.token.toUpperCase()), false); +}); + +test('candidates of any length are compared without throwing', () => { + createToken({ label: 'Phone' }); + // timingSafeEqual needs equal-length buffers; hashing both sides first is what + // keeps a short or overlong candidate from crashing the auth check. + assert.equal(isTokenAllowed('x'), false); + assert.equal(isTokenAllowed('y'.repeat(10000)), false); +}); + +test('each active token is accepted independently', () => { + const first = createToken({ label: 'Phone' }); + const second = createToken({ label: 'Laptop' }); + assert.equal(isTokenAllowed(first.token), true); + assert.equal(isTokenAllowed(second.token), true); +}); + +test('token ids are accepted only while the token is active', () => { + const record = createToken({ label: 'Phone' }); + assert.equal(isTokenIdAllowed(record.id), true); + assert.equal(isTokenIdAllowed('not-an-id'), false); + assert.equal(isTokenIdAllowed(''), false); + + revokeTokenById(record.id); + assert.equal(isTokenIdAllowed(record.id), false); +}); + +test('a record with an empty token value is never active', () => { + fs.writeFileSync( + tokensFile, + JSON.stringify([{ id: 'blank', token: ' ', revoked: false }]), + ); + assert.equal(hasConfiguredToken(), false); + assert.equal(isTokenAllowed(''), false); + assert.equal(isTokenAllowed(' '), false); +}); + +test('a malformed token file degrades to "no tokens" instead of accepting anything', () => { + fs.writeFileSync(tokensFile, '{"not":"an array"}'); + assert.equal(hasConfiguredToken(), false); + assert.equal(isTokenAllowed('anything'), false); +}); + +// --- revocation and deletion ------------------------------------------------- + +test('revoking by id stops the token from being accepted', () => { + const record = createToken({ label: 'Phone' }); + const revoked = revokeTokenById(record.id); + + assert.equal(revoked.revoked, true); + assert.ok(Date.parse(revoked.revokedAt)); + assert.equal(isTokenAllowed(record.token), false); + assert.equal(hasConfiguredToken(), false); +}); + +test('revokeToken accepts either the id or the token value', () => { + const byId = createToken({ label: 'Phone' }); + const byValue = createToken({ label: 'Laptop' }); + + assert.equal(revokeToken(byId.id).revoked, true); + assert.equal(revokeToken(byValue.token).revoked, true); + assert.equal(revokeToken('unknown'), null); + assert.equal(revokeToken(''), null); + assert.equal(isTokenAllowed(byId.token), false); + assert.equal(isTokenAllowed(byValue.token), false); +}); + +test('only a revoked record can be deleted', () => { + const record = createToken({ label: 'Phone' }); + + assert.equal(deleteRevokedTokenById(record.id), false); + assert.equal(readFile().length, 1); + assert.equal(deleteRevokedTokenById('unknown'), null); + + revokeTokenById(record.id); + assert.equal(deleteRevokedTokenById(record.id).id, record.id); + assert.equal(readFile().length, 0); +}); + +// --- summaries --------------------------------------------------------------- + +test('summaries never expose the token value and mark the calling device', () => { + const mine = createToken({ label: 'Phone' }); + createToken({ label: 'Laptop' }); + + const summaries = listTokenSummaries({ currentToken: mine.token }); + assert.equal(summaries.length, 2); + for (const summary of summaries) { + assert.equal('token' in summary, false); + } + assert.deepEqual( + summaries.map((summary) => [summary.label, summary.current]), + [['Phone', true], ['Laptop', false]], + ); + + // Without a current token, nothing is marked as current. + assert.equal( + listTokenSummaries().every((summary) => summary.current === false), + true, + ); +}); + +test('a revoked token still appears in summaries with its revocation time', () => { + const record = createToken({ label: 'Phone' }); + revokeTokenById(record.id); + + const [summary] = listTokenSummaries(); + assert.equal(summary.revoked, true); + assert.ok(Date.parse(summary.revokedAt)); +}); + +test('tokenRecordForToken matches only an exact value', () => { + const record = createToken({ label: 'Phone' }); + assert.equal(tokenRecordForToken(record.token).id, record.id); + assert.equal(tokenRecordForToken(record.token.slice(0, -1)), null); + assert.equal(tokenRecordForToken(''), null); +}); + +// --- last-use metadata ------------------------------------------------------- + +test('device metadata is recorded and trimmed', () => { + const record = createToken({ label: 'Phone' }); + const used = markTokenUsed(record.token, { + deviceId: ' device-1\n', + deviceName: 'My Phone ', + }); + + assert.equal(used.lastDeviceId, 'device-1'); + assert.equal(used.lastDeviceName, 'My Phone'); + assert.ok(Date.parse(used.lastUsedAt)); + assert.equal(readFile()[0].lastDeviceId, 'device-1'); +}); + +test('overlong device metadata is capped', () => { + const record = createToken({ label: 'Phone' }); + const used = markTokenUsed(record.token, { + deviceId: 'i'.repeat(200), + deviceName: 'n'.repeat(500), + }); + assert.equal(used.lastDeviceId.length, 80); + assert.equal(used.lastDeviceName.length, 160); +}); + +test('repeat use by the same device does not rewrite the file every request', () => { + const record = createToken({ label: 'Phone' }); + const device = { deviceId: 'device-1', deviceName: 'Phone' }; + const start = new Date('2026-07-27T10:00:00.000Z'); + + markTokenUsed(record.token, { ...device, now: start }); + // Well inside the write interval: the timestamp is left alone. + markTokenUsed(record.token, { + ...device, + now: new Date(start.getTime() + 30 * 1000), + }); + assert.equal(readFile()[0].lastUsedAt, start.toISOString()); + + // Past the interval: the timestamp moves forward. + const later = new Date(start.getTime() + 61 * 1000); + markTokenUsed(record.token, { ...device, now: later }); + assert.equal(readFile()[0].lastUsedAt, later.toISOString()); +}); + +test('a different device is recorded immediately', () => { + const record = createToken({ label: 'Phone' }); + const start = new Date('2026-07-27T10:00:00.000Z'); + markTokenUsed(record.token, { deviceId: 'device-1', deviceName: 'Phone', now: start }); + + const soon = new Date(start.getTime() + 1000); + markTokenUsed(record.token, { deviceId: 'device-2', deviceName: 'Tablet', now: soon }); + assert.equal(readFile()[0].lastDeviceId, 'device-2'); + assert.equal(readFile()[0].lastUsedAt, soon.toISOString()); +}); + +test('marking use of an unknown or revoked token changes nothing', () => { + const record = createToken({ label: 'Phone' }); + assert.equal(markTokenUsed('not-a-token'), null); + assert.equal(markTokenUsed(''), null); + + revokeTokenById(record.id); + assert.equal(markTokenUsed(record.token, { deviceId: 'device-1' }), null); + assert.equal(readFile()[0].lastDeviceId, undefined); +}); diff --git a/server/test/usage.test.js b/server/test/usage.test.js index 1c5be66..185700d 100644 --- a/server/test/usage.test.js +++ b/server/test/usage.test.js @@ -3,102 +3,7 @@ const assert = require('node:assert/strict'); const { test } = require('node:test'); -const { normalizeAgyQuotaSummary, markExpiredQuotas } = require('../lib/usage'); - -const SAMPLE_AGY_SUMMARY = { - response: { - groups: [ - { - displayName: 'Gemini Models', - description: 'Models within this group: Gemini Flash, Gemini Pro', - buckets: [ - { - bucketId: 'gemini-weekly', - displayName: 'Weekly Limit', - description: 'Refreshes in 1 day, 5 hours.', - window: 'weekly', - remainingFraction: 0.89087933, - resetTime: '2026-06-19T03:26:58Z', - }, - { - bucketId: 'gemini-5h', - displayName: 'Five Hour Limit', - window: '5h', - remainingFraction: 0.9260299, - resetTime: '2026-06-17T23:12:49Z', - }, - ], - }, - { - displayName: 'Claude and GPT models', - description: 'Models within this group: Claude Opus, Claude Sonnet, GPT-OSS', - buckets: [ - { - bucketId: '3p-weekly', - displayName: 'Weekly Limit', - window: 'weekly', - remainingFraction: 1, - resetTime: '2026-06-24T21:19:41Z', - }, - { - bucketId: '3p-5h', - displayName: 'Five Hour Limit', - window: '5h', - remainingFraction: 0.75, - resetTime: '2026-06-18T02:19:41Z', - }, - ], - }, - ], - }, -}; - -test('normalizeAgyQuotaSummary selects Gemini quota group for Gemini models', () => { - const out = normalizeAgyQuotaSummary( - SAMPLE_AGY_SUMMARY, - 'Gemini 3.5 Flash (High)', - ); - - assert.equal(out.plan, 'Gemini Models'); - assert.equal(out.five_hour.resets_at, '2026-06-17T23:12:49.000Z'); - assert.equal(out.seven_day.resets_at, '2026-06-19T03:26:58.000Z'); - assert.equal(Number(out.five_hour.utilization.toFixed(5)), 7.39701); - assert.equal(Number(out.seven_day.utilization.toFixed(5)), 10.91207); -}); - -test('normalizeAgyQuotaSummary selects third-party quota group for Claude/GPT models', () => { - const out = normalizeAgyQuotaSummary( - SAMPLE_AGY_SUMMARY, - 'Claude Sonnet 4.6 (Thinking)', - ); - - assert.equal(out.plan, 'Claude and GPT models'); - assert.equal(out.five_hour.resets_at, '2026-06-18T02:19:41.000Z'); - assert.equal(out.seven_day.resets_at, '2026-06-24T21:19:41.000Z'); - assert.equal(out.five_hour.utilization, 25); - assert.equal(out.seven_day.utilization, 0); -}); - -test('normalizeAgyQuotaSummary rejects missing quota groups', () => { - assert.throws( - () => normalizeAgyQuotaSummary({ response: { groups: [] } }, 'Gemini 3.5 Flash (High)'), - /did not include quota groups/, - ); -}); - -test('normalizeAgyQuotaSummary prefers compact subscription labels', () => { - const out = normalizeAgyQuotaSummary( - { - response: { - plan: 'Google AI Pro', - groups: SAMPLE_AGY_SUMMARY.response.groups, - }, - }, - 'Gemini 3.5 Flash (High)', - ); - - assert.equal(out.plan, 'Pro'); -}); +const { markExpiredQuotas } = require('../lib/usage'); const NOW = Date.parse('2026-06-19T12:00:00Z'); diff --git a/test/agent_controls_test.dart b/test/agent_controls_test.dart index 43ab901..8dbd19d 100644 --- a/test/agent_controls_test.dart +++ b/test/agent_controls_test.dart @@ -7,10 +7,15 @@ import 'package:relay/core/settings/app_settings_controller.dart'; import 'package:relay/features/chat/agent_controls.dart'; void main() { + // The catalog cache outlives a widget on purpose, so each test starts from a + // cold one instead of inheriting the previous test's fetch. + setUp(clearAgentOptionsCache); + Future pumpControls( WidgetTester tester, - _OptionsBackendClient backend, - ) async { + _OptionsBackendClient backend, { + bool settle = true, + }) async { final AppSettingsController settings = AppSettingsController(); addTearDown(settings.dispose); addTearDown(backend.close); @@ -27,7 +32,7 @@ void main() { ), ), ); - await tester.pumpAndSettle(); + if (settle) await tester.pumpAndSettle(); } testWidgets('effort page filters choices by the selected model', ( @@ -86,7 +91,7 @@ void main() { expect(backend.settingsFetches, 1); }); - testWidgets('returning from an option page reloads the parent controls', ( + testWidgets('returning from an option page adopts the saved selection', ( WidgetTester tester, ) async { final _OptionsBackendClient backend = _OptionsBackendClient(); @@ -94,12 +99,37 @@ void main() { await tester.tap(find.text('Model')); await tester.pumpAndSettle(); - await tester.tap(find.text('GPT New')); + await tester.tap(find.text('GPT Lite')); await tester.pumpAndSettle(); + // The page already returned the saved settings, so the controls take them + // as-is instead of refetching the catalog and the settings again. expect(backend.settingUpdates, 1); - expect(backend.optionsFetches, 2); - expect(backend.settingsFetches, 2); + expect(backend.optionsFetches, 1); + expect(backend.settingsFetches, 1); + + // Reopening shows the new selection, which is what the refetch was for. + await tester.tap(find.text('Model')); + await tester.pumpAndSettle(); + final ListTile lite = tester.widget( + find.widgetWithText(ListTile, 'GPT Lite'), + ); + expect((lite.leading! as Icon).icon, Icons.radio_button_checked_rounded); + }); + + testWidgets('a cached catalog renders the controls without a spinner', ( + WidgetTester tester, + ) async { + await pumpControls(tester, _OptionsBackendClient()); + // Unmount, then mount again: what happens every time the composer's action + // panel closes and reopens. + await tester.pumpWidget(const SizedBox.shrink()); + await pumpControls(tester, _OptionsBackendClient(), settle: false); + + // First frame, before the refresh lands: buttons already at final size. + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text('Model'), findsOneWidget); + await tester.pumpAndSettle(); }); testWidgets('fast switch updates the Codex fast setting', ( diff --git a/test/chat_search_jump_test.dart b/test/chat_search_jump_test.dart new file mode 100644 index 0000000..f9a94c9 --- /dev/null +++ b/test/chat_search_jump_test.dart @@ -0,0 +1,288 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:relay/core/backend/backend_client.dart'; +import 'package:relay/core/i18n/app_strings.dart'; +import 'package:relay/core/models/agent_session.dart'; +import 'package:relay/core/models/chat_message.dart'; +import 'package:relay/core/models/cli_agent.dart'; +import 'package:relay/core/models/machine_credential.dart'; +import 'package:relay/core/settings/app_settings_controller.dart'; +import 'package:relay/core/storage/machine_credentials_store.dart'; +import 'package:relay/features/chat/bot_chat_controller.dart'; +import 'package:relay/features/chat/bot_chat_screen.dart'; +import 'package:relay/features/cli_agents/cli_agents_controller.dart'; +import 'package:relay/features/machines/machine_credentials_controller.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +// The hit sits near the start of a long conversation, so it can only show up on +// screen if the jump actually scrolled the (lazily built, reversed) list there. +const int _hitIndex = 3; +const int _messageCount = 60; +const String _term = 'deployment'; + +void main() { + group('search chats jump', () { + testWidgets('scrolls to the matched message and marks the term', ( + WidgetTester tester, + ) async { + final CliAgentsController agents = await _openChat(tester); + + // The conversation opens on the newest message, far from the hit. + expect(_hitParagraph(), findsNothing); + + await _pickTheOnlyHit(tester); + + // The matched message is on screen… + expect(_hitParagraph(), findsOneWidget); + final Rect hit = tester.getRect(_hitParagraph()); + expect(hit.top, greaterThanOrEqualTo(0)); + expect(hit.bottom, lessThanOrEqualTo(844)); + + // …with the search term marked inside it. + expect(_markedTextIn(tester, _hitParagraph()), [_term]); + expect(agents.activeAgentKey, 'claude'); + + // The mark is temporary: it clears itself shortly after. + await tester.pump(const Duration(seconds: 3)); + await tester.pumpAndSettle(); + expect(_markedTextIn(tester, _hitParagraph()), isEmpty); + expect(tester.takeException(), isNull); + }); + + testWidgets('moves the active agent when the hit belongs to another one', ( + WidgetTester tester, + ) async { + final CliAgentsController agents = await _openChat( + tester, + hitAgentKey: 'codex', + ); + expect(agents.activeAgentKey, 'claude'); + + await _pickTheOnlyHit(tester); + + // Both the chat and the agent selection follow the hit; leaving the + // agents controller behind would let the next context sync load the old + // agent's conversation back over the one we just jumped to. + expect(agents.activeAgentKey, 'codex'); + expect(_hitParagraph(), findsOneWidget); + expect(_markedTextIn(tester, _hitParagraph()), [_term]); + expect(tester.takeException(), isNull); + }); + }); +} + +/// Builds the chat screen over a fake backend holding one long conversation. +Future _openChat( + WidgetTester tester, { + String hitAgentKey = 'claude', +}) async { + SharedPreferences.setMockInitialValues({}); + MachineCredentialsStore.resetCacheForTest(); + + final MachineCredential machine = MachineCredential( + id: 'machine-1', + name: 'Local test', + baseUrl: 'http://127.0.0.1:8787', + token: 'token', + createdAt: DateTime.utc(2026).toIso8601String(), + ); + final CliAgentsController agentsController = CliAgentsController(); + final MachineCredentialsController machinesController = + MachineCredentialsController( + store: _MemoryMachineCredentialsStore(machine), + ); + final AppSettingsController settingsController = AppSettingsController(); + final BotChatController chatController = BotChatController( + backendClient: _SearchBackendClient(hitAgentKey), + ); + addTearDown(chatController.disposeController); + + await agentsController.load(); + await machinesController.load(); + await chatController.loadFor(defaultCliAgents.first, machine); + + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + AppScope( + controller: settingsController, + child: MaterialApp( + home: BotChatScreen( + agentsController: agentsController, + chatController: chatController, + machinesController: machinesController, + settingsController: settingsController, + ), + ), + ), + ); + await tester.pumpAndSettle(); + return agentsController; +} + +Future _pickTheOnlyHit(WidgetTester tester) async { + await tester.tap(find.byIcon(Icons.search_rounded)); + await tester.pumpAndSettle(); + await tester.enterText( + find.descendant( + of: find.byType(AlertDialog), + matching: find.byType(TextField), + ), + _term, + ); + await tester.tap(find.widgetWithText(TextButton, 'Search chats')); + await tester.pumpAndSettle(); + await tester.tap(find.textContaining('…$_term…')); + await tester.pumpAndSettle(); +} + +Finder _hitParagraph() { + return find.byWidgetPredicate( + (Widget widget) => + widget is RichText && widget.text.toPlainText().contains('#$_hitIndex '), + ); +} + +List _markedTextIn(WidgetTester tester, Finder finder) { + final List marked = []; + void walk(InlineSpan span) { + if (span is! TextSpan) return; + if (span.text != null && span.style?.backgroundColor != null) { + marked.add(span.text!); + } + for (final InlineSpan child in span.children ?? const []) { + walk(child); + } + } + + for (final RichText text in tester.widgetList(finder)) { + walk(text.text); + } + return marked; +} + +class _MemoryMachineCredentialsStore extends MachineCredentialsStore { + _MemoryMachineCredentialsStore(this.machine); + + final MachineCredential machine; + String? _activeId; + + @override + Future> readAll() async { + _activeId ??= machine.id; + return [machine]; + } + + @override + Future readActiveId() async { + _activeId ??= machine.id; + return _activeId; + } + + @override + Future setActive(String id) async { + _activeId = id; + } + + @override + Future upsert( + MachineCredential credential, { + bool makeActive = true, + }) async {} + + @override + Future delete(String id) async { + if (_activeId == id) _activeId = null; + } +} + +class _SearchBackendClient extends BackendClient { + _SearchBackendClient(this.hitAgentKey); + + /// The agent whose history holds the match. Only that agent's conversation + /// contains the searched term. + final String hitAgentKey; + + @override + Future fetchSessions(String agentKey) async { + return _list(agentKey); + } + + @override + Future selectSession( + String agentKey, + String sessionId, + ) async { + return _list(agentKey); + } + + @override + Future> fetchHistory( + String agentKey, { + required String sessionId, + }) async { + final bool holdsHit = agentKey == hitAgentKey; + return [ + for (int i = 0; i < _messageCount; i += 1) + ChatMessage( + id: '$agentKey-msg-$i', + role: i.isEven ? ChatRole.user : ChatRole.assistant, + content: holdsHit && i == _hitIndex + ? 'Note #$i about the $_term pipeline.' + : 'Note #$i about something else entirely.', + createdAt: DateTime.utc(2026, 1, 1).add(Duration(minutes: i)), + ), + ]; + } + + @override + Future> searchHistory( + String query, { + String? agentKey, + }) async { + return [ + ChatHistorySearchResult( + agentKey: hitAgentKey, + sessionId: AgentSession.defaultId, + sessionName: 'Main', + snippet: '…$_term…', + messageId: '$hitAgentKey-msg-$_hitIndex', + ), + ]; + } + + @override + Future> fetchAuthStatus() async { + return const {}; + } + + @override + Future> fetchAgents() async => defaultCliAgents; + + // A stream that stays open: an empty one completes at once and the controller + // then schedules a reconnect timer that outlives the test. + final StreamController _events = + StreamController.broadcast(); + + @override + Stream streamEvents() => _events.stream; + + @override + Future close() async { + await _events.close(); + } + + AgentSessionList _list(String agentKey) { + return AgentSessionList( + agentKey: agentKey, + workdir: '/repo', + activeSessionId: AgentSession.defaultId, + sessions: [AgentSession.fallback()], + ); + } +} diff --git a/test/cli_agent_test.dart b/test/cli_agent_test.dart index 9c0990f..b7602b1 100644 --- a/test/cli_agent_test.dart +++ b/test/cli_agent_test.dart @@ -1,9 +1,8 @@ -import 'dart:async'; - import 'package:flutter_test/flutter_test.dart'; -import 'package:relay/core/backend/backend_client.dart'; +import 'package:relay/core/i18n/app_strings.dart'; +import 'package:relay/core/settings/app_settings_controller.dart'; import 'package:relay/core/models/cli_agent.dart'; -import 'package:relay/features/machines/agent_login_flow_controller.dart'; +import 'package:relay/features/cli_agents/agent_status_lights.dart'; void main() { test('parses agent status fields from backend payload', () { @@ -86,130 +85,82 @@ void main() { expect(isCliAgentSelectable(ready), true); }); - test('agent login flow tracks URL, submitted code, and completion', () async { - final StreamController events = - StreamController(); - String? submittedSessionId; - String? submittedCode; - final AgentLoginFlowController controller = AgentLoginFlowController( - startLogin: (_) => events.stream, - submitCode: (String sessionId, String code) async { - submittedSessionId = sessionId; - submittedCode = code; - }, - ); - addTearDown(() async { - controller.dispose(); - await events.close(); + test('reads the credential expiry from the backend payload', () { + final CliAgent agent = CliAgent.fromJson({ + 'key': 'claude', + 'label': 'Claude Code', + 'description': 'Anthropic Claude Code CLI', + 'credentialExpiresAt': 1893456000000, + }); + final CliAgent legacy = CliAgent.fromJson({ + 'key': 'codex', + 'label': 'Codex', + 'description': 'OpenAI Codex CLI', }); - await controller.start('codex'); - events.add( - const BackendEvent( - type: 'login_started', - data: {'sessionId': 's1', 'agent': 'codex'}, - ), - ); - await pumpEventQueue(); - - expect(controller.phase, AgentLoginPhase.waitingForUrl); - expect(controller.sessionId, 's1'); - - events.add( - const BackendEvent( - type: 'login_url', - data: { - 'sessionId': 's1', - 'agent': 'codex', - 'url': 'https://example.test/login', - }, - ), - ); - await pumpEventQueue(); - - expect(controller.phase, AgentLoginPhase.readyForCode); - expect(controller.url, 'https://example.test/login'); - - await controller.submitCode(' abc123 '); - - expect(controller.phase, AgentLoginPhase.submitting); - expect(submittedSessionId, 's1'); - expect(submittedCode, 'abc123'); - - events.add( - const BackendEvent( - type: 'login_done', - data: {'sessionId': 's1', 'agent': 'codex'}, - ), + expect( + agent.credentialExpiresAt, + DateTime.fromMillisecondsSinceEpoch(1893456000000), ); - await pumpEventQueue(); - - expect(controller.phase, AgentLoginPhase.done); + expect(legacy.credentialExpiresAt, isNull); }); - test('agent login flow surfaces stream errors', () async { - final AgentLoginFlowController controller = AgentLoginFlowController( - startLogin: (_) => Stream.error( - BackendException('could not start'), - ), - submitCode: (_, __) async {}, - ); - addTearDown(controller.dispose); - - await controller.start('claude'); - await pumpEventQueue(); - - expect(controller.phase, AgentLoginPhase.error); - expect(controller.error, 'could not start'); + test('counts whole days on both sides of the credential expiry', () { + final DateTime now = DateTime(2026, 8, 16, 12); + CredentialExpiry expiryAfter(Duration offset) => + CredentialExpiry.at(now.add(offset), now: now); + + expect(expiryAfter(const Duration(days: 7)).days, 7); + expect(expiryAfter(const Duration(days: 7)).expired, false); + // Truncates, so a day and a half of runway still reads as one full day. + expect(expiryAfter(const Duration(days: 1, hours: 12)).days, 1); + expect(expiryAfter(const Duration(hours: 3)).days, 0); + expect(expiryAfter(const Duration(hours: 3)).expired, false); + expect(expiryAfter(const Duration(hours: -3)).expired, true); + expect(expiryAfter(const Duration(hours: -3)).days, 0); + expect(expiryAfter(const Duration(days: -3, hours: -1)).days, 3); + expect(expiryAfter(const Duration(days: -3, hours: -1)).expired, true); }); - test('agent login flow supports browser-only OAuth without code entry', - () async { - final StreamController events = - StreamController(); - bool submitted = false; - final AgentLoginFlowController controller = AgentLoginFlowController( - startLogin: (_) => events.stream, - submitCode: (_, __) async { - submitted = true; - }, + test('describes the expiry only for agents that report one', () { + const AppStrings strings = AppStrings(AppLanguage.en); + final DateTime now = DateTime(2026, 8, 16, 12); + CliAgent claudeExpiring(Duration offset) => CliAgent( + key: 'claude', + label: 'Claude Code', + description: 'Anthropic Claude Code CLI', + authKind: 'oauth', + credentialExpiresAt: now.add(offset), + ); + + expect( + agentCredentialExpiryMessage( + strings, + claudeExpiring(const Duration(days: 12)), + now: now, + ), + 'Log in again in 12 days', ); - addTearDown(() async { - controller.dispose(); - await events.close(); - }); - - await controller.start('agy'); - events.add( - const BackendEvent( - type: 'login_started', - data: { - 'sessionId': 's1', - 'agent': 'agy', - 'requiresCode': false, - }, + expect( + agentCredentialExpiryMessage( + strings, + claudeExpiring(const Duration(days: -2)), + now: now, ), + 'Expired 2 days ago. Log in again on the backend host.', ); - events.add( - const BackendEvent( - type: 'login_url', - data: { - 'sessionId': 's1', - 'agent': 'agy', - 'requiresCode': false, - 'url': 'https://accounts.google.com/o/oauth2/auth', - }, + expect( + agentCredentialExpiryMessage( + strings, + const CliAgent( + key: 'opencode', + label: 'OpenCode', + description: 'OpenCode CLI', + authKind: 'apiKeyOptional', + ), + now: now, ), + isNull, ); - await pumpEventQueue(); - - expect(controller.requiresCode, false); - expect(controller.canSubmitCode, false); - expect(controller.phase, AgentLoginPhase.readyForCode); - - await controller.submitCode('unused'); - - expect(submitted, false); - expect(controller.phase, AgentLoginPhase.error); }); } diff --git a/test/machine_credentials_screen_test.dart b/test/machine_credentials_screen_test.dart index c1a18a6..f894cd8 100644 --- a/test/machine_credentials_screen_test.dart +++ b/test/machine_credentials_screen_test.dart @@ -2,9 +2,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:relay/core/backend/backend_client.dart'; import 'package:relay/core/i18n/app_strings.dart'; +import 'package:relay/core/models/cli_agent.dart'; import 'package:relay/core/models/machine_credential.dart'; import 'package:relay/core/settings/app_settings_controller.dart'; import 'package:relay/core/storage/machine_credentials_store.dart'; +import 'package:relay/features/cli_agents/cli_agents_controller.dart'; import 'package:relay/features/machines/machine_credentials_controller.dart'; import 'package:relay/features/machines/machine_credentials_screen.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -107,6 +109,69 @@ void main() { tester.widget(find.byType(TerminalView)).terminal; expect(identical(firstTerminal, secondTerminal), isTrue); }); + testWidgets('agent credentials report expiry instead of a login action', ( + WidgetTester tester, + ) async { + SharedPreferences.setMockInitialValues({}); + MachineCredentialsStore.resetCacheForTest(); + tester.view.physicalSize = const Size(1200, 1600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + const MachineCredential credential = MachineCredential( + id: 'machine-1', + name: 'Test machine', + baseUrl: 'https://relay.example.com', + token: 'device-token', + createdAt: '2026-07-13T00:00:00.000Z', + ); + final MachineCredentialsController machinesController = + MachineCredentialsController( + store: _MemoryMachineCredentialsStore(credential), + ); + await machinesController.load(); + + final DateTime now = DateTime.now(); + final CliAgentsController agentsController = CliAgentsController() + ..syncAgents([ + CliAgent( + key: 'claude', + label: 'Claude Code', + description: 'Anthropic Claude Code CLI', + authKind: 'oauth', + credentialExpiresAt: now.add(const Duration(days: 12, hours: 1)), + ), + CliAgent( + key: 'codex', + label: 'Codex', + description: 'OpenAI Codex CLI', + authKind: 'oauth', + credentialExpiresAt: now.subtract(const Duration(days: 2, hours: 1)), + ), + ]); + + await tester.pumpWidget( + AppScope( + controller: AppSettingsController(), + child: MaterialApp( + home: MachineCredentialsScreen( + machinesController: machinesController, + agentsController: agentsController, + backendClient: _FailingTerminalBackendClient(), + ), + ), + ), + ); + + expect(find.text('Log in again in 12 days'), findsOneWidget); + expect( + find.text('Expired 2 days ago. Log in again on the backend host.'), + findsOneWidget, + ); + expect(find.widgetWithText(FilledButton, 'Log in'), findsNothing); + expect(find.widgetWithText(FilledButton, 'Log in again'), findsNothing); + }); } class _FailingTerminalBackendClient extends BackendClient { diff --git a/test/message_highlight_test.dart b/test/message_highlight_test.dart new file mode 100644 index 0000000..cf46a6e --- /dev/null +++ b/test/message_highlight_test.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:relay/features/chat/chat_content.dart'; + +// Collects every span the widget tree paints, so a test can assert which slice +// of text carries the search-hit background. +List _spans(WidgetTester tester) { + final List found = []; + void walk(InlineSpan span) { + if (span is TextSpan) { + if (span.text != null) found.add(span); + for (final InlineSpan child in span.children ?? const []) { + walk(child); + } + } + } + + for (final RichText text in tester.widgetList(find.byType(RichText))) { + walk(text.text); + } + return found; +} + +Iterable _markedText(WidgetTester tester) => _spans(tester) + .where((TextSpan span) => span.style?.backgroundColor != null) + .map((TextSpan span) => span.text!); + +Future _pump(WidgetTester tester, Widget child) { + return tester.pumpWidget( + MaterialApp(home: Scaffold(body: Center(child: child))), + ); +} + +void main() { + group('MessageText search highlight', () { + testWidgets('marks the term in the plain-text path', ( + WidgetTester tester, + ) async { + await _pump( + tester, + const MessageText( + text: 'deploy the Relay backend today', + color: Colors.black, + formatInlineEmphasis: false, + highlightQuery: 'relay', + ), + ); + + expect(_markedText(tester), ['Relay']); + expect( + _spans(tester).map((TextSpan span) => span.text).join(), + 'deploy the Relay backend today', + ); + }); + + testWidgets('marks every occurrence, case-insensitively', ( + WidgetTester tester, + ) async { + await _pump( + tester, + const MessageText( + text: 'Relay, then relay again', + color: Colors.black, + formatInlineEmphasis: false, + highlightQuery: 'RELAY', + ), + ); + + expect(_markedText(tester), ['Relay', 'relay']); + }); + + testWidgets('marks the term inside rendered markdown', ( + WidgetTester tester, + ) async { + await _pump( + tester, + const MessageText( + text: 'The **deploy** step restarts the backend service.', + color: Colors.black, + formatInlineEmphasis: true, + highlightQuery: 'backend', + ), + ); + + expect(_markedText(tester), ['backend']); + // The surrounding markdown still renders: "deploy" stays bold and the + // literal asterisks are gone. + final Iterable bold = _spans(tester).where( + (TextSpan span) => span.style?.fontWeight == FontWeight.w700, + ); + expect(bold.map((TextSpan span) => span.text), contains('deploy')); + expect( + _spans(tester).map((TextSpan span) => span.text).join(), + isNot(contains('**')), + ); + }); + + testWidgets('renders unmarked when no query is set', ( + WidgetTester tester, + ) async { + await _pump( + tester, + const MessageText( + text: 'The **deploy** step restarts the backend service.', + color: Colors.black, + formatInlineEmphasis: true, + ), + ); + + expect(_markedText(tester), isEmpty); + }); + }); +} diff --git a/test/models_test.dart b/test/models_test.dart index 91c856b..ec95e30 100644 --- a/test/models_test.dart +++ b/test/models_test.dart @@ -55,9 +55,9 @@ void main() { test('memberLabels resolves known agent keys', () { final ChatGroup g = ChatGroup.fromJson({ - 'members': ['claude', 'codex', 'agy'], + 'members': ['claude', 'codex', 'opencode'], }); - expect(g.memberLabels, ['Claude Code', 'Codex', 'Antigravity']); + expect(g.memberLabels, ['Claude Code', 'Codex', 'OpenCode']); }); }); diff --git a/test/usage_model_test.dart b/test/usage_model_test.dart index b15e4ef..9d84112 100644 --- a/test/usage_model_test.dart +++ b/test/usage_model_test.dart @@ -39,8 +39,8 @@ void main() { group('UsageAgent.fromJson', () { test('parses nested quotas and propagates the expired flag', () { final UsageAgent agent = UsageAgent.fromJson({ - 'key': 'agy', - 'label': 'Antigravity', + 'key': 'codex', + 'label': 'Codex', 'available': true, 'stale': true, 'asOf': '2026-06-19T07:00:00.000Z', @@ -49,7 +49,7 @@ void main() { {'key': 'seven_day', 'expired': false}, ], }); - expect(agent.key, 'agy'); + expect(agent.key, 'codex'); expect(agent.available, isTrue); expect(agent.stale, isTrue); expect(agent.quotas, hasLength(2)); @@ -59,13 +59,13 @@ void main() { test('handles an unavailable agent with no quotas', () { final UsageAgent agent = UsageAgent.fromJson({ - 'key': 'agy', - 'label': 'Antigravity', + 'key': 'codex', + 'label': 'Codex', 'available': false, - 'unavailableReason': 'start agy once', + 'unavailableReason': 'codex is not logged in', }); expect(agent.available, isFalse); - expect(agent.unavailableReason, 'start agy once'); + expect(agent.unavailableReason, 'codex is not logged in'); expect(agent.quotas, isEmpty); }); });